Compare commits

...

48 Commits

Author SHA1 Message Date
KS Jannette
63eecaaf72 m
Some checks are pending
check / check (push) Waiting to run
2026-03-02 03:42:58 -05:00
KS Jannette
8a5f5db903 refact css sytem 2026-03-02 03:36:21 -05:00
KS Jannette
c2515472b2 more css hottie 2026-03-02 00:34:34 -05:00
S Jannette
5ad51271af Merge pull request #12 from kjannette/migrate-inline-css
Migrate inline css
2026-03-02 00:28:46 -05:00
KS Jannette
b3a08e8f25 css trick
Some checks are pending
check / check (push) Waiting to run
2026-03-02 00:28:13 -05:00
KS Jannette
8716be0201 fudge 2026-03-02 00:09:24 -05:00
KS Jannette
647be874e5 m 2026-03-02 00:09:01 -05:00
KS Jannette
c12c4fe742 add image 2026-03-01 23:14:25 -05:00
S Jannette
5f5253d495 Revise README for clarity and feature description
Updated the README to clarify features and improve readability.
2026-03-01 21:48:53 -05:00
S Jannette
bbe2374703 Merge pull request #11 from kjannette/history-reconfig-possible-bugfix
History reconfig possible bugfix
2026-03-01 20:38:56 -05:00
KS Jannette
205317c920 update docs
Some checks are pending
check / check (push) Waiting to run
2026-03-01 20:35:08 -05:00
KS Jannette
25850825b4 updated types and poller 2026-03-01 20:32:21 -05:00
S Jannette
f01115de86 Merge pull request #10 from kjannette/webhook-configs
webhook configs for users, changes to system
2026-03-01 19:08:42 -05:00
KS Jannette
6735000050 webhook configs for users, changes to system
Some checks are pending
check / check (push) Waiting to run
2026-03-01 19:06:32 -05:00
KS Jannette
42a64fc043 Add multi-step onboarding wizard to replace bare signup form
Introduces a 5-step guided flow (Create Account → Add Wallet → Alert
Rules → Notifications → Done) that takes new users from zero to fully
configured alerts in one linear session. /signup now redirects to
/onboarding; already-onboarded users who revisit /onboarding are
redirected to /addresses.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 19:59:04 -05:00
S Jannette
6d11b273f1 Merge pull request #1 from kjannette/go-get-em
move over
2026-02-27 16:41:38 -05:00
91 changed files with 8276 additions and 4677 deletions

3
.dockerignore Normal file
View File

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

15
.editorconfig Normal file
View File

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

View File

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

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Node
node_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Go build artifacts
backend-go/bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
vendor/
tmp/
# Frontend build output
frontend/dist/
frontend/build/
frontend/.vite/
# Prompts directory (local tooling, not part of the project)
Prompts/
# Logs
*.log
.claude

7
.prettierignore Normal file
View File

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

4
.prettierrc Normal file
View File

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

22
Dockerfile Normal file
View File

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

21
LICENSE Normal file
View File

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

90
Makefile Normal file
View File

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

211
README.md
View File

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

188
REPO_POLICIES.md Normal file
View File

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

View File

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

32
backend-go/.golangci.yml Normal file
View File

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

View File

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

View File

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

BIN
backend-go/api Executable file

Binary file not shown.

View File

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

View File

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

View File

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

View File

@@ -92,6 +92,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v3 v3.1.1 h1:Uwpf/tZU+O/r/3nMWE6zUAMIG9dX/vTBS3wlQzYJKSw=
github.com/resend/resend-go/v3 v3.1.1/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log"
"math/big" "math/big"
"net/http" "net/http"
"strings" "strings"
@@ -13,7 +15,11 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
) )
const rpcTimeoutMS = 30000 const (
rpcTimeoutMS = 30000
rpcMaxRetries = 3
rpcRetryBaseMS = 1000
)
type JsonRpcEthereum struct { type JsonRpcEthereum struct {
rpcURL string rpcURL string
@@ -66,6 +72,44 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
return nil, fmt.Errorf("marshal RPC request: %w", err) return nil, fmt.Errorf("marshal RPC request: %w", err)
} }
return j.callWithRetry(ctx, method, body)
}
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
// It retries on network errors, HTTP 429, and HTTP 5xx. It does NOT retry on RPC-level
// errors or other 4xx responses (those are permanent failures).
func (j *JsonRpcEthereum) callWithRetry(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
var lastErr error
for attempt := range rpcMaxRetries {
if attempt > 0 {
wait := time.Duration(rpcRetryBaseMS*(1<<(attempt-1))) * time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
log.Printf("Retrying RPC call [%s] (attempt %d/%d)", method, attempt+1, rpcMaxRetries)
}
result, err := j.doRPCCall(ctx, method, body)
if err == nil {
return result, nil
}
lastErr = err
// Permanent errors: do not retry
if isPermanentRPCError(err) {
return nil, err
}
log.Printf("Transient RPC error [%s] (attempt %d/%d): %v", method, attempt+1, rpcMaxRetries, err)
}
return nil, lastErr
}
func (j *JsonRpcEthereum) doRPCCall(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, fmt.Errorf("create RPC request: %w", err) return nil, fmt.Errorf("create RPC request: %w", err)
@@ -78,8 +122,13 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
} }
defer resp.Body.Close() defer resp.Body.Close()
// 429 and 5xx are transient; other non-200 are permanent.
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method) err := fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { //nolint:mnd
return nil, err // transient — will be retried
}
return nil, &permanentRPCError{err}
} }
var rpcResp rpcResponse var rpcResp rpcResponse
@@ -88,12 +137,25 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
} }
if rpcResp.Error != nil { if rpcResp.Error != nil {
return nil, fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code) // RPC-level errors are permanent (bad params, unsupported method, etc.)
return nil, &permanentRPCError{
fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code),
}
} }
return rpcResp.Result, nil return rpcResp.Result, nil
} }
type permanentRPCError struct{ cause error }
func (e *permanentRPCError) Error() string { return e.cause.Error() }
func (e *permanentRPCError) Unwrap() error { return e.cause }
func isPermanentRPCError(err error) bool {
var p *permanentRPCError
return errors.As(err, &p)
}
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) { func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
result, err := j.callRPC(ctx, "eth_blockNumber") result, err := j.callRPC(ctx, "eth_blockNumber")
if err != nil { if err != nil {
@@ -180,6 +242,132 @@ func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (strin
return hexToDecimalString(hexBalance), nil return hexToDecimalString(hexBalance), nil
} }
const erc20TransferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
const minTransferTopics = 3
type rpcLog struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
BlockNumber string `json:"blockNumber"`
TxHash string `json:"transactionHash"`
}
// GetTokenTransfers fetches ERC-20 Transfer events for a given address
// across a block range, covering both incoming and outgoing transfers.
func (j *JsonRpcEthereum) GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error) {
paddedAddr := padAddress(address)
hexFrom := fmt.Sprintf("0x%x", fromBlock)
hexTo := fmt.Sprintf("0x%x", toBlock)
incomingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, "", paddedAddr)
if err != nil {
return nil, fmt.Errorf("fetch incoming token transfers: %w", err)
}
outgoingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, paddedAddr, "")
if err != nil {
return nil, fmt.Errorf("fetch outgoing token transfers: %w", err)
}
seen := make(map[string]bool)
var txs []domain.NormalizedTx
for _, entry := range append(incomingLogs, outgoingLogs...) {
key := entry.TxHash + "|" + entry.Address + "|" + entry.Data
if seen[key] {
continue
}
seen[key] = true
tx, parseErr := parseTransferLog(entry)
if parseErr != nil {
log.Printf("Skipping unparseable transfer log in tx %s: %v", entry.TxHash, parseErr)
continue
}
txs = append(txs, tx)
}
return txs, nil
}
func (j *JsonRpcEthereum) getTransferLogs(ctx context.Context, fromBlock, toBlock, fromAddr, toAddr string) ([]rpcLog, error) {
topics := make([]interface{}, minTransferTopics)
topics[0] = erc20TransferTopic
if fromAddr != "" {
topics[1] = fromAddr
}
if toAddr != "" {
topics[2] = toAddr
}
filter := map[string]interface{}{
"fromBlock": fromBlock,
"toBlock": toBlock,
"topics": topics,
}
result, err := j.callRPC(ctx, "eth_getLogs", filter)
if err != nil {
return nil, err
}
var logs []rpcLog
if err := json.Unmarshal(result, &logs); err != nil {
return nil, fmt.Errorf("unmarshal logs: %w", err)
}
return logs, nil
}
func parseTransferLog(entry rpcLog) (domain.NormalizedTx, error) {
if len(entry.Topics) < minTransferTopics {
return domain.NormalizedTx{}, fmt.Errorf("transfer log has %d topics, expected >= 3", len(entry.Topics))
}
from := topicToAddress(entry.Topics[1])
to := topicToAddress(entry.Topics[2])
tokenValue := hexToDecimalString(entry.Data)
blockNumber, _ := hexToInt(entry.BlockNumber)
contractAddr := strings.ToLower(entry.Address)
tokenInfo, known := LookupToken(contractAddr)
tx := domain.NormalizedTx{
Hash: entry.TxHash,
From: from,
To: &to,
Value: "0",
BlockNumber: blockNumber,
TokenContract: &contractAddr,
TokenValue: &tokenValue,
}
if known {
tx.TokenSymbol = &tokenInfo.Symbol
tx.TokenDecimals = &tokenInfo.Decimals
}
return tx, nil
}
func padAddress(addr string) string {
clean := strings.TrimPrefix(strings.ToLower(addr), "0x")
const addressHexLen = 64
return "0x" + strings.Repeat("0", addressHexLen-len(clean)) + clean
}
func topicToAddress(topic string) string {
clean := strings.TrimPrefix(topic, "0x")
const ethAddrLen = 40
if len(clean) > ethAddrLen {
clean = clean[len(clean)-ethAddrLen:]
}
return "0x" + strings.ToLower(clean)
}
func hexToInt(hex string) (int, error) { func hexToInt(hex string) (int, error) {
hex = strings.TrimPrefix(hex, "0x") hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16) n, ok := new(big.Int).SetString(hex, 16)

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"time"
"github.com/kjannette/koin-ping/backend-go/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
@@ -12,12 +13,20 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/wei" "github.com/kjannette/koin-ping/backend-go/internal/wei"
) )
const (
notificationTimeout = 30 * time.Second
notificationMaxRetries = 3
notificationRetryBase = time.Second
)
type EvaluatorService struct { type EvaluatorService struct {
eth ethereum.EthereumObserver eth ethereum.EthereumObserver
alertRules *models.AlertRuleModel alertRules *models.AlertRuleModel
alertEvents *models.AlertEventModel alertEvents *models.AlertEventModel
addresses *models.AddressModel addresses *models.AddressModel
notifConfigs *models.NotificationConfigModel notifConfigs *models.NotificationConfigModel
resendAPIKey string
emailFrom string
} }
func NewEvaluatorService( func NewEvaluatorService(
@@ -26,6 +35,8 @@ func NewEvaluatorService(
alertEvents *models.AlertEventModel, alertEvents *models.AlertEventModel,
addresses *models.AddressModel, addresses *models.AddressModel,
notifConfigs *models.NotificationConfigModel, notifConfigs *models.NotificationConfigModel,
resendAPIKey string,
emailFrom string,
) *EvaluatorService { ) *EvaluatorService {
return &EvaluatorService{ return &EvaluatorService{
eth: eth, eth: eth,
@@ -33,6 +44,8 @@ func NewEvaluatorService(
alertEvents: alertEvents, alertEvents: alertEvents,
addresses: addresses, addresses: addresses,
notifConfigs: notifConfigs, notifConfigs: notifConfigs,
resendAPIKey: resendAPIKey,
emailFrom: emailFrom,
} }
} }
@@ -161,21 +174,85 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
message := s.buildMessage(rule, obs) message := s.buildMessage(rule, obs)
txHash := &obs.Hash txHash := &obs.Hash
_, err = s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash) event, err := s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
if err != nil { if err != nil {
return err return err
} }
if event == nil {
log.Printf("[ALERT DEDUP] Rule %d (%s) - duplicate event skipped for TX: %s", rule.ID, rule.Type, obs.Hash)
return nil
}
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash) log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
// Send Discord notification (non-fatal on failure)
if addr != nil { if addr != nil {
go s.sendNotification(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address) userID := addr.UserID
address := addr.Address
go func() {
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
defer cancel()
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
}()
} }
return nil return nil
} }
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
var notifiers []notifications.Notifier
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
notifiers = append(notifiers, &notifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
}
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
notifiers = append(notifiers, &notifications.TelegramNotifier{
BotToken: *cfg.TelegramBotToken,
ChatID: *cfg.TelegramChatID,
})
}
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
notifiers = append(notifiers, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
}
if cfg.Email != nil && *cfg.Email != "" {
notifiers = append(notifiers, &notifications.EmailNotifier{
APIKey: s.resendAPIKey,
From: s.emailFrom,
To: *cfg.Email,
})
}
return notifiers
}
func sendWithRetry(ctx context.Context, n notifications.Notifier, message string, meta notifications.AlertMetadata) error {
var lastErr error
for attempt := range notificationMaxRetries {
if attempt > 0 {
wait := notificationRetryBase * time.Duration(1<<(attempt-1))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
if err := n.Send(ctx, message, meta); err != nil {
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
lastErr = err
continue
}
return nil
}
return lastErr
}
func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) { func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID) notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
if err != nil { if err != nil {
@@ -183,29 +260,31 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
return return
} }
if notifConfig == nil || !notifConfig.NotificationEnabled || notifConfig.DiscordWebhookURL == nil { if notifConfig == nil || !notifConfig.NotificationEnabled {
return return
} }
sent, err := notifications.SendDiscordNotification( meta := notifications.AlertMetadata{
*notifConfig.DiscordWebhookURL,
message,
notifications.AlertMetadata{
TxHash: obs.Hash, TxHash: obs.Hash,
AddressLabel: addressLabel, AddressLabel: addressLabel,
AlertType: string(rule.Type), AlertType: string(rule.Type),
Address: address, Address: address,
}, }
)
if err != nil || !sent { for _, n := range s.buildNotifiers(notifConfig) {
log.Printf("Discord notification failed for user %s: %v", userID, err) if err := sendWithRetry(ctx, n, message, meta); err != nil {
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
} else { } else {
log.Printf("Discord notification sent to user %s", userID) log.Printf("Notification sent to user %s via %T", userID, n)
}
} }
} }
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string { func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
if obs.IsTokenTransfer() {
return s.buildTokenMessage(rule, obs)
}
switch rule.Type { switch rule.Type {
case domain.AlertIncomingTx: case domain.AlertIncomingTx:
ethStr, _ := wei.FormatAsEth(obs.Value, 4) ethStr, _ := wei.FormatAsEth(obs.Value, 4)
@@ -230,3 +309,36 @@ func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.Observ
return "Alert triggered" return "Alert triggered"
} }
} }
const defaultTokenDecimals = 18
func (s *EvaluatorService) buildTokenMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
symbol := "tokens"
if obs.TokenSymbol != nil {
symbol = *obs.TokenSymbol
}
amount := "unknown"
if obs.TokenValue != nil {
decimals := defaultTokenDecimals
if obs.TokenDecimals != nil {
decimals = *obs.TokenDecimals
}
amount = wei.FormatTokenAmount(*obs.TokenValue, decimals)
}
switch rule.Type {
case domain.AlertIncomingTx:
return fmt.Sprintf("Incoming transfer: %s %s received", amount, symbol)
case domain.AlertOutgoingTx:
return fmt.Sprintf("Outgoing transfer: %s %s sent", amount, symbol)
case domain.AlertLargeTransfer:
threshold := float64(0)
if rule.Threshold != nil {
threshold = *rule.Threshold
}
return fmt.Sprintf("Large token transfer: %s %s (threshold: %g)", amount, symbol, threshold)
default:
return fmt.Sprintf("Token transfer: %s %s", amount, symbol)
}
}

View File

@@ -80,6 +80,15 @@ func (s *ObserverService) observeAddress(ctx context.Context, addr domain.Addres
} }
} }
tokenTxs, err := s.eth.GetTokenTransfers(ctx, startBlock, endBlock, addr.Address)
if err != nil {
log.Printf("Error fetching token transfers for %s: %v", addr.Address, err)
} else {
for _, tx := range tokenTxs {
observations = append(observations, createObservedTx(tx, addr))
}
}
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil { if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
return nil, err return nil, err
} }

View File

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

View File

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

View File

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

View File

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

4
frontend/prettierrc Normal file
View File

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

BIN
frontend/public/ping.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
* Includes Firebase ID token in Authorization header * Includes Firebase ID token in Authorization header
*/ */
import { auth } from '../firebase/config'; import { auth } from "../firebase/config";
/** /**
* Get headers with authentication token * Get headers with authentication token
@@ -15,15 +15,15 @@ export async function getAuthHeaders() {
const currentUser = auth.currentUser; const currentUser = auth.currentUser;
if (!currentUser) { if (!currentUser) {
throw new Error('No authenticated user'); throw new Error("No authenticated user");
} }
// Get Firebase ID token // Get Firebase ID token
const token = await currentUser.getIdToken(); const token = await currentUser.getIdToken();
return { return {
'Content-Type': 'application/json', "Content-Type": "application/json",
'Authorization': `Bearer ${token}` Authorization: `Bearer ${token}`,
}; };
} }
@@ -35,13 +35,12 @@ export async function getAuthHeadersSimple() {
const currentUser = auth.currentUser; const currentUser = auth.currentUser;
if (!currentUser) { if (!currentUser) {
throw new Error('No authenticated user'); throw new Error("No authenticated user");
} }
const token = await currentUser.getIdToken(); const token = await currentUser.getIdToken();
return { return {
'Authorization': `Bearer ${token}` Authorization: `Bearer ${token}`,
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
margin-bottom: 1.5rem;
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border-light);
}
.navbar__brand {
font-weight: 700;
font-size: 1.4rem;
color: var(--color-brand);
margin-right: 2rem;
letter-spacing: 0.5px;
}
.navbar__links {
display: flex;
gap: 0.25rem;
}
.navbar__link {
padding: 0.5rem 1.25rem;
border-radius: var(--radius-lg);
text-decoration: none;
font-size: 1.425rem;
font-weight: 200;
color: var(--color-text-subtle);
background-color: transparent;
transition: all 0.15s ease;
}
.navbar__link--active {
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg-card);
}
.navbar__user {
font-size: 1.275rem;
color: #777;
}
.navbar__logout {
padding: 0.4rem 1rem;
font-size: 1.275rem;
font-weight: 400;
background-color: transparent;
color: var(--color-text-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.15s ease;
}

View File

@@ -0,0 +1,45 @@
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import "./Navbar.css";
const navLinks = [
{ to: "/addresses", label: "Addresses" },
{ to: "/alerts", label: "Configure Alerts" },
{ to: "/alertevents", label: "Alert Events" },
];
export default function Navbar() {
const { currentUser, logout } = useAuth();
const location = useLocation();
if (!currentUser) return null;
return (
<nav className="navbar">
<div className="flex flex--center gap-sm">
<span className="navbar__brand">Koin Ping</span>
<div className="navbar__links">
{navLinks.map(({ to, label }) => {
const isActive = location.pathname === to;
return (
<Link
key={to}
to={to}
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
>
{label}
</Link>
);
})}
</div>
</div>
<div className="flex flex--center gap-lg">
<span className="navbar__user">{currentUser.email}</span>
<button onClick={logout} className="navbar__logout">
Logout
</button>
</div>
</nav>
);
}

View File

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

View File

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

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

@@ -0,0 +1,358 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap');
:root {
--color-primary: #0066cc;
--color-primary-hover: #0077ee;
--color-brand: #e62525;
--color-success: #28a745;
--color-success-text: #00ff00;
--color-success-bg: #00ff0020;
--color-success-border: #00ff00;
--color-error: #ff6666;
--color-error-bg: #ff000020;
--color-error-border: #ff0000;
--color-warning: #ffcc44;
--color-warning-bg: #3a2e00;
--color-warning-border: #aa7700;
--color-danger: #cc6666;
--color-danger-border: #884444;
--color-bg: #1a1a1a;
--color-bg-elevated: #242424;
--color-bg-card: #333;
--color-bg-section: #2a2a2a;
--color-border: #444;
--color-border-light: #333;
--color-border-section: #3a3a3a;
--color-text: white;
--color-text-muted: #b3b3b3;
--color-text-dimmed: #808080;
--color-text-subtle: #999;
--color-text-label: #ccc;
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
--radius-xl: 8px;
}
*,
*::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;
}
/* ── Page Layouts ─────────────────────────────────────────── */
.page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.page--wide {
max-width: 1400px;
}
/* ── Cards & Sections ─────────────────────────────────────── */
.card {
padding: 1rem;
background-color: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.section {
margin-bottom: 1.5rem;
padding: 1rem;
background-color: var(--color-bg-section);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-section);
}
/* ── Form Controls ────────────────────────────────────────── */
.form-field {
margin-bottom: 1.25rem;
}
.form-field--last {
margin-bottom: 1.75rem;
}
.form-label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.9rem;
color: var(--color-text-label);
}
.form-control {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
}
.form-control:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
}
.form-control--mono {
font-family: monospace;
}
.form-select {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
}
/* ── Buttons ──────────────────────────────────────────────── */
.btn {
padding: 0.5rem 1rem;
font-size: 1rem;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text);
transition: opacity 0.15s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn--primary {
background-color: var(--color-primary);
}
.btn--success {
background-color: var(--color-success);
}
.btn--secondary {
background-color: #6c757d;
}
.btn--danger {
background-color: transparent;
border: 1px solid var(--color-danger-border);
color: var(--color-danger);
}
.btn--ghost {
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-subtle);
}
.btn--sm {
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
.btn--lg {
padding: 0.75rem 1.5rem;
}
/* ── Feedback Alerts ──────────────────────────────────────── */
.alert {
padding: 0.75rem;
margin-bottom: 1rem;
border-radius: var(--radius-md);
}
.alert--error {
background-color: var(--color-error-bg);
border: 1px solid var(--color-error-border);
color: var(--color-error);
}
.alert--success {
background-color: var(--color-success-bg);
border: 1px solid var(--color-success-border);
color: var(--color-success-text);
}
.alert--warning {
background-color: var(--color-warning-bg);
border: 1px solid var(--color-warning-border);
color: var(--color-warning);
}
/* ── Lists ────────────────────────────────────────────────── */
.list-unstyled {
list-style: none;
padding: 0;
}
.list-item--card {
padding: 1rem;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-card);
}
/* ── Typography Helpers ───────────────────────────────────── */
.text-muted {
color: var(--color-text-muted);
}
.text-dimmed {
color: var(--color-text-dimmed);
}
.text-mono {
font-family: monospace;
}
.text-bold {
font-weight: bold;
}
.text-error {
color: red;
}
.text-sm {
font-size: 1.035rem;
}
.text-xs {
font-size: 0.978rem;
}
.help-link {
color: var(--color-primary);
font-size: 0.85rem;
}
/* ── Flex Utilities ───────────────────────────────────────── */
.flex {
display: flex;
}
.flex--between {
justify-content: space-between;
}
.flex--center {
align-items: center;
}
.flex--column {
flex-direction: column;
}
.flex--wrap {
flex-wrap: wrap;
}
.gap-xs {
gap: 0.25rem;
}
.gap-sm {
gap: 0.5rem;
}
.gap-md {
gap: 0.75rem;
}
.gap-lg {
gap: 1rem;
}
/* ── Spacing Helpers ──────────────────────────────────────── */
.mb-0 {
margin-bottom: 0;
}
.mb-sm {
margin-bottom: 0.5rem;
}
.mb-md {
margin-bottom: 1rem;
}
.mb-lg {
margin-bottom: 1.5rem;
}
.mb-xl {
margin-bottom: 2rem;
}
.mt-0 {
margin-top: 0;
}
.mt-md {
margin-top: 1rem;
}
.text-white {
color: white;
}
.text-white-sm {
color: white;
font-size: 0.85rem;
}

View File

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

View File

@@ -0,0 +1,30 @@
.address__edit-input {
background: var(--color-border);
border: 1px solid #666;
border-radius: var(--radius-sm);
color: var(--color-text);
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
}
.address__edit-link {
cursor: pointer;
background: transparent;
border: none;
color: #6699cc;
font-size: 0.8rem;
padding: 0;
text-decoration: underline;
}
.address__remove {
cursor: pointer;
background: transparent;
border: 1px solid var(--color-danger-border);
color: var(--color-danger);
border-radius: var(--radius-sm);
padding: 0.3rem 0.7rem;
font-size: 0.85rem;
margin-left: 1rem;
flex-shrink: 0;
}

View File

@@ -1,13 +1,15 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import AddressForm from "../components/AddressForm"; import AddressForm from "../components/AddressForm";
import { getAddresses, createAddress } from "../api/addresses"; import { getAddresses, createAddress, deleteAddress, updateAddress } from "../api/addresses";
import "./Addresses.css";
export default function Addresses() { export default function Addresses() {
const [addresses, setAddresses] = useState([]); const [addresses, setAddresses] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [editingId, setEditingId] = useState(null);
const [editLabel, setEditLabel] = useState("");
// Load addresses on mount
useEffect(() => { useEffect(() => {
async function fetchAddresses() { async function fetchAddresses() {
try { try {
@@ -25,54 +27,128 @@ export default function Addresses() {
fetchAddresses(); fetchAddresses();
}, []); }, []);
// Handle new address submission
async function handleAddressSubmit(data) { async function handleAddressSubmit(data) {
try { try {
const newAddress = await createAddress(data); const newAddress = await createAddress(data);
// Append new address to state
setAddresses((prev) => [...prev, newAddress]); setAddresses((prev) => [...prev, newAddress]);
setError(null); // Clear any previous errors setError(null);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to create address:", err); console.error("Failed to create address:", err);
} }
} }
return ( async function handleDelete(id, label) {
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}> const displayName = label || "this address";
<h1>Tracked Addresses</h1> if (!window.confirm(`Remove "${displayName}"? This will also delete all associated alert rules.`)) {
return;
}
try {
await deleteAddress(id);
setAddresses((prev) => prev.filter((a) => a.id !== id));
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to delete address:", err);
}
}
<div style={{ marginBottom: "2rem" }}> function handleEditStart(addr) {
setEditingId(addr.id);
setEditLabel(addr.label ?? "");
}
async function handleEditSave(id) {
try {
const updated = await updateAddress(id, { label: editLabel || null });
setAddresses((prev) => prev.map((a) => (a.id === id ? updated : a)));
setEditingId(null);
setEditLabel("");
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to update address:", err);
}
}
function handleEditCancel() {
setEditingId(null);
setEditLabel("");
}
return (
<div className="page">
<h1>Add Addresses to Track</h1>
<div className="mb-xl">
<AddressForm onSubmit={handleAddressSubmit} /> <AddressForm onSubmit={handleAddressSubmit} />
</div> </div>
<div> <div>
<h2>Existing Addresses</h2> <h2>Existing Tracked Addresses</h2>
{loading && <p>Loading addresses...</p>} {loading && <p>Loading addresses...</p>}
{error && <p style={{ color: "red" }}>Error: {error}</p>} {error && <p className="text-error">Error: {error}</p>}
{!loading && !error && addresses.length === 0 && ( {!loading && !error && addresses.length === 0 && (
<p style={{ color: "#666" }}> <p className="text-dimmed">
No addresses tracked yet. Add one above to get started. No addresses tracked yet. Add one above to get started.
</p> </p>
)} )}
{addresses.length > 0 && ( {addresses.length > 0 && (
<ul style={{ listStyle: "none", padding: 0 }}> <ul className="list-unstyled">
{addresses.map((addr, index) => ( {addresses.map((addr, index) => (
<li <li key={addr.id || index} className="list-item--card">
key={addr.id || index} <div className="flex flex--between flex--center">
style={{ <div>
padding: "1rem", {editingId === addr.id ? (
marginBottom: "0.5rem", <div className="flex flex--center gap-sm mb-sm">
border: "1px solid #ddd", <input
borderRadius: "4px", value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
placeholder="Label (optional)"
className="address__edit-input"
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave(addr.id);
if (e.key === "Escape") handleEditCancel();
}} }}
autoFocus
/>
<button
onClick={() => handleEditSave(addr.id)}
className="btn btn--primary btn--sm"
> >
<div style={{ fontWeight: "bold", marginBottom: "0.25rem" }}> Save
{addr.label || "Unlabeled"} </button>
<button
onClick={handleEditCancel}
className="btn btn--ghost btn--sm"
>
Cancel
</button>
</div> </div>
<div style={{ fontFamily: "monospace", fontSize: "0.9rem" }}> ) : (
<div className="flex flex--center gap-sm mb-sm">
<span className="text-bold">
{addr.label || "Unlabeled"}
</span>
<button
onClick={() => handleEditStart(addr)}
className="address__edit-link"
>
Edit
</button>
</div>
)}
<div className="text-mono text-sm text-muted">
{addr.address} {addr.address}
</div> </div>
</div>
<button
onClick={() => handleDelete(addr.id, addr.label)}
className="address__remove"
>
Remove
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -0,0 +1,7 @@
.alert-history__item {
padding: 1rem;
margin-bottom: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-card);
}

View File

@@ -1,12 +1,12 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { getAlertEvents } from "../api/alertEvents"; import { getAlertEvents } from "../api/alertEvents";
import "./AlertHistory.css";
export default function AlertHistory() { export default function AlertHistory() {
const [alertEvents, setAlertEvents] = useState([]); const [alertEvents, setAlertEvents] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Fetch alert events on mount
useEffect(() => { useEffect(() => {
async function fetchAlertEvents() { async function fetchAlertEvents() {
try { try {
@@ -25,38 +25,32 @@ export default function AlertHistory() {
}, []); }, []);
if (loading) { if (loading) {
return <div style={{ padding: "2rem" }}>Loading...</div>; return <div className="page">Loading...</div>;
} }
if (error) { if (error) {
return <div style={{ padding: "2rem", color: "red" }}>Error: {error}</div>; return (
<div className="page text-error">Error: {error}</div>
);
} }
return ( return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}> <div className="page">
<h1>Recent Alerts</h1> <h1>Recent Alert Events</h1>
{alertEvents.length === 0 ? ( {alertEvents.length === 0 ? (
<p style={{ color: "#666" }}>No alerts yet</p> <p className="text-dimmed">No alerts yet</p>
) : ( ) : (
<ul style={{ listStyle: "none", padding: 0 }}> <ul className="list-unstyled">
{alertEvents.map((event) => ( {alertEvents.map((event) => (
<li <li key={event.id} className="alert-history__item">
key={event.id} <div className="mb-sm">{event.message}</div>
style={{
padding: "1rem",
marginBottom: "0.75rem",
border: "1px solid #ddd",
borderRadius: "4px",
}}
>
<div style={{ marginBottom: "0.5rem" }}>{event.message}</div>
{event.address_label && ( {event.address_label && (
<div style={{ fontSize: "0.9rem", color: "#666", marginBottom: "0.25rem" }}> <div className="text-sm text-dimmed">
Address: {event.address_label} Address: {event.address_label}
</div> </div>
)} )}
<small style={{ color: "#999" }}> <small className="text-muted">
{formatTimestamp(event.timestamp)} {formatTimestamp(event.timestamp)}
</small> </small>
</li> </li>
@@ -67,7 +61,6 @@ export default function AlertHistory() {
); );
} }
// Helper to format timestamp for display
function formatTimestamp(timestamp) { function formatTimestamp(timestamp) {
const date = new Date(timestamp); const date = new Date(timestamp);
return date.toLocaleString(); return date.toLocaleString();

View File

@@ -0,0 +1,63 @@
.alerts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: start;
}
.alerts__address-info {
padding: 1rem;
margin-bottom: 2rem;
background-color: var(--color-bg-card);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.alerts__toggle-panel {
margin-bottom: 1.5rem;
padding: 1rem;
background-color: var(--color-bg-card);
border-radius: var(--radius-md);
}
.alerts__toggle-label {
display: flex;
align-items: center;
cursor: pointer;
}
.alerts__toggle-checkbox {
margin-right: 0.5rem;
width: 18px;
height: 18px;
}
.alerts__toggle-hint {
font-size: 0.978rem;
color: var(--color-text-dimmed);
margin-top: 0.5rem;
margin-left: 26px;
}
.alerts__rule {
padding: 1rem;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-card);
}
.alerts__rule--disabled {
opacity: 0.6;
}
.alerts__email-buttons {
display: flex;
gap: 0.5rem;
}
.alerts__save-test {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}

View File

@@ -3,8 +3,20 @@ import AlertForm from "../components/AlertForm";
import Button from "../components/Button"; import Button from "../components/Button";
import Input from "../components/Input"; import Input from "../components/Input";
import { getAddresses } from "../api/addresses"; import { getAddresses } from "../api/addresses";
import { getAlerts, createAlert, updateAlertStatus, deleteAlert } from "../api/alerts"; import {
import { getNotificationConfig, updateNotificationConfig, testDiscordWebhook } from "../api/notificationConfig"; getAlerts,
createAlert,
updateAlertStatus,
deleteAlert,
} from "../api/alerts";
import {
getNotificationConfig,
updateNotificationConfig,
testNotificationChannels,
setupEmail,
sendEmailDigest,
} from "../api/notificationConfig";
import "./Alerts.css";
export default function Alerts() { export default function Alerts() {
const [addresses, setAddresses] = useState([]); const [addresses, setAddresses] = useState([]);
@@ -13,33 +25,48 @@ export default function Alerts() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Notification config state const [notificationEnabled, setNotificationEnabled] = useState(false);
const [notificationConfig, setNotificationConfig] = useState(null); const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(''); const [telegramBotToken, setTelegramBotToken] = useState("");
const [notificationEnabled, setNotificationEnabled] = useState(true); const [telegramChatId, setTelegramChatId] = useState("");
const [email, setEmail] = useState("");
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
const [notificationLoading, setNotificationLoading] = useState(false); const [notificationLoading, setNotificationLoading] = useState(false);
const [notificationError, setNotificationError] = useState(null); const [notificationError, setNotificationError] = useState(null);
const [notificationSuccess, setNotificationSuccess] = useState(null); const [notificationSuccess, setNotificationSuccess] = useState(null);
const [testingWebhook, setTestingWebhook] = useState(false); const [testingChannels, setTestingChannels] = useState(false);
const [settingUpEmail, setSettingUpEmail] = useState(false);
const [sendingDigest, setSendingDigest] = useState(false);
const [hasExistingConfig, setHasExistingConfig] = useState(false);
// Load addresses and notification config on mount
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
try { try {
setLoading(true); setLoading(true);
// Fetch addresses
const addressData = await getAddresses(); const addressData = await getAddresses();
setAddresses(addressData); setAddresses(addressData);
if (addressData.length > 0) { if (addressData.length > 0) {
setSelectedAddressId(addressData[0].id); setSelectedAddressId(addressData[0].id);
} }
// Fetch notification config
const configData = await getNotificationConfig(); const configData = await getNotificationConfig();
setNotificationConfig(configData); setNotificationEnabled(
setDiscordWebhookUrl(configData.discord_webhook_url || ''); configData.notification_enabled !== false,
setNotificationEnabled(configData.notification_enabled !== false); );
setDiscordWebhookUrl(configData.discord_webhook_url || "");
setTelegramBotToken(configData.telegram_bot_token || "");
setTelegramChatId(configData.telegram_chat_id || "");
setEmail(configData.email || "");
setSlackWebhookUrl(configData.slack_webhook_url || "");
const hasSaved =
!!configData.discord_webhook_url ||
!!configData.telegram_bot_token ||
!!configData.telegram_chat_id ||
!!configData.email ||
!!configData.slack_webhook_url;
setHasExistingConfig(hasSaved);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to fetch data:", err); console.error("Failed to fetch data:", err);
@@ -51,7 +78,6 @@ export default function Alerts() {
fetchData(); fetchData();
}, []); }, []);
// Load alerts when address is selected
useEffect(() => { useEffect(() => {
if (!selectedAddressId) { if (!selectedAddressId) {
setAlerts([]); setAlerts([]);
@@ -62,7 +88,7 @@ export default function Alerts() {
try { try {
const data = await getAlerts(selectedAddressId); const data = await getAlerts(selectedAddressId);
setAlerts(data); setAlerts(data);
setError(null); // Clear any previous errors setError(null);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to fetch alerts:", err); console.error("Failed to fetch alerts:", err);
@@ -72,63 +98,68 @@ export default function Alerts() {
fetchAlerts(); fetchAlerts();
}, [selectedAddressId]); }, [selectedAddressId]);
// Handle new alert submission
async function handleAlertSubmit(data) { async function handleAlertSubmit(data) {
if (!selectedAddressId) return; if (!selectedAddressId) return;
try { try {
const newAlert = await createAlert(selectedAddressId, data); const newAlert = await createAlert(selectedAddressId, data);
setAlerts((prev) => [...prev, newAlert]); setAlerts((prev) => [...prev, newAlert]);
setError(null); // Clear any previous errors setError(null);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to create alert:", err); console.error("Failed to create alert:", err);
} }
} }
// Toggle alert enabled/disabled
async function handleToggleAlert(alertId, currentStatus) { async function handleToggleAlert(alertId, currentStatus) {
try { try {
const updated = await updateAlertStatus(alertId, !currentStatus); const updated = await updateAlertStatus(alertId, !currentStatus);
setAlerts((prev) => setAlerts((prev) =>
prev.map((alert) => (alert.id === alertId ? updated : alert)) prev.map((alert) => (alert.id === alertId ? updated : alert)),
); );
setError(null); // Clear any previous errors setError(null);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to update alert:", err); console.error("Failed to update alert:", err);
} }
} }
// Delete alert
async function handleDeleteAlert(alertId) { async function handleDeleteAlert(alertId) {
try { try {
await deleteAlert(alertId); await deleteAlert(alertId);
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId)); setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
setError(null); // Clear any previous errors setError(null);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to delete alert:", err); console.error("Failed to delete alert:", err);
} }
} }
// Save notification config
async function handleSaveNotificationConfig() { async function handleSaveNotificationConfig() {
if (hasExistingConfig) {
const confirmed = window.confirm(
"This will overwrite your previously saved notification settings. Continue?",
);
if (!confirmed) return;
}
try { try {
setNotificationLoading(true); setNotificationLoading(true);
setNotificationError(null); setNotificationError(null);
setNotificationSuccess(null); setNotificationSuccess(null);
const config = { const config = {
discord_webhook_url: discordWebhookUrl || null,
notification_enabled: notificationEnabled, notification_enabled: notificationEnabled,
discord_webhook_url: discordWebhookUrl || null,
telegram_bot_token: telegramBotToken || null,
telegram_chat_id: telegramChatId || null,
email: email || null,
slack_webhook_url: slackWebhookUrl || null,
}; };
const updated = await updateNotificationConfig(config); await updateNotificationConfig(config);
setNotificationConfig(updated); setHasExistingConfig(true);
setNotificationSuccess('Notification settings saved!'); setNotificationSuccess("Notification settings saved!");
// Clear success message after 3 seconds
setTimeout(() => setNotificationSuccess(null), 3000); setTimeout(() => setNotificationSuccess(null), 3000);
} catch (err) { } catch (err) {
setNotificationError(err.message); setNotificationError(err.message);
@@ -138,75 +169,112 @@ export default function Alerts() {
} }
} }
// Test Discord webhook async function handleTestChannels() {
async function handleTestWebhook() {
if (!discordWebhookUrl) {
setNotificationError('Please enter a Discord webhook URL first');
return;
}
try { try {
setTestingWebhook(true); setTestingChannels(true);
setNotificationError(null); setNotificationError(null);
setNotificationSuccess(null); setNotificationSuccess(null);
const success = await testDiscordWebhook(discordWebhookUrl); const data = await testNotificationChannels();
const results = data.results || [];
if (success) { const failed = results.filter((r) => !r.success);
setNotificationSuccess('Test notification sent! Check your Discord channel.'); const succeeded = results.filter((r) => r.success);
setTimeout(() => setNotificationSuccess(null), 5000);
} else { if (failed.length === 0 && succeeded.length > 0) {
setNotificationError('Test failed. Check your webhook URL.'); setNotificationSuccess(
`Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
);
} else if (failed.length > 0 && succeeded.length > 0) {
setNotificationSuccess(
`Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
);
} else if (failed.length > 0) {
setNotificationError(
`Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
);
} }
setTimeout(() => {
setNotificationSuccess(null);
setNotificationError(null);
}, 6000);
} catch (err) { } catch (err) {
setNotificationError('Test failed: ' + err.message); setNotificationError(err.message);
} finally { } finally {
setTestingWebhook(false); setTestingChannels(false);
}
}
async function handleSetupEmail() {
try {
setSettingUpEmail(true);
setNotificationError(null);
setNotificationSuccess(null);
await handleSaveNotificationConfig();
const result = await setupEmail();
setNotificationSuccess(
result.message || "Confirmation email sent!",
);
setTimeout(() => setNotificationSuccess(null), 5000);
} catch (err) {
setNotificationError(err.message);
} finally {
setSettingUpEmail(false);
}
}
async function handleSendDigest() {
try {
setSendingDigest(true);
setNotificationError(null);
setNotificationSuccess(null);
const result = await sendEmailDigest();
setNotificationSuccess(result.message || "Digest email sent!");
setTimeout(() => setNotificationSuccess(null), 5000);
} catch (err) {
setNotificationError(err.message);
} finally {
setSendingDigest(false);
} }
} }
const selectedAddress = addresses.find((a) => a.id === selectedAddressId); const selectedAddress = addresses.find((a) => a.id === selectedAddressId);
if (loading) { if (loading) {
return <div style={{ padding: "2rem" }}>Loading addresses...</div>; return <div className="page">Loading addresses...</div>;
} }
if (addresses.length === 0) { if (addresses.length === 0) {
return ( return (
<div style={{ padding: "2rem" }}> <div className="page">
<p>No addresses tracked yet. Add an address first to create alerts.</p> <p>No addresses tracked yet. Add an address first to create alerts.</p>
</div> </div>
); );
} }
return ( return (
<div style={{ maxWidth: "1400px", margin: "0 auto", padding: "2rem" }}> <div className="page page--wide">
<h1 style={{ marginBottom: "2rem" }}>Alert Rules & Notifications</h1> <h1 className="mb-xl">Alert Rules & Notifications</h1>
{/* Two-column layout */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "2rem" }}>
<div className="alerts-grid">
{/* LEFT COLUMN: Alert Rules */} {/* LEFT COLUMN: Alert Rules */}
<div> <div>
<h2 style={{ marginTop: 0 }}>Alert Rules</h2> <h2 className="mt-0">Alert Rules</h2>
{/* Address selector */} <div className="mb-xl">
<div style={{ marginBottom: "2rem" }}> <label className="form-label mb-sm">
<label style={{ display: "block", marginBottom: "0.5rem" }}>
<strong>Select Address:</strong> <strong>Select Address:</strong>
</label> </label>
<select <select
value={selectedAddressId || ""} value={selectedAddressId || ""}
onChange={(e) => setSelectedAddressId(Number(e.target.value))} onChange={(e) =>
style={{ setSelectedAddressId(Number(e.target.value))
width: "100%", }
padding: "0.5rem", className="form-select"
fontSize: "1rem",
backgroundColor: "#1a1a1a",
border: "1px solid #444",
borderRadius: "4px",
color: "white"
}}
> >
{addresses.map((addr) => ( {addresses.map((addr) => (
<option key={addr.id} value={addr.id}> <option key={addr.id} value={addr.id}>
@@ -218,82 +286,64 @@ export default function Alerts() {
{selectedAddress && ( {selectedAddress && (
<> <>
{/* Current address info */} <div className="alerts__address-info">
<div <div className="text-sm text-muted">
style={{
padding: "1rem",
marginBottom: "2rem",
backgroundColor: "#2a2a2a",
borderRadius: "4px",
border: "1px solid #444"
}}
>
<div style={{ fontSize: "0.9rem", color: "#999" }}>
Managing alerts for: Managing alerts for:
</div> </div>
<div style={{ fontWeight: "bold", marginTop: "0.25rem" }}> <div className="text-bold">
{selectedAddress.label || "Unlabeled"} {selectedAddress.label || "Unlabeled"}
</div> </div>
<div style={{ fontFamily: "monospace", fontSize: "0.9rem", color: "#999" }}> <div className="text-mono text-sm text-muted">
{selectedAddress.address} {selectedAddress.address}
</div> </div>
</div> </div>
{/* Alert creation form */} <div className="mb-xl">
<div style={{ marginBottom: "2rem" }}>
<h3>Create New Alert</h3> <h3>Create New Alert</h3>
<AlertForm onSubmit={handleAlertSubmit} /> <AlertForm onSubmit={handleAlertSubmit} />
</div> </div>
{/* Existing alerts list */}
<div> <div>
<h3>Active Alert Rules</h3> <h3>Active Alert Rules</h3>
{error && <p style={{ color: "red" }}>Error: {error}</p>} {error && (
<p className="text-error">Error: {error}</p>
)}
{alerts.length === 0 ? ( {alerts.length === 0 ? (
<p style={{ color: "#666" }}> <p className="text-dimmed">
No alert rules defined yet. Create one above. No alert rules defined yet. Create one above.
</p> </p>
) : ( ) : (
<ul style={{ listStyle: "none", padding: 0 }}> <ul className="list-unstyled">
{alerts.map((alert) => ( {alerts.map((alert) => (
<li <li
key={alert.id} key={alert.id}
style={{ className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
padding: "1rem",
marginBottom: "0.5rem",
border: "1px solid #444",
borderRadius: "4px",
backgroundColor: "#2a2a2a",
opacity: alert.enabled ? 1 : 0.6,
}}
> >
<div <div className="flex flex--between">
style={{ <div>
display: "flex", <div className="text-bold mb-sm">
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: "bold", marginBottom: "0.25rem" }}>
{formatAlertType(alert.type)} {formatAlertType(alert.type)}
</div> </div>
{alert.threshold && ( {alert.threshold && (
<div style={{ fontSize: "0.9rem", color: "#999" }}> <div className="text-sm text-muted">
Threshold: {alert.threshold} ETH Threshold: {alert.threshold} ETH
</div> </div>
)} )}
<div style={{ fontSize: "0.85rem", color: "#666", marginTop: "0.25rem" }}> <div className="text-xs text-dimmed">
Status: {alert.enabled ? "Enabled" : "Disabled"} Status: {alert.enabled ? "Enabled" : "Disabled"}
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: "0.5rem" }}> <div className="flex flex--center gap-sm">
<Button <Button
onClick={() => handleToggleAlert(alert.id, alert.enabled)} onClick={() =>
handleToggleAlert(alert.id, alert.enabled)
}
> >
{alert.enabled ? "Disable" : "Enable"} {alert.enabled ? "Disable" : "Enable"}
</Button> </Button>
<Button onClick={() => handleDeleteAlert(alert.id)}> <Button
onClick={() => handleDeleteAlert(alert.id)}
>
Delete Delete
</Button> </Button>
</div> </div>
@@ -309,157 +359,158 @@ export default function Alerts() {
{/* RIGHT COLUMN: Notification Settings */} {/* RIGHT COLUMN: Notification Settings */}
<div> <div>
<h2 style={{ marginTop: 0 }}>Notification Settings</h2> <h2 className="mt-0">Notification Settings</h2>
{notificationSuccess && ( {notificationSuccess && (
<div style={{ <div className="alert alert--success">{notificationSuccess}</div>
padding: '0.75rem',
marginBottom: '1rem',
backgroundColor: '#00ff0020',
border: '1px solid #00ff00',
borderRadius: '4px',
color: '#00ff00'
}}>
{notificationSuccess}
</div>
)} )}
{notificationError && ( {notificationError && (
<div style={{ <div className="alert alert--error">{notificationError}</div>
padding: '0.75rem',
marginBottom: '1rem',
backgroundColor: '#ff000020',
border: '1px solid #ff0000',
borderRadius: '4px',
color: '#ff6666'
}}>
{notificationError}
</div>
)} )}
{/* Master toggle */} {/* Master toggle */}
<div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: '#f5f5f5', borderRadius: '4px' }}> <div className="alerts__toggle-panel">
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}> <label className="alerts__toggle-label">
<input <input
type="checkbox" type="checkbox"
checked={notificationEnabled} checked={notificationEnabled}
onChange={(e) => setNotificationEnabled(e.target.checked)} onChange={(e) =>
style={{ marginRight: '0.5rem', width: '18px', height: '18px' }} setNotificationEnabled(e.target.checked)
}
className="alerts__toggle-checkbox"
/> />
<span style={{ fontWeight: 'bold' }}>Enable Notifications</span> <span className="text-bold">Enable Notifications</span>
</label> </label>
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.5rem', marginLeft: '26px' }}> <div className="alerts__toggle-hint">
Master switch for all notification channels Master switch for all notification channels
</div> </div>
</div> </div>
{/* Discord Section */} {/* All channel settings -- hidden when master toggle is off */}
<div style={{ marginBottom: '2rem' }}> {notificationEnabled && (
<h3 style={{ marginBottom: '1rem' }}>Discord</h3> <>
{/* Telegram */}
<div style={{ marginBottom: '1rem' }}> <div className="section">
<label style={{ display: 'block', marginBottom: '0.5rem' }}> <h3 className="mt-0 mb-md">Telegram</h3>
Discord Webhook URL <Input
</label> label="Bot Token"
<input value={telegramBotToken}
type="text" onChange={setTelegramBotToken}
value={discordWebhookUrl} placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
onChange={(e) => setDiscordWebhookUrl(e.target.value)} />
placeholder="https://discord.com/api/webhooks/..." <Input
style={{ label="Chat ID"
width: '100%', value={telegramChatId}
padding: '0.5rem', onChange={setTelegramChatId}
fontSize: '1rem', placeholder="-1001234567890"
backgroundColor: '#1a1a1a', />
border: '1px solid #444', <a
borderRadius: '4px', href="https://core.telegram.org/bots#how-do-i-create-a-bot"
color: 'white', target="_blank"
fontFamily: 'monospace', rel="noopener noreferrer"
fontSize: '0.9rem' className="help-link"
}} >
How to create a Telegram bot & get your Chat ID
</a>
</div>
{/* Email */}
<div className="section">
<h3 className="mt-0 mb-md">Email</h3>
<Input
label="Email Address"
type="email"
value={email}
onChange={setEmail}
placeholder="you@example.com"
/>
<div className="help-link text-dimmed mb-md">
Alert notifications and digests will be sent to this address
</div>
<div className="alerts__email-buttons">
<Button
onClick={handleSetupEmail}
disabled={settingUpEmail || !email}
className="btn--sm"
>
{settingUpEmail ? "Setting up..." : "Verify Email"}
</Button>
<Button
onClick={handleSendDigest}
disabled={sendingDigest || !email}
variant="secondary"
className="btn--sm"
>
{sendingDigest ? "Sending..." : "Send Digest Now"}
</Button>
</div>
</div>
{/* Discord */}
<div className="section">
<h3 className="mt-0 mb-md">Discord</h3>
<Input
label="Discord Webhook URL"
value={discordWebhookUrl}
onChange={setDiscordWebhookUrl}
placeholder="https://discord.com/api/webhooks/..."
/> />
<div style={{ fontSize: '0.85rem', color: '#999', marginTop: '0.5rem' }}>
<a <a
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks" href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ color: '#0066cc' }} className="help-link"
> >
How to get a Discord webhook URL How to get a Discord webhook URL
</a> </a>
</div> </div>
{/* Slack */}
<div className="section">
<h3 className="mt-0 mb-md">Slack</h3>
<Input
label="Slack Webhook URL"
value={slackWebhookUrl}
onChange={setSlackWebhookUrl}
placeholder="https://hooks.slack.com/services/..."
/>
<a
href="https://api.slack.com/messaging/webhooks"
target="_blank"
rel="noopener noreferrer"
className="help-link"
>
How to set up Slack Incoming Webhooks
</a>
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem' }}> {/* Save & Test buttons */}
<button <div className="alerts__save-test">
<Button
onClick={handleSaveNotificationConfig} onClick={handleSaveNotificationConfig}
disabled={notificationLoading} disabled={notificationLoading}
style={{ className="btn--lg"
padding: '0.75rem 1.5rem',
fontSize: '1rem',
backgroundColor: notificationLoading ? '#333' : '#0066cc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: notificationLoading ? 'not-allowed' : 'pointer'
}}
> >
{notificationLoading ? 'Saving...' : 'Save Settings'} {notificationLoading ? "Saving..." : "Save Settings"}
</button> </Button>
<Button
<button onClick={handleTestChannels}
onClick={handleTestWebhook} disabled={testingChannels}
disabled={testingWebhook || !discordWebhookUrl} variant="success"
style={{ className="btn--lg"
padding: '0.75rem 1.5rem',
fontSize: '1rem',
backgroundColor: testingWebhook || !discordWebhookUrl ? '#333' : '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: testingWebhook || !discordWebhookUrl ? 'not-allowed' : 'pointer'
}}
> >
{testingWebhook ? 'Testing...' : 'Test Webhook'} {testingChannels ? "Testing..." : "Test All Channels"}
</button> </Button>
</div> </div>
</>
)}
</div> </div>
{/* Telegram Section (Coming Soon) */}
<div style={{ marginBottom: '2rem', opacity: 0.5 }}>
<h3 style={{ marginBottom: '1rem' }}>Telegram</h3>
<div style={{
padding: '1rem',
backgroundColor: '#333',
borderRadius: '4px',
color: '#999',
textAlign: 'center'
}}>
Coming Soon
</div>
</div>
{/* Email Section (Coming Soon) */}
<div style={{ opacity: 0.5 }}>
<h3 style={{ marginBottom: '1rem' }}>Email</h3>
<div style={{
padding: '1rem',
backgroundColor: '#333',
borderRadius: '4px',
color: '#999',
textAlign: 'center'
}}>
Coming Soon
</div>
</div>
</div>
</div> </div>
</div> </div>
); );
} }
// Helper to format alert type for display
function formatAlertType(type) { function formatAlertType(type) {
const labels = { const labels = {
incoming_tx: "Incoming transaction", incoming_tx: "Incoming transaction",

View File

@@ -0,0 +1,62 @@
.login-page {
min-height: 100vh;
background-image: url(/ping.png);
background-size: 67%;
background-position: center;
background-repeat: no-repeat;
}
.login-card {
max-width: 400px;
margin: 20px auto;
padding: 2rem;
padding-top: 8rem;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
background-color: rgba(0, 0, 0, 0.75);
}
.login-heading {
margin-bottom: 2rem;
text-align: center;
}
.login-brand {
color: var(--color-brand);
}
.login-button {
width: 100%;
padding: 0.75rem;
font-size: 1.2rem;
margin-top: 16px;
;
}
.login-footer {
margin-top: 1.5rem;
text-align: center;
}
.login-signup-link {
color: var(--color-primary);
text-decoration: none;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.login-form-hidden {
opacity: 0;
}
.login-form-visible {
animation: fadeIn 2s ease-in-out forwards;
}

View File

@@ -1,17 +1,14 @@
/** import { useState } from "react";
* Login Page import { Link, useNavigate } from "react-router-dom";
* import { useAuth } from "../contexts/AuthContext";
* Allows existing users to sign in with email and password import Input from "../components/Input";
*/ import "./Login.css";
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function Login() { export default function Login() {
const [email, setEmail] = useState(''); const [isVisible, setIsVisible] = useState(false);
const [password, setPassword] = useState(''); const [email, setEmail] = useState("");
const [error, setError] = useState(''); const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { login } = useAuth(); const { login } = useAuth();
@@ -20,124 +17,67 @@ export default function Login() {
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
// Validation
if (!email || !password) { if (!email || !password) {
setError('Please fill in all fields'); setError("Please fill in all fields");
return; return;
} }
try { try {
setError(''); setError("");
setLoading(true); setLoading(true);
await login(email, password); await login(email, password);
navigate('/addresses'); // Redirect to main app navigate("/addresses");
} catch (err) { } catch (err) {
setError('Failed to log in: ' + err.message); setError("Failed to log in: " + err.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
return ( return (
<div style={{ <div className="login-page">
maxWidth: '400px', <div className="login-card" onMouseEnter={() => setIsVisible(true)}>
margin: '4rem auto', <h1 className="login-heading">
padding: '2rem', <span className="login-brand">Koin Ping</span> - Login
border: '1px solid #333',
borderRadius: '8px'
}}>
<h1 style={{ marginBottom: '2rem', textAlign: 'center' }}>
Koin Ping - Login
</h1> </h1>
{error && ( <div className={isVisible ? "login-form-visible" : "login-form-hidden"}>
<div style={{ {error && <div className="alert alert--error">{error}</div>}
padding: '0.75rem',
marginBottom: '1rem',
backgroundColor: '#ff000020',
border: '1px solid #ff0000',
borderRadius: '4px',
color: '#ff6666'
}}>
{error}
</div>
)}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1rem' }}> <Input
<label style={{ display: 'block', marginBottom: '0.5rem' }}> label="Email"
Email
</label>
<input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={setEmail}
disabled={loading} disabled={loading}
style={{
width: '100%',
padding: '0.5rem',
fontSize: '1rem',
backgroundColor: '#1a1a1a',
border: '1px solid #444',
borderRadius: '4px',
color: 'white'
}}
required required
/> />
</div> <Input
label="Password"
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Password
</label>
<input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={setPassword}
disabled={loading} disabled={loading}
style={{
width: '100%',
padding: '0.5rem',
fontSize: '1rem',
backgroundColor: '#1a1a1a',
border: '1px solid #444',
borderRadius: '4px',
color: 'white'
}}
required required
className="form-field--last"
/> />
</div>
<button <button type="submit" disabled={loading} className="btn btn--primary login-button">
type="submit" {loading ? "Logging in..." : "Log In"}
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> </button>
</form> </form>
<div style={{ marginTop: '1.5rem', textAlign: 'center' }}> <div className="login-footer">
<p style={{ color: '#999' }}> <p className="text-muted">
Don't have an account?{' '} Don't have an account?{" "}
<Link <Link to="/signup" className="login-signup-link">
to="/signup"
style={{ color: '#0066cc', textDecoration: 'none' }}
>
Sign up here Sign up here
</Link> </Link>
</p> </p>
</div> </div>
</div> </div>
</div>
</div>
); );
} }

View File

@@ -0,0 +1,185 @@
.onboarding {
min-height: 100vh;
background-color: var(--color-bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding-top: 3rem;
padding-bottom: 3rem;
}
.onboarding__container {
width: 100%;
max-width: 540px;
padding: 0 1rem;
}
.onboarding__title {
text-align: center;
margin-bottom: 2rem;
color: var(--color-primary);
letter-spacing: 0.5px;
}
.onboarding__card {
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
padding: 2rem;
}
.onboarding__login-link {
text-align: center;
margin-top: 1.25rem;
color: #888;
font-size: 0.9rem;
}
.onboarding__login-link a {
color: var(--color-primary);
}
/* Progress bar */
.progress-bar {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
}
.progress-bar__step {
display: flex;
align-items: center;
}
.progress-bar__connector {
width: 40px;
height: 2px;
margin: 0 4px;
}
.progress-bar__connector--active {
background-color: var(--color-primary);
}
.progress-bar__connector--inactive {
background-color: var(--color-border);
}
.progress-bar__dot {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.85rem;
color: white;
margin: 0 auto 4px;
}
.progress-bar__dot--done,
.progress-bar__dot--active {
background-color: var(--color-primary);
}
.progress-bar__dot--active {
border: 2px solid #4499ff;
}
.progress-bar__dot--pending {
background-color: var(--color-bg-card);
border: 2px solid transparent;
}
.progress-bar__label {
font-size: 0.7rem;
white-space: nowrap;
}
.progress-bar__label--active {
color: white;
}
.progress-bar__label--inactive {
color: #888;
}
/* Step 5 summary */
.onboarding__summary {
background-color: #1e2e1e;
border: 1px solid #2d5a2d;
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
}
.onboarding__summary-title {
margin: 0 0 0.5rem;
color: #90ee90;
font-weight: bold;
}
.onboarding__summary-list {
margin: 0;
padding-left: 1.25rem;
color: var(--color-text-label);
line-height: 1.8;
}
/* Checkbox rows */
.checkbox-row {
margin-bottom: 1rem;
}
.checkbox-row__label {
display: flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
color: #ddd;
}
.checkbox-row__input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.checkbox-row__nested {
margin-top: 0.5rem;
margin-left: 1.75rem;
}
/* Footer navigation */
.onboarding__footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border-light);
}
/* Test results */
.test-result {
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.test-result--success {
color: #90ee90;
}
.test-result--failure {
color: var(--color-error);
}
/* Step subtitle */
.onboarding__subtitle {
color: #aaa;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}

View File

@@ -0,0 +1,644 @@
/**
* Onboarding Wizard
*
* 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
*/
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { createAddress, getAddresses } from "../api/addresses";
import { createAlert } from "../api/alerts";
import {
updateNotificationConfig,
testNotificationChannels,
} from "../api/notificationConfig";
import Input from "../components/Input";
import Button from "../components/Button";
import "./Onboarding.css";
const STEPS = [
"Create Account",
"Add Wallet",
"Alert Rules",
"Notifications",
"Done",
];
export default function Onboarding() {
const { currentUser, signup } = useAuth();
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [skipWarning, setSkipWarning] = useState("");
const [testResults, setTestResults] = useState(null);
const [testLoading, setTestLoading] = useState(false);
const [data, setData] = useState({
email: "",
password: "",
confirmPassword: "",
walletAddress: "",
walletLabel: "",
createdAddressId: null,
alertIncomingTx: false,
alertOutgoingTx: false,
alertLargeTransfer: false,
largeTransferThreshold: "",
alertBalanceBelow: false,
balanceBelowThreshold: "",
discordWebhookUrl: "",
slackWebhookUrl: "",
notificationEmail: "",
alertsCreated: [],
notificationConfigured: false,
});
function set(field, value) {
setData((prev) => ({ ...prev, [field]: value }));
}
useEffect(() => {
if (!currentUser) return;
getAddresses()
.then((addresses) => {
if (addresses.length > 0) {
navigate("/addresses", { replace: true });
}
})
.catch(() => {});
}, [currentUser, navigate]);
// ── Step handlers ─────────────────────────────────────────────────────────
async function handleStep1() {
setError("");
if (!data.email || !data.password || !data.confirmPassword) {
setError("Please fill in all fields");
return;
}
if (data.password !== data.confirmPassword) {
setError("Passwords do not match");
return;
}
if (data.password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
if (!currentUser) {
try {
setLoading(true);
await signup(data.email, data.password);
} catch (err) {
if (err.code === "auth/email-already-in-use") {
setError("Email already in use. Try logging in instead.");
} else if (err.code === "auth/invalid-email") {
setError("Invalid email address");
} else if (err.code === "auth/weak-password") {
setError("Password is too weak");
} else {
setError("Failed to create account: " + err.message);
}
setLoading(false);
return;
} finally {
setLoading(false);
}
}
setStep(2);
}
async function handleStep2() {
setError("");
if (!data.walletAddress) {
setError("Please enter a wallet address");
return;
}
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
return;
}
try {
setLoading(true);
const created = await createAddress({
address: data.walletAddress,
label: data.walletLabel || undefined,
});
set("createdAddressId", created.id);
setStep(3);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep3() {
setError("");
const rules = [];
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
if (data.alertLargeTransfer) {
if (!data.largeTransferThreshold) {
setError("Please enter a threshold for large transfers");
return;
}
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
}
if (data.alertBalanceBelow) {
if (!data.balanceBelowThreshold) {
setError("Please enter a threshold for balance below");
return;
}
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
}
if (rules.length === 0) {
setStep(4);
return;
}
try {
setLoading(true);
const created = [];
for (const rule of rules) {
const result = await createAlert(data.createdAddressId, rule);
created.push(result);
}
set("alertsCreated", created);
setStep(4);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep4() {
setError("");
const hasAny =
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
if (!hasAny) {
setStep(5);
return;
}
try {
setLoading(true);
await updateNotificationConfig({
notification_enabled: true,
discord_webhook_url: data.discordWebhookUrl || undefined,
slack_webhook_url: data.slackWebhookUrl || undefined,
email: data.notificationEmail || undefined,
});
set("notificationConfigured", true);
setStep(5);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleTestChannels() {
setTestLoading(true);
setTestResults(null);
try {
const results = await testNotificationChannels();
setTestResults(results);
} catch (err) {
setTestResults({ error: err.message });
} finally {
setTestLoading(false);
}
}
// ── Progress bar ──────────────────────────────────────────────────────────
function ProgressBar() {
return (
<div className="progress-bar">
{STEPS.map((label, i) => {
const stepNum = i + 1;
const done = step > stepNum;
const active = step === stepNum;
const dotClass = done
? "progress-bar__dot--done"
: active
? "progress-bar__dot--active"
: "progress-bar__dot--pending";
return (
<div key={label} className="progress-bar__step">
{i > 0 && (
<div
className={`progress-bar__connector ${
done || active
? "progress-bar__connector--active"
: "progress-bar__connector--inactive"
}`}
/>
)}
<div>
<div className={`progress-bar__dot ${dotClass}`}>
{done ? "\u2713" : stepNum}
</div>
<div
className={`progress-bar__label ${
active
? "progress-bar__label--active"
: "progress-bar__label--inactive"
}`}
>
{label}
</div>
</div>
</div>
);
})}
</div>
);
}
// ── Step content ──────────────────────────────────────────────────────────
function Step1() {
return (
<>
<h2 className="mb-lg">Create your account</h2>
<Input
label="Email"
type="email"
value={data.email}
onChange={(v) => set("email", v)}
disabled={loading}
placeholder="you@example.com"
/>
<Input
label="Password"
type="password"
value={data.password}
onChange={(v) => set("password", v)}
disabled={loading}
placeholder="At least 6 characters"
/>
<Input
label="Confirm Password"
type="password"
value={data.confirmPassword}
onChange={(v) => set("confirmPassword", v)}
disabled={loading}
placeholder="Repeat your password"
className="form-field--last"
/>
</>
);
}
function Step2() {
return (
<>
<h2 className="mb-sm">Add a wallet address</h2>
<p className="onboarding__subtitle">
Enter the Ethereum address you want to monitor.
</p>
<Input
label="ETH Address"
value={data.walletAddress}
onChange={(v) => set("walletAddress", v)}
disabled={loading}
placeholder="0x..."
/>
<Input
label="Label (optional)"
value={data.walletLabel}
onChange={(v) => set("walletLabel", v)}
disabled={loading}
placeholder="e.g. My main wallet"
className="form-field--last"
/>
</>
);
}
function Step3() {
return (
<>
<h2 className="mb-sm">Configure alert rules</h2>
<p className="onboarding__subtitle">
Choose which events trigger notifications. You can change these later.
</p>
<CheckboxRow
checked={data.alertIncomingTx}
onChange={(v) => set("alertIncomingTx", v)}
label="Incoming transaction"
/>
<CheckboxRow
checked={data.alertOutgoingTx}
onChange={(v) => set("alertOutgoingTx", v)}
label="Outgoing transaction"
/>
<CheckboxRow
checked={data.alertLargeTransfer}
onChange={(v) => set("alertLargeTransfer", v)}
label="Large transfer"
>
{data.alertLargeTransfer && (
<div className="checkbox-row__nested">
<Input
type="number"
label=""
value={data.largeTransferThreshold}
onChange={(v) => set("largeTransferThreshold", v)}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
<CheckboxRow
checked={data.alertBalanceBelow}
onChange={(v) => set("alertBalanceBelow", v)}
label="Balance below"
>
{data.alertBalanceBelow && (
<div className="checkbox-row__nested">
<Input
type="number"
label=""
value={data.balanceBelowThreshold}
onChange={(v) => set("balanceBelowThreshold", v)}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
</>
);
}
function Step4() {
return (
<>
<h2 className="mb-sm">Set up notifications</h2>
<p className="onboarding__subtitle">
Add at least one channel so you receive alerts. All fields are optional.
</p>
<div className="mb-md">
<label className="form-label">
Discord Webhook URL{" "}
<a
href="https://support.discord.com/hc/en-us/articles/228383668"
target="_blank"
rel="noreferrer"
className="help-link"
>
(how to get one)
</a>
</label>
<Input
label=""
type="url"
value={data.discordWebhookUrl}
onChange={(v) => set("discordWebhookUrl", v)}
disabled={loading}
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
<div className="mb-md">
<label className="form-label">
Slack Webhook URL{" "}
<a
href="https://api.slack.com/messaging/webhooks"
target="_blank"
rel="noreferrer"
className="help-link"
>
(how to get one)
</a>
</label>
<Input
label=""
type="url"
value={data.slackWebhookUrl}
onChange={(v) => set("slackWebhookUrl", v)}
disabled={loading}
placeholder="https://hooks.slack.com/services/..."
/>
</div>
<Input
label="Email address for alerts"
type="email"
value={data.notificationEmail}
onChange={(v) => set("notificationEmail", v)}
disabled={loading}
placeholder="you@example.com"
className="form-field--last"
/>
</>
);
}
function Step5() {
const alertCount = data.alertsCreated.length;
const hasNotif = data.notificationConfigured;
return (
<>
<h2 className="mb-md">You're all set!</h2>
<div className="onboarding__summary">
<p className="onboarding__summary-title">Summary</p>
<ul className="onboarding__summary-list">
<li>
Wallet address added:{" "}
<span className="text-mono text-white-sm">
{data.walletAddress}
</span>
{data.walletLabel && ` (${data.walletLabel})`}
</li>
<li>
Alert rules configured:{" "}
<span className="text-white">
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
</span>
</li>
<li>
Notification channels:{" "}
<span className="text-white">
{hasNotif ? "Configured" : "Not set up (skipped)"}
</span>
</li>
</ul>
</div>
{hasNotif && (
<div className="mb-lg">
<Button
onClick={handleTestChannels}
disabled={testLoading}
variant="ghost"
>
{testLoading ? "Testing..." : "Test All Channels"}
</Button>
{testResults && (
<div className="mt-md">
{testResults.error ? (
<p className="text-error">{testResults.error}</p>
) : (
<ul className="list-unstyled">
{Object.entries(testResults).map(([channel, result]) => (
<li
key={channel}
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
>
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
{result.message || (result.success ? "OK" : "Failed")}
</li>
))}
</ul>
)}
</div>
)}
</div>
)}
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
Go to Dashboard
</Button>
</>
);
}
// ── Shared helpers ────────────────────────────────────────────────────────
function CheckboxRow({ checked, onChange, label, children }) {
return (
<div className="checkbox-row">
<label className="checkbox-row__label">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="checkbox-row__input"
/>
{label}
</label>
{children}
</div>
);
}
// ── Footer navigation ─────────────────────────────────────────────────────
function Footer() {
if (step === 5) return null;
const canSkip = step === 3 || step === 4;
const canBack = step > 1;
async function handleNext() {
setSkipWarning("");
if (step === 1) await handleStep1();
else if (step === 2) await handleStep2();
else if (step === 3) await handleStep3();
else if (step === 4) await handleStep4();
}
function handleSkip() {
setError("");
setSkipWarning("");
setStep((s) => s + 1);
}
function handleBack() {
setError("");
setSkipWarning("");
setStep((s) => s - 1);
}
return (
<div className="onboarding__footer">
<div>
{canBack && (
<Button
onClick={handleBack}
disabled={loading}
variant="ghost"
>
Back
</Button>
)}
</div>
<div className="flex gap-md">
{canSkip && (
<Button
onClick={handleSkip}
disabled={loading}
variant="ghost"
>
Skip for now
</Button>
)}
<Button
onClick={handleNext}
disabled={loading}
className="text-bold"
>
{loading ? "Please wait..." : step === 4 ? "Finish" : "Next →"}
</Button>
</div>
</div>
);
}
// ── Render ────────────────────────────────────────────────────────────────
const stepContent = {
1: <Step1 />,
2: <Step2 />,
3: <Step3 />,
4: <Step4 />,
5: <Step5 />,
};
return (
<div className="onboarding">
<div className="onboarding__container">
<h1 className="onboarding__title">Koin Ping</h1>
<ProgressBar />
{error && (
<div className="alert alert--error">{error}</div>
)}
{skipWarning && (
<div className="alert alert--warning">{skipWarning}</div>
)}
<div className="onboarding__card">
{stepContent[step]}
<Footer />
</div>
{step === 1 && (
<p className="onboarding__login-link">
Already have an account?{" "}
<a href="/login">Log in here</a>
</p>
)}
</div>
</div>
);
}

View File

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

View File

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

View File

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