Compare commits

...

15 Commits

Author SHA1 Message Date
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
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
59 changed files with 5616 additions and 4577 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 .

44
.gitignore vendored Normal file
View File

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

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/

183
README.md
View File

@@ -1,95 +1,122 @@
# Koin Ping # Koin Ping
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about. Koin Ping is an MIT-licensed blockchain monitoring system by Steven Jannette
that polls Ethereum addresses for on-chain activity and delivers real-time
## Overview alerts to users via a Go REST API backend and a React single-page application
frontend.
Koin Ping is designed to reliably observe on-chain activity and notify users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
## Project Structure
```
koin_ping/
├── backend/ # Node.js + Express + PostgreSQL backend
│ ├── api/ # API endpoints and server configuration
│ ├── poller/ # Blockchain polling logic
│ ├── alerts/ # Alert evaluation and management
│ ├── notifications/ # Notification delivery system
│ ├── domain/ # Domain models and business logic
│ ├── infra/ # Infrastructure (database, external services)
│ └── shared/ # Shared utilities and helpers
└── frontend/ # React + Vite frontend
├── public/ # Static assets
└── src/
├── api/ # Frontend API calls to backend
├── components/ # Reusable UI components
├── pages/ # Top-level pages (views)
└── utils/ # Utility functions
```
## Tech Stack
### Backend
- **Runtime:** Node.js
- **Framework:** Express
- **Database:** PostgreSQL
- **Key Dependencies:**
- `pg` - PostgreSQL client
- `dotenv` - Environment variable management
- `cors` - CORS middleware
- `nodemon` - Development auto-reload
### Frontend
- **Framework:** React 19
- **Build Tool:** Vite
- **Language:** JavaScript/TypeScript (mixed)
- **Type Checking:** TypeScript
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
- Node.js (v18 or higher recommended)
- PostgreSQL database
- npm or yarn
### 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
```
2. Install dependencies (already done): ```bash
```bash # Clone and enter the repo
npm install git clone <repo-url>
``` cd koin_ping_0.2.0
3. Create a `.env` file (see backend/README.md for required variables) # Install pre-commit hook
make hooks
4. Start the development server: # Install frontend dependencies
```bash cd frontend && npm install && cd ..
npm run dev
```
The backend will run on `http://localhost:3001` # 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
### Frontend Setup # Run checks (requires golangci-lint)
make check
1. Navigate to the frontend directory: # Start the API server
```bash make run
cd frontend
```
2. Install dependencies (already done): # Start the poller (separate terminal)
```bash cd backend-go && go run ./cmd/poller
npm install
```
3. Start the development server: # Start the frontend dev server (separate terminal)
```bash cd frontend && npm run dev
npm run dev ```
```
The frontend will run on `http://localhost:3000` The API listens on `http://localhost:3001` and the frontend on
`http://localhost:3000` by default.
## Rationale
Crypto users who hold or actively monitor addresses need a lightweight, reliable
way to know when on-chain activity occurs without polling block explorers
manually. Koin Ping fills that gap: it watches a set of Ethereum addresses,
evaluates configurable alert rules (incoming transactions, outgoing
transactions, large transfers, balance thresholds), and notifies the user
through Discord webhooks.
## Design
The system is split into two independently deployable processes and one
frontend:
```
koin_ping_0.2.0/
├── backend-go/ # Go monorepo root
│ ├── cmd/api/ # HTTP REST API server
│ ├── cmd/poller/ # Blockchain polling daemon
│ └── internal/
│ ├── config/ # Environment-based config loading
│ ├── database/ # pgx connection pool
│ ├── domain/ # Shared domain types
│ ├── firebase/ # Firebase auth client
│ ├── handlers/ # HTTP handler wiring
│ ├── middleware/ # Auth middleware
│ ├── models/ # SQL persistence layer
│ ├── notifications/ # Discord webhook delivery
│ ├── protocols/ethereum/ # Ethereum JSON-RPC client
│ ├── services/ # Observer and evaluator business logic
│ └── wei/ # ETH/Wei conversion utilities
└── frontend/ # React + Vite SPA
└── src/
├── api/ # Fetch wrappers for the REST API
├── components/ # Reusable UI components
├── contexts/ # React contexts (auth, etc.)
├── firebase/ # Firebase SDK initialization
└── pages/ # Top-level route pages
```
**API server** (`cmd/api`): standard-library `net/http` with Firebase JWT
authentication middleware. Exposes CRUD endpoints for addresses, alert rules,
alert events, and notification configuration.
**Poller** (`cmd/poller`): long-running daemon that polls Ethereum via JSON-RPC,
compares observed transactions against persisted alert rules, fires alert events
to the database, and dispatches Discord notifications.
**Frontend** (`frontend/`): React 19 SPA built with Vite. Authenticates with
Firebase, communicates with the API via fetch, and renders the address/alert
management UI.
## TODO
- [ ] Switch frontend package manager from npm to yarn (per repo policy)
- [ ] Rename `go.mod` module path from `github.com/kjannette/koin-ping` to
`sneak.berlin/go/koin-ping` (per repo policy)
- [ ] Move database migrations to `internal/db/migrations/` and embed them in
the binary
- [ ] Pin all Docker base images by `@sha256` in Dockerfile
- [ ] Install `golangci-lint` locally and pass `make lint`
- [ ] Add vitest unit tests for the React frontend
- [ ] Add a `make db-reset` / `make migrate` target for schema management
- [ ] Set HTTP server read/write timeouts (currently unbounded)
- [ ] Replace `log.Printf` calls with structured `log/slog`
## License
MIT. See [LICENSE](LICENSE).
## Author
Steven Jannette

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`

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,7 @@
Start DB:
brew services start postgresql@15
Run Backend: Run Backend:
cd /Users/kjannette/workspace/koin_ping/backend-go cd /Users/kjannette/workspace/koin_ping/backend-go go run ./cmd/api
go run ./cmd/api

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"
@@ -15,6 +16,14 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
) )
const (
// serverReadTimeoutSeconds is the maximum duration to read a request.
serverReadTimeoutSeconds = 5
// serverWriteTimeoutSeconds is the maximum duration to write a response.
serverWriteTimeoutSeconds = 10
)
//nolint:funlen
func main() { 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,12 +36,13 @@ 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)
@@ -51,23 +61,34 @@ func main() {
mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus) mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus)
// 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)))
// Authenticated routes — alert rules // Authenticated routes — 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)))
handler := corsMiddleware(mux) handler := corsMiddleware(mux)
@@ -76,9 +97,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 +116,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)
@@ -58,20 +68,20 @@ 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
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.Println(strings.Repeat("=", separatorWidth))
runCycle(ctx, observer, evaluator) runCycle(ctx, observer, evaluator)
@@ -82,6 +92,7 @@ func main() {
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)
@@ -89,19 +100,25 @@ func main() {
} }
} }
func runCycle(ctx context.Context, observer *services.ObserverService, evaluator *services.EvaluatorService) { func runCycle(
ctx context.Context,
observer *services.ObserverService,
evaluator *services.EvaluatorService,
) {
startTime := time.Now() 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

@@ -1,3 +1,4 @@
//loads environment-based configuration.
package config package config
import ( import (
@@ -7,6 +8,13 @@ import (
"strings" "strings"
) )
const (
defaultPort = 3001
defaultDBPort = 5432
defaultPollIntervalMS = 60000
minPollIntervalMS = 1000
)
type Config struct { type Config struct {
Port int Port int
APIBasePath string APIBasePath string
@@ -22,24 +30,25 @@ type Config struct {
NodeEnv string NodeEnv string
} }
// Load reads configuration from environment variables and returns a Config.
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
Port: getEnvInt("PORT", 3001), Port: getEnvInt("PORT", defaultPort),
APIBasePath: getEnv("API_BASE_PATH", "/v1"), APIBasePath: getEnv("API_BASE_PATH", "/v1"),
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
DBHost: getEnv("DB_HOST", "localhost"), DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnvInt("DB_PORT", 5432), DBPort: getEnvInt("DB_PORT", defaultDBPort),
DBUser: os.Getenv("DB_USER"), DBUser: os.Getenv("DB_USER"),
DBPassword: os.Getenv("DB_PASSWORD"), DBPassword: os.Getenv("DB_PASSWORD"),
DBName: os.Getenv("DB_NAME"), DBName: os.Getenv("DB_NAME"),
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"), FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
EthRPCURL: os.Getenv("ETH_RPC_URL"), EthRPCURL: os.Getenv("ETH_RPC_URL"),
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", 60000), PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
NodeEnv: getEnv("NODE_ENV", "development"), NodeEnv: getEnv("NODE_ENV", "development"),
} }
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 +56,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 +79,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 +89,6 @@ func getEnvInt(key string, fallback int) int {
return n return n
} }
} }
return fallback return fallback
} }

View File

@@ -1,3 +1,4 @@
// Package database manages PostgreSQL connection pools.
package database package database
import ( import (
@@ -9,20 +10,34 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
var pool *pgxpool.Pool const (
// maxConnIdleSeconds is the maximum idle time for a connection.
maxConnIdleSeconds = 30
// maxConnLifetimeMinutes is the maximum lifetime for a connection.
maxConnLifetimeMinutes = 5
// connectTimeoutSeconds is the timeout for initial connection.
connectTimeoutSeconds = 10
// maxConns is the maximum number of connections in the pool.
maxConns = 20
// minConns is the minimum number of connections in the pool.
minConns = 2
)
var pool *pgxpool.Pool //nolint:gochecknoglobals
// Connect establishes a PostgreSQL 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,18 +47,22 @@ 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
} }
// Pool returns the global connection pool.
func Pool() *pgxpool.Pool { func Pool() *pgxpool.Pool {
return pool return pool
} }
// Close closes the global connection pool.
func Close() { func Close() {
if pool != nil { if pool != nil {
pool.Close() pool.Close()

View File

@@ -1,17 +1,24 @@
// Package domain defines core domain types shared across the application.
package domain package domain
import "time" import "time"
// Address represents a tracked Ethereum address.
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
} }
// AlertType identifies the kind of alert rule.
type AlertType string type AlertType string
// String implements fmt.Stringer.
func (a AlertType) String() string { return string(a) }
// Alert type constants define the supported alert triggers.
const ( const (
AlertIncomingTx AlertType = "incoming_tx" AlertIncomingTx AlertType = "incoming_tx"
AlertOutgoingTx AlertType = "outgoing_tx" AlertOutgoingTx AlertType = "outgoing_tx"
@@ -19,97 +26,115 @@ const (
AlertBalanceBelow AlertType = "balance_below" AlertBalanceBelow AlertType = "balance_below"
) )
var ValidAlertTypes = []AlertType{ // ValidAlertTypes lists all alert types accepted by the API.
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
} }
// AlertRule represents a user-defined alert rule for an address.
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
} }
// AlertEvent represents a fired alert event stored for history.
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"`
} }
// AddressCheckpoint tracks the last block checked for an address.
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,omitempty"` //nolint:tagliatelle
Email *string `json:"email"` Email *string `json:"email"`
NotificationEnabled bool `json:"notification_enabled"` NotificationEnabled bool `json:"notification_enabled"` //nolint:tagliatelle
CreatedAt *time.Time `json:"created_at,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` //nolint:tagliatelle
UpdatedAt *time.Time `json:"updated_at,omitempty"` 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
} }
// Direction indicates whether a transaction is incoming or outgoing.
type Direction string type Direction string
// String implements fmt.Stringer.
func (d Direction) String() string { return string(d) }
// Direction constants indicate the flow of a transaction relative to a watched address.
const ( const (
DirectionIncoming Direction = "incoming" DirectionIncoming Direction = "incoming"
DirectionOutgoing Direction = "outgoing" DirectionOutgoing Direction = "outgoing"
) )
// ObservedTx is a NormalizedTx enriched with address and direction context.
type ObservedTx struct { type ObservedTx struct {
NormalizedTx NormalizedTx
AddressID int `json:"address_id"` AddressID int `json:"address_id"` //nolint:tagliatelle
Direction Direction `json:"direction"` Direction Direction `json:"direction"`
} }

View File

@@ -1,3 +1,4 @@
// Package firebase provides Firebase authentication integration.
package firebase package firebase
import ( import (
@@ -10,12 +11,13 @@ 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
) )
// Init initializes the Firebase app and auth client using the given project ID.
func Init(projectID string) error { func Init(projectID string) error {
once.Do(func() { once.Do(func() {
ctx := context.Background() ctx := context.Background()
@@ -30,20 +32,23 @@ 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
} }
// Auth returns the initialized Firebase auth client.
func Auth() *auth.Client { func Auth() *auth.Client {
return authClient return authClient
} }

View File

@@ -1,3 +1,4 @@
// Package handlers implements HTTP request handlers for the API.
package handlers package handlers
import ( import (
@@ -14,14 +15,17 @@ import (
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`) var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
// AddressHandler handles HTTP requests for address management.
type AddressHandler struct { type AddressHandler struct {
addresses *models.AddressModel addresses *models.AddressModel
} }
// NewAddressHandler creates a new AddressHandler.
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler { func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
return &AddressHandler{addresses: addresses} return &AddressHandler{addresses: addresses}
} }
// Create handles POST requests to add a new tracked address.
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
@@ -32,16 +36,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 +58,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 +71,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 +80,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 +92,13 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, addresses) writeJSON(w, http.StatusOK, addresses)
} }
// 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 +108,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

@@ -11,14 +11,17 @@ import (
"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 +37,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,6 +45,7 @@ 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
} }
@@ -67,15 +72,15 @@ func mockEvents(limit int) []domain.AlertEvent {
Timestamp: time.Now().Add(-2 * time.Hour), Timestamp: time.Now().Add(-2 * time.Hour),
}, },
{ {
ID: 2, ID: 2, //nolint:mnd
AlertRuleID: 2, AlertRuleID: 2, //nolint:mnd
Message: "Balance dropped below threshold: Current balance 8.2 ETH", Message: "Balance dropped below threshold: Current balance 8.2 ETH",
AddressLabel: &label1, AddressLabel: &label1,
Timestamp: time.Now().Add(-5 * time.Hour), Timestamp: time.Now().Add(-5 * time.Hour),
}, },
{ {
ID: 3, ID: 3, //nolint:mnd
AlertRuleID: 3, AlertRuleID: 3, //nolint:mnd
Message: "Outgoing transaction detected: 2.0 ETH sent", Message: "Outgoing transaction detected: 2.0 ETH sent",
AddressLabel: &label2, AddressLabel: &label2,
Timestamp: time.Now().Add(-24 * time.Hour), Timestamp: time.Now().Add(-24 * time.Hour),
@@ -85,5 +90,6 @@ func mockEvents(limit int) []domain.AlertEvent {
if limit < len(mocks) { if limit < len(mocks) {
return mocks[:limit] return mocks[:limit]
} }
return mocks 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,20 +14,27 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
) )
// errThresholdFormat is returned when the threshold JSON cannot be decoded.
var errThresholdFormat = errors.New("unsupported threshold format")
// AlertRuleHandler handles HTTP requests for alert rule management.
type AlertRuleHandler struct { type AlertRuleHandler struct {
alertRules *models.AlertRuleModel alertRules *models.AlertRuleModel
addresses *models.AddressModel addresses *models.AddressModel
} }
// NewAlertRuleHandler creates a new AlertRuleHandler.
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler { func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses} return &AlertRuleHandler{alertRules: alertRules, addresses: addresses}
} }
// Create handles POST requests to create a new alert rule for an address.
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *AlertRuleHandler) Create(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
} }
@@ -37,6 +45,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 +53,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 +61,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 +81,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 +90,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 +104,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 +114,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 +131,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 +160,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 +174,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 +186,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 +202,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 +210,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 +218,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 +232,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 +240,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 +256,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

@@ -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,7 @@ 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, 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"
) )
@@ -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,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"
) )
@@ -23,7 +25,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"
) )
@@ -26,7 +28,7 @@ func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string)
).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.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

View File

@@ -9,6 +9,22 @@ import (
"time" "time"
) )
const (
// discordHTTPTimeoutSeconds is the timeout for Discord webhook requests.
discordHTTPTimeoutSeconds = 10
// Alert color codes for Discord embeds.
colorGreen = 0x00ff00
colorOrange = 0xff9900
colorRed = 0xff0000
colorBlue = 0x0099ff
)
// discordHTTPClient is a shared HTTP client with a timeout for Discord requests.
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
Timeout: discordHTTPTimeoutSeconds * time.Second,
}
type AlertMetadata struct { type AlertMetadata struct {
TxHash string TxHash string
AddressLabel string AddressLabel string
@@ -73,7 +89,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 +116,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 +131,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

@@ -170,7 +170,11 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
// Send Discord notification (non-fatal on failure) // 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) go func() {
s.sendNotification(
ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address,
)
}()
} }
return nil return nil

View File

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

View File

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

View File

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

4
frontend/prettierrc Normal file
View File

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

View File

@@ -1,5 +1,6 @@
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 Addresses from "./pages/Addresses"; import Addresses from "./pages/Addresses";
@@ -7,9 +8,8 @@ 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>
@@ -20,36 +20,9 @@ export default function App() {
); );
} }
// 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 />} />

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,10 @@ 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; throw error;
} }
@@ -85,13 +89,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 +107,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,7 @@
// API client for notification configuration // API client for notification configuration
import { getAuthHeaders } from './authHeaders'; import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from './config'; import { API_BASE } from "./config";
/** /**
* Get notification configuration for current user * Get notification configuration for current user
@@ -11,11 +11,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 +27,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;
} }
@@ -47,13 +49,13 @@ 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,8 +67,10 @@ 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;
} }
@@ -81,19 +85,19 @@ export async function updateNotificationConfig(config) {
export async function testDiscordWebhook(webhookUrl) { export async function testDiscordWebhook(webhookUrl) {
try { try {
const payload = { const payload = {
content: 'Koin Ping test notification - Your Discord webhook is configured correctly!', content:
"Koin Ping test notification - Your Discord webhook is configured correctly!",
}; };
const response = await fetch(webhookUrl, { const response = await fetch(webhookUrl, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload) body: JSON.stringify(payload),
}); });
return response.ok; return response.ok;
} catch (error) { } catch (error) {
console.error('Discord webhook test failed:', error); console.error("Discord webhook test failed:", error);
return false; return false;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -51,7 +51,7 @@ export default function Addresses() {
{loading && <p>Loading addresses...</p>} {loading && <p>Loading addresses...</p>}
{error && <p style={{ color: "red" }}>Error: {error}</p>} {error && <p style={{ color: "red" }}>Error: {error}</p>}
{!loading && !error && addresses.length === 0 && ( {!loading && !error && addresses.length === 0 && (
<p style={{ color: "#666" }}> <p style={{ color: "#808080" }}>
No addresses tracked yet. Add one above to get started. No addresses tracked yet. Add one above to get started.
</p> </p>
)} )}
@@ -63,14 +63,26 @@ export default function Addresses() {
style={{ style={{
padding: "1rem", padding: "1rem",
marginBottom: "0.5rem", marginBottom: "0.5rem",
border: "1px solid #ddd", border: "1px solid #444",
borderRadius: "4px", borderRadius: "4px",
backgroundColor: "#333",
}}
>
<div
style={{
fontWeight: "bold",
marginBottom: "0.25rem",
}} }}
> >
<div style={{ fontWeight: "bold", marginBottom: "0.25rem" }}>
{addr.label || "Unlabeled"} {addr.label || "Unlabeled"}
</div> </div>
<div style={{ fontFamily: "monospace", fontSize: "0.9rem" }}> <div
style={{
fontFamily: "monospace",
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
{addr.address} {addr.address}
</div> </div>
</li> </li>

View File

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

View File

@@ -3,8 +3,17 @@ 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,
testDiscordWebhook,
} from "../api/notificationConfig";
export default function Alerts() { export default function Alerts() {
const [addresses, setAddresses] = useState([]); const [addresses, setAddresses] = useState([]);
@@ -15,7 +24,7 @@ export default function Alerts() {
// Notification config state // Notification config state
const [notificationConfig, setNotificationConfig] = useState(null); const [notificationConfig, setNotificationConfig] = useState(null);
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(''); const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
const [notificationEnabled, setNotificationEnabled] = useState(true); const [notificationEnabled, setNotificationEnabled] = useState(true);
const [notificationLoading, setNotificationLoading] = useState(false); const [notificationLoading, setNotificationLoading] = useState(false);
const [notificationError, setNotificationError] = useState(null); const [notificationError, setNotificationError] = useState(null);
@@ -38,8 +47,10 @@ export default function Alerts() {
// Fetch notification config // Fetch notification config
const configData = await getNotificationConfig(); const configData = await getNotificationConfig();
setNotificationConfig(configData); setNotificationConfig(configData);
setDiscordWebhookUrl(configData.discord_webhook_url || ''); setDiscordWebhookUrl(configData.discord_webhook_url || "");
setNotificationEnabled(configData.notification_enabled !== false); setNotificationEnabled(
configData.notification_enabled !== false,
);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to fetch data:", err); console.error("Failed to fetch data:", err);
@@ -91,7 +102,7 @@ export default function Alerts() {
try { try {
const updated = await updateAlertStatus(alertId, !currentStatus); const updated = await updateAlertStatus(alertId, !currentStatus);
setAlerts((prev) => setAlerts((prev) =>
prev.map((alert) => (alert.id === alertId ? updated : alert)) prev.map((alert) => (alert.id === alertId ? updated : alert)),
); );
setError(null); // Clear any previous errors setError(null); // Clear any previous errors
} catch (err) { } catch (err) {
@@ -126,7 +137,7 @@ export default function Alerts() {
const updated = await updateNotificationConfig(config); const updated = await updateNotificationConfig(config);
setNotificationConfig(updated); setNotificationConfig(updated);
setNotificationSuccess('Notification settings saved!'); setNotificationSuccess("Notification settings saved!");
// Clear success message after 3 seconds // Clear success message after 3 seconds
setTimeout(() => setNotificationSuccess(null), 3000); setTimeout(() => setNotificationSuccess(null), 3000);
@@ -141,7 +152,7 @@ export default function Alerts() {
// Test Discord webhook // Test Discord webhook
async function handleTestWebhook() { async function handleTestWebhook() {
if (!discordWebhookUrl) { if (!discordWebhookUrl) {
setNotificationError('Please enter a Discord webhook URL first'); setNotificationError("Please enter a Discord webhook URL first");
return; return;
} }
@@ -153,13 +164,15 @@ export default function Alerts() {
const success = await testDiscordWebhook(discordWebhookUrl); const success = await testDiscordWebhook(discordWebhookUrl);
if (success) { if (success) {
setNotificationSuccess('Test notification sent! Check your Discord channel.'); setNotificationSuccess(
"Test notification sent! Check your Discord channel.",
);
setTimeout(() => setNotificationSuccess(null), 5000); setTimeout(() => setNotificationSuccess(null), 5000);
} else { } else {
setNotificationError('Test failed. Check your webhook URL.'); setNotificationError("Test failed. Check your webhook URL.");
} }
} catch (err) { } catch (err) {
setNotificationError('Test failed: ' + err.message); setNotificationError("Test failed: " + err.message);
} finally { } finally {
setTestingWebhook(false); setTestingWebhook(false);
} }
@@ -174,30 +187,44 @@ export default function Alerts() {
if (addresses.length === 0) { if (addresses.length === 0) {
return ( return (
<div style={{ padding: "2rem" }}> <div style={{ padding: "2rem" }}>
<p>No addresses tracked yet. Add an address first to create alerts.</p> <p>
No addresses tracked yet. Add an address first to create
alerts.
</p>
</div> </div>
); );
} }
return ( return (
<div style={{ maxWidth: "1400px", margin: "0 auto", padding: "2rem" }}> <div style={{ maxWidth: "1400px", margin: "0 auto", padding: "2rem" }}>
<h1 style={{ marginBottom: "2rem" }}>Alert Rules & Notifications</h1> <h1 style={{ marginBottom: "2rem" }}>
Alert Rules & Notifications
</h1>
{/* Two-column layout */} {/* Two-column layout */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "2rem" }}> <div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "2rem",
}}
>
{/* LEFT COLUMN: Alert Rules */} {/* LEFT COLUMN: Alert Rules */}
<div> <div>
<h2 style={{ marginTop: 0 }}>Alert Rules</h2> <h2 style={{ marginTop: 0 }}>Alert Rules</h2>
{/* Address selector */} {/* Address selector */}
<div style={{ marginBottom: "2rem" }}> <div style={{ marginBottom: "2rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}> <label
style={{ display: "block", marginBottom: "0.5rem" }}
>
<strong>Select Address:</strong> <strong>Select Address:</strong>
</label> </label>
<select <select
value={selectedAddressId || ""} value={selectedAddressId || ""}
onChange={(e) => setSelectedAddressId(Number(e.target.value))} onChange={(e) =>
setSelectedAddressId(Number(e.target.value))
}
style={{ style={{
width: "100%", width: "100%",
padding: "0.5rem", padding: "0.5rem",
@@ -205,7 +232,7 @@ export default function Alerts() {
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
border: "1px solid #444", border: "1px solid #444",
borderRadius: "4px", borderRadius: "4px",
color: "white" color: "white",
}} }}
> >
{addresses.map((addr) => ( {addresses.map((addr) => (
@@ -223,18 +250,34 @@ export default function Alerts() {
style={{ style={{
padding: "1rem", padding: "1rem",
marginBottom: "2rem", marginBottom: "2rem",
backgroundColor: "#2a2a2a", backgroundColor: "#333",
borderRadius: "4px", borderRadius: "4px",
border: "1px solid #444" border: "1px solid #444",
}}
>
<div
style={{
fontSize: "1.035rem",
color: "#b3b3b3",
}} }}
> >
<div style={{ fontSize: "0.9rem", color: "#999" }}>
Managing alerts for: Managing alerts for:
</div> </div>
<div style={{ fontWeight: "bold", marginTop: "0.25rem" }}> <div
style={{
fontWeight: "bold",
marginTop: "0.25rem",
}}
>
{selectedAddress.label || "Unlabeled"} {selectedAddress.label || "Unlabeled"}
</div> </div>
<div style={{ fontFamily: "monospace", fontSize: "0.9rem", color: "#999" }}> <div
style={{
fontFamily: "monospace",
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
{selectedAddress.address} {selectedAddress.address}
</div> </div>
</div> </div>
@@ -248,13 +291,23 @@ export default function Alerts() {
{/* Existing alerts list */} {/* 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 style={{ color: "red" }}>
Error: {error}
</p>
)}
{alerts.length === 0 ? ( {alerts.length === 0 ? (
<p style={{ color: "#666" }}> <p style={{ color: "#666" }}>
No alert rules defined yet. Create one above. No alert rules defined yet. Create one
above.
</p> </p>
) : ( ) : (
<ul style={{ listStyle: "none", padding: 0 }}> <ul
style={{
listStyle: "none",
padding: 0,
}}
>
{alerts.map((alert) => ( {alerts.map((alert) => (
<li <li
key={alert.id} key={alert.id}
@@ -263,37 +316,89 @@ export default function Alerts() {
marginBottom: "0.5rem", marginBottom: "0.5rem",
border: "1px solid #444", border: "1px solid #444",
borderRadius: "4px", borderRadius: "4px",
backgroundColor: "#2a2a2a", backgroundColor: "#333",
opacity: alert.enabled ? 1 : 0.6, opacity: alert.enabled
? 1
: 0.6,
}} }}
> >
<div <div
style={{ style={{
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent:
alignItems: "flex-start", "space-between",
alignItems:
"flex-start",
}} }}
> >
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontWeight: "bold", marginBottom: "0.25rem" }}> <div
{formatAlertType(alert.type)} style={{
fontWeight:
"bold",
marginBottom:
"0.25rem",
}}
>
{formatAlertType(
alert.type,
)}
</div> </div>
{alert.threshold && ( {alert.threshold && (
<div style={{ fontSize: "0.9rem", color: "#999" }}> <div
Threshold: {alert.threshold} ETH style={{
fontSize:
"1.035rem",
color: "#b3b3b3",
}}
>
Threshold:{" "}
{
alert.threshold
}{" "}
ETH
</div> </div>
)} )}
<div style={{ fontSize: "0.85rem", color: "#666", marginTop: "0.25rem" }}> <div
Status: {alert.enabled ? "Enabled" : "Disabled"} style={{
</div> fontSize:
</div> "0.978rem",
<div style={{ display: "flex", gap: "0.5rem" }}> color: "#808080",
<Button marginTop:
onClick={() => handleToggleAlert(alert.id, alert.enabled)} "0.25rem",
}}
> >
{alert.enabled ? "Disable" : "Enable"} Status:{" "}
{alert.enabled
? "Enabled"
: "Disabled"}
</div>
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
}}
>
<Button
onClick={() =>
handleToggleAlert(
alert.id,
alert.enabled,
)
}
>
{alert.enabled
? "Disable"
: "Enable"}
</Button> </Button>
<Button onClick={() => handleDeleteAlert(alert.id)}> <Button
onClick={() =>
handleDeleteAlert(
alert.id,
)
}
>
Delete Delete
</Button> </Button>
</div> </div>
@@ -312,148 +417,208 @@ export default function Alerts() {
<h2 style={{ marginTop: 0 }}>Notification Settings</h2> <h2 style={{ marginTop: 0 }}>Notification Settings</h2>
{notificationSuccess && ( {notificationSuccess && (
<div style={{ <div
padding: '0.75rem', style={{
marginBottom: '1rem', padding: "0.75rem",
backgroundColor: '#00ff0020', marginBottom: "1rem",
border: '1px solid #00ff00', backgroundColor: "#00ff0020",
borderRadius: '4px', border: "1px solid #00ff00",
color: '#00ff00' borderRadius: "4px",
}}> color: "#00ff00",
}}
>
{notificationSuccess} {notificationSuccess}
</div> </div>
)} )}
{notificationError && ( {notificationError && (
<div style={{ <div
padding: '0.75rem', style={{
marginBottom: '1rem', padding: "0.75rem",
backgroundColor: '#ff000020', marginBottom: "1rem",
border: '1px solid #ff0000', backgroundColor: "#ff000020",
borderRadius: '4px', border: "1px solid #ff0000",
color: '#ff6666' borderRadius: "4px",
}}> color: "#ff6666",
}}
>
{notificationError} {notificationError}
</div> </div>
)} )}
{/* Master toggle */} {/* Master toggle */}
<div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: '#f5f5f5', borderRadius: '4px' }}> <div
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}> style={{
marginBottom: "2rem",
padding: "1rem",
backgroundColor: "#333",
borderRadius: "4px",
}}
>
<label
style={{
display: "flex",
alignItems: "center",
cursor: "pointer",
}}
>
<input <input
type="checkbox" type="checkbox"
checked={notificationEnabled} checked={notificationEnabled}
onChange={(e) => setNotificationEnabled(e.target.checked)} onChange={(e) =>
style={{ marginRight: '0.5rem', width: '18px', height: '18px' }} setNotificationEnabled(e.target.checked)
}
style={{
marginRight: "0.5rem",
width: "18px",
height: "18px",
}}
/> />
<span style={{ fontWeight: 'bold' }}>Enable Notifications</span> <span style={{ fontWeight: "bold" }}>
Enable Notifications
</span>
</label> </label>
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.5rem', marginLeft: '26px' }}> <div
style={{
fontSize: "0.978rem",
color: "#808080",
marginTop: "0.5rem",
marginLeft: "26px",
}}
>
Master switch for all notification channels Master switch for all notification channels
</div> </div>
</div> </div>
{/* Discord Section */} {/* Discord Section */}
<div style={{ marginBottom: '2rem' }}> <div style={{ marginBottom: "2rem" }}>
<h3 style={{ marginBottom: '1rem' }}>Discord</h3> <h3 style={{ marginBottom: "1rem" }}>Discord</h3>
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: "1rem" }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}> <label
style={{
display: "block",
marginBottom: "0.5rem",
}}
>
Discord Webhook URL Discord Webhook URL
</label> </label>
<input <input
type="text" type="text"
value={discordWebhookUrl} value={discordWebhookUrl}
onChange={(e) => setDiscordWebhookUrl(e.target.value)} onChange={(e) =>
setDiscordWebhookUrl(e.target.value)
}
placeholder="https://discord.com/api/webhooks/..." placeholder="https://discord.com/api/webhooks/..."
style={{ style={{
width: '100%', width: "100%",
padding: '0.5rem', padding: "0.5rem",
fontSize: '1rem', fontSize: "1rem",
backgroundColor: '#1a1a1a', backgroundColor: "#1a1a1a",
border: '1px solid #444', border: "1px solid #444",
borderRadius: '4px', borderRadius: "4px",
color: 'white', color: "white",
fontFamily: 'monospace', fontFamily: "monospace",
fontSize: '0.9rem' fontSize: "0.9rem",
}} }}
/> />
<div style={{ fontSize: '0.85rem', color: '#999', marginTop: '0.5rem' }}> <div
style={{
fontSize: "0.978rem",
color: "#b3b3b3",
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' }} style={{ color: "#0066cc" }}
> >
How to get a Discord webhook URL How to get a Discord webhook URL
</a> </a>
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div style={{ display: "flex", gap: "0.5rem" }}>
<button <button
onClick={handleSaveNotificationConfig} onClick={handleSaveNotificationConfig}
disabled={notificationLoading} disabled={notificationLoading}
style={{ style={{
padding: '0.75rem 1.5rem', padding: "0.75rem 1.5rem",
fontSize: '1rem', fontSize: "1rem",
backgroundColor: notificationLoading ? '#333' : '#0066cc', backgroundColor: notificationLoading
color: 'white', ? "#333"
border: 'none', : "#0066cc",
borderRadius: '4px', color: "white",
cursor: notificationLoading ? 'not-allowed' : 'pointer' border: "none",
borderRadius: "4px",
cursor: notificationLoading
? "not-allowed"
: "pointer",
}} }}
> >
{notificationLoading ? 'Saving...' : 'Save Settings'} {notificationLoading
? "Saving..."
: "Save Settings"}
</button> </button>
<button <button
onClick={handleTestWebhook} onClick={handleTestWebhook}
disabled={testingWebhook || !discordWebhookUrl} disabled={testingWebhook || !discordWebhookUrl}
style={{ style={{
padding: '0.75rem 1.5rem', padding: "0.75rem 1.5rem",
fontSize: '1rem', fontSize: "1rem",
backgroundColor: testingWebhook || !discordWebhookUrl ? '#333' : '#28a745', backgroundColor:
color: 'white', testingWebhook || !discordWebhookUrl
border: 'none', ? "#333"
borderRadius: '4px', : "#28a745",
cursor: testingWebhook || !discordWebhookUrl ? 'not-allowed' : 'pointer' color: "white",
border: "none",
borderRadius: "4px",
cursor:
testingWebhook || !discordWebhookUrl
? "not-allowed"
: "pointer",
}} }}
> >
{testingWebhook ? 'Testing...' : 'Test Webhook'} {testingWebhook ? "Testing..." : "Test Webhook"}
</button> </button>
</div> </div>
</div> </div>
{/* Telegram Section (Coming Soon) */} {/* Telegram Section (Coming Soon) */}
<div style={{ marginBottom: '2rem', opacity: 0.5 }}> <div style={{ marginBottom: "2rem", opacity: 0.5 }}>
<h3 style={{ marginBottom: '1rem' }}>Telegram</h3> <h3 style={{ marginBottom: "1rem" }}>Telegram</h3>
<div style={{ <div
padding: '1rem', style={{
backgroundColor: '#333', padding: "1rem",
borderRadius: '4px', backgroundColor: "#333",
color: '#999', borderRadius: "4px",
textAlign: 'center' color: "#999",
}}> textAlign: "center",
}}
>
Coming Soon Coming Soon
</div> </div>
</div> </div>
{/* Email Section (Coming Soon) */} {/* Email Section (Coming Soon) */}
<div style={{ opacity: 0.5 }}> <div style={{ opacity: 0.5 }}>
<h3 style={{ marginBottom: '1rem' }}>Email</h3> <h3 style={{ marginBottom: "1rem" }}>Email</h3>
<div style={{ <div
padding: '1rem', style={{
backgroundColor: '#333', padding: "1rem",
borderRadius: '4px', backgroundColor: "#333",
color: '#999', borderRadius: "4px",
textAlign: 'center' color: "#999",
}}> textAlign: "center",
}}
>
Coming Soon Coming Soon
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );

View File

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

View File

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