Compare commits
22 Commits
more-messa
...
install-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61a8c5fe8b | ||
|
|
c79136a06e | ||
|
|
a5590a438f | ||
|
|
1c196cae9f | ||
|
|
c494bf5f53 | ||
|
|
d07dc971fe | ||
|
|
112c9d0627 | ||
|
|
b1e63a24c7 | ||
|
|
7357315810 | ||
|
|
90a338d46c | ||
|
|
230dc99dba | ||
|
|
62e6c64ddb | ||
|
|
da8b45012a | ||
|
|
a5d1bc171c | ||
|
|
4f6ed5dd20 | ||
|
|
6901c759da | ||
|
|
b283d0f835 | ||
|
|
aac62bacf4 | ||
|
|
5c721ca271 | ||
|
|
2fcbcf5b54 | ||
|
|
21ae859d1c | ||
|
|
6648cd347d |
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
15
.editorconfig
Normal file
15
.editorconfig
Normal 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
|
||||||
9
.gitea/workflows/check.yml
Normal file
9
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name: check
|
||||||
|
on: [push]
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
- run: docker build .
|
||||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
*.bak
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# Go build artifacts
|
||||||
|
backend-go/bin/
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
vendor/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Frontend build output
|
||||||
|
frontend/dist/
|
||||||
|
frontend/build/
|
||||||
|
frontend/.vite/
|
||||||
|
|
||||||
|
# Prompts directory (local tooling, not part of the project)
|
||||||
|
Prompts/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
.claude
|
||||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/build/
|
||||||
|
backend-go/bin/
|
||||||
|
*.lock
|
||||||
|
Prompts/
|
||||||
|
.claude/
|
||||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"proseWrap": "always"
|
||||||
|
}
|
||||||
22
Dockerfile
Normal file
22
Dockerfile
Normal 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
21
LICENSE
Normal 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
90
Makefile
Normal 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/
|
||||||
169
README.md
169
README.md
@@ -1,95 +1,108 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
Steven Jannette
|
||||||
|
|||||||
188
REPO_POLICIES.md
Normal file
188
REPO_POLICIES.md
Normal 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`
|
||||||
@@ -14,3 +14,7 @@ POLL_INTERVAL_MS=60000
|
|||||||
|
|
||||||
# Firebase
|
# Firebase
|
||||||
FIREBASE_PROJECT_ID=koin-ping
|
FIREBASE_PROJECT_ID=koin-ping
|
||||||
|
|
||||||
|
# Email notifications (Resend — https://resend.com)
|
||||||
|
# RESEND_API_KEY=re_xxxxxxxxxxxx
|
||||||
|
# EMAIL_FROM=Koin Ping <alerts@yourdomain.com>
|
||||||
|
|||||||
32
backend-go/.golangci.yml
Normal file
32
backend-go/.golangci.yml
Normal 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
|
||||||
@@ -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
|
||||||
- Or keep it in backend/ and add to .gitignore
|
.env
|
||||||
|
- Or keep it in backend/ and add to .gitignore
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
// Package main is the entry point for the API server.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||||
@@ -13,11 +14,19 @@ import (
|
|||||||
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// max duration to read a request.
|
||||||
|
serverReadTimeoutSeconds = 5
|
||||||
|
// maxiduration to write a response.
|
||||||
|
serverWriteTimeoutSeconds = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:funlen
|
||||||
func main() {
|
func main() {
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load() // .env is optional; env vars can also be set externally
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -28,53 +37,72 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to connect to database: %v", err)
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
|
if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
|
||||||
log.Fatalf("Failed to initialize Firebase: %v", err)
|
log.Fatalf("Failed to initialize Firebase: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var eth ethereum.EthereumObserver
|
defer database.Close()
|
||||||
if cfg.EthRPCURL != "" {
|
|
||||||
eth, err = ethereum.NewJsonRpcEthereum(cfg.EthRPCURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: Failed to create Ethereum observer: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addressModel := models.NewAddressModel(pool)
|
addressModel := models.NewAddressModel(pool)
|
||||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||||
alertEventModel := models.NewAlertEventModel(pool)
|
alertEventModel := models.NewAlertEventModel(pool)
|
||||||
notifConfigModel := models.NewNotificationConfigModel(pool)
|
notifConfigModel := models.NewNotificationConfigModel(pool)
|
||||||
checkpointModel := models.NewCheckpointModel(pool)
|
|
||||||
|
emailDigestSvc := services.NewEmailDigestService(
|
||||||
|
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
|
||||||
|
)
|
||||||
|
|
||||||
addressHandler := handlers.NewAddressHandler(addressModel)
|
addressHandler := handlers.NewAddressHandler(addressModel)
|
||||||
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
|
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
|
||||||
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
||||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel)
|
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
|
||||||
statusHandler := handlers.NewStatusHandler(eth, checkpointModel)
|
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
b := cfg.APIBasePath
|
b := cfg.APIBasePath // e.g. "/v1"
|
||||||
|
|
||||||
|
// Public routes
|
||||||
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
||||||
mux.HandleFunc("GET "+b+"/status", statusHandler.SystemStatus)
|
mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus)
|
||||||
|
|
||||||
mux.Handle("POST "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
|
// Authenticated routes — addresses
|
||||||
mux.Handle("GET "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
|
mux.Handle("POST "+b+"/addresses",
|
||||||
mux.Handle("DELETE "+b+"/addresses/{addressId}", middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
|
middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
|
||||||
|
mux.Handle("GET "+b+"/addresses",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
|
||||||
|
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
|
||||||
|
|
||||||
mux.Handle("POST "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
|
// Authenticated routes for alert rules
|
||||||
mux.Handle("GET "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
|
||||||
mux.Handle("PATCH "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
|
||||||
mux.Handle("DELETE "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
|
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
||||||
|
mux.Handle("PATCH "+b+"/alerts/{alertId}",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
||||||
|
mux.Handle("DELETE "+b+"/alerts/{alertId}",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
|
||||||
|
|
||||||
mux.Handle("GET "+b+"/alert-events", middleware.Authenticate(http.HandlerFunc(alertEventHandler.List)))
|
// Authenticated routes — alert events
|
||||||
|
mux.Handle("GET "+b+"/alert-events",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(alertEventHandler.List)))
|
||||||
|
|
||||||
mux.Handle("GET "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
// Authenticated routes — notification config
|
||||||
mux.Handle("PUT "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
mux.Handle("GET "+b+"/notification-config",
|
||||||
mux.Handle("DELETE "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
||||||
mux.Handle("POST "+b+"/notification-config/test", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestWebhook)))
|
mux.Handle("PUT "+b+"/notification-config",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||||
|
mux.Handle("DELETE "+b+"/notification-config",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
||||||
|
mux.Handle("POST "+b+"/notification-config/test",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestChannels)))
|
||||||
|
|
||||||
|
// Authenticated routes — email digest
|
||||||
|
mux.Handle("POST "+b+"/email/setup",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
||||||
|
mux.Handle("POST "+b+"/email/digest",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
||||||
|
|
||||||
handler := corsMiddleware(mux)
|
handler := corsMiddleware(mux)
|
||||||
|
|
||||||
@@ -83,9 +111,14 @@ func main() {
|
|||||||
log.Printf("API base path: %s", cfg.APIBasePath)
|
log.Printf("API base path: %s", cfg.APIBasePath)
|
||||||
log.Printf("Environment: %s", cfg.NodeEnv)
|
log.Printf("Environment: %s", cfg.NodeEnv)
|
||||||
|
|
||||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
server := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: handler,
|
||||||
|
ReadTimeout: serverReadTimeoutSeconds * time.Second,
|
||||||
|
WriteTimeout: serverWriteTimeoutSeconds * time.Second,
|
||||||
|
}
|
||||||
|
if err := server.ListenAndServe(); err != nil {
|
||||||
log.Fatalf("Server failed: %v", err)
|
log.Fatalf("Server failed: %v", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +130,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package main is the entry point for the blockchain observer poller.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,11 +15,18 @@ import (
|
|||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// separatorWidth is the number of characters in log separator lines.
|
||||||
|
separatorWidth = 60
|
||||||
|
// msPerSecond converts milliseconds to seconds.
|
||||||
|
msPerSecond = 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:funlen
|
||||||
func main() {
|
func main() {
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
|
|
||||||
@@ -35,29 +43,25 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to connect to database: %v", err)
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
eth, err := ethereum.NewJsonRpcEthereum(cfg.EthRPCURL)
|
eth, err := ethereum.NewJsonRpcEthereum(cfg.EthRPCURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create Ethereum observer: %v", err)
|
log.Fatalf("Failed to create Ethereum observer: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
addressModel := models.NewAddressModel(pool)
|
addressModel := models.NewAddressModel(pool)
|
||||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||||
alertEventModel := models.NewAlertEventModel(pool)
|
alertEventModel := models.NewAlertEventModel(pool)
|
||||||
checkpointModel := models.NewCheckpointModel(pool)
|
checkpointModel := models.NewCheckpointModel(pool)
|
||||||
notifConfigModel := models.NewNotificationConfigModel(pool)
|
notifConfigModel := models.NewNotificationConfigModel(pool)
|
||||||
|
|
||||||
smtpCfg := notifications.SMTPConfig{
|
|
||||||
Host: cfg.SMTPHost,
|
|
||||||
Port: cfg.SMTPPort,
|
|
||||||
Username: cfg.SMTPUser,
|
|
||||||
Password: cfg.SMTPPassword,
|
|
||||||
From: cfg.SMTPFrom,
|
|
||||||
}
|
|
||||||
|
|
||||||
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
||||||
evaluator := services.NewEvaluatorService(eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel, smtpCfg)
|
evaluator := services.NewEvaluatorService(
|
||||||
|
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
|
||||||
|
cfg.ResendAPIKey, cfg.EmailFrom,
|
||||||
|
)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -67,20 +71,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)
|
||||||
|
|
||||||
@@ -91,6 +95,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)
|
||||||
@@ -98,19 +103,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
ALTER TABLE user_notification_configs
|
ALTER TABLE user_notification_configs
|
||||||
DROP COLUMN IF EXISTS telegram_chat_id,
|
|
||||||
DROP COLUMN IF EXISTS telegram_bot_token,
|
|
||||||
ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT;
|
ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT;
|
||||||
@@ -53,8 +53,10 @@ CREATE TABLE address_checkpoints (
|
|||||||
CREATE TABLE user_notification_configs (
|
CREATE TABLE user_notification_configs (
|
||||||
user_id VARCHAR(128) PRIMARY KEY,
|
user_id VARCHAR(128) PRIMARY KEY,
|
||||||
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
||||||
slack_webhook_url TEXT, -- Slack incoming webhook URL (nullable)
|
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
|
||||||
|
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
|
||||||
email VARCHAR(255), -- Email for notifications (nullable)
|
email VARCHAR(255), -- Email for notifications (nullable)
|
||||||
|
slack_webhook_url TEXT, -- Slack incoming webhook URL (nullable)
|
||||||
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
|
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -20,38 +28,31 @@ type Config struct {
|
|||||||
EthRPCURL string
|
EthRPCURL string
|
||||||
PollIntervalMS int
|
PollIntervalMS int
|
||||||
NodeEnv string
|
NodeEnv string
|
||||||
|
ResendAPIKey string
|
||||||
SMTPHost string
|
EmailFrom string
|
||||||
SMTPPort int
|
|
||||||
SMTPUser string
|
|
||||||
SMTPPassword string
|
|
||||||
SMTPFrom string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load reads configuration from environment variables and returns a Config.
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Port: getEnvInt("PORT", 3001),
|
Port: getEnvInt("PORT", defaultPort),
|
||||||
APIBasePath: getEnv("API_BASE_PATH", "/v1"),
|
APIBasePath: getEnv("API_BASE_PATH", "/v1"),
|
||||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
DBHost: getEnv("DB_HOST", "localhost"),
|
DBHost: getEnv("DB_HOST", "localhost"),
|
||||||
DBPort: getEnvInt("DB_PORT", 5432),
|
DBPort: getEnvInt("DB_PORT", defaultDBPort),
|
||||||
DBUser: os.Getenv("DB_USER"),
|
DBUser: os.Getenv("DB_USER"),
|
||||||
DBPassword: os.Getenv("DB_PASSWORD"),
|
DBPassword: os.Getenv("DB_PASSWORD"),
|
||||||
DBName: os.Getenv("DB_NAME"),
|
DBName: os.Getenv("DB_NAME"),
|
||||||
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
|
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
|
||||||
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
||||||
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", 60000),
|
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
||||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||||
|
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||||
SMTPPort: getEnvInt("SMTP_PORT", 587),
|
|
||||||
SMTPUser: os.Getenv("SMTP_USER"),
|
|
||||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
|
||||||
SMTPFrom: os.Getenv("SMTP_FROM"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.PollIntervalMS < 1000 {
|
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||||
return nil, fmt.Errorf("POLL_INTERVAL_MS must be >= 1000, got %d", cfg.PollIntervalMS)
|
return nil, fmt.Errorf("POLL_INTERVAL_MS must be >= 1000, got %d", cfg.PollIntervalMS) //nolint:err113
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -59,17 +60,19 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
func (c *Config) DSN() string {
|
func (c *Config) DSN() string {
|
||||||
if c.DatabaseURL != "" {
|
if c.DatabaseURL != "" {
|
||||||
// pgx defaults to sslmode=prefer, which fails against local Postgres.
|
|
||||||
// Append sslmode=disable if not already specified.
|
// Append sslmode=disable if not already specified.
|
||||||
if !strings.Contains(c.DatabaseURL, "sslmode=") {
|
if !strings.Contains(c.DatabaseURL, "sslmode=") {
|
||||||
sep := "?"
|
sep := "?"
|
||||||
if strings.Contains(c.DatabaseURL, "?") {
|
if strings.Contains(c.DatabaseURL, "?") {
|
||||||
sep = "&"
|
sep = "&"
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.DatabaseURL + sep + "sslmode=disable"
|
return c.DatabaseURL + sep + "sslmode=disable"
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.DatabaseURL
|
return c.DatabaseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||||
c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName,
|
c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName,
|
||||||
@@ -80,6 +83,7 @@ func getEnv(key, fallback string) string {
|
|||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,5 +93,6 @@ func getEnvInt(key string, fallback int) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoad_Defaults(t *testing.T) {
|
|
||||||
for _, key := range []string{"PORT", "API_BASE_PATH", "POLL_INTERVAL_MS", "NODE_ENV", "SMTP_PORT"} {
|
|
||||||
os.Unsetenv(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Port != 3001 {
|
|
||||||
t.Errorf("Port = %d, want 3001", cfg.Port)
|
|
||||||
}
|
|
||||||
if cfg.APIBasePath != "/v1" {
|
|
||||||
t.Errorf("APIBasePath = %q, want /v1", cfg.APIBasePath)
|
|
||||||
}
|
|
||||||
if cfg.PollIntervalMS != 60000 {
|
|
||||||
t.Errorf("PollIntervalMS = %d, want 60000", cfg.PollIntervalMS)
|
|
||||||
}
|
|
||||||
if cfg.NodeEnv != "development" {
|
|
||||||
t.Errorf("NodeEnv = %q, want development", cfg.NodeEnv)
|
|
||||||
}
|
|
||||||
if cfg.SMTPPort != 587 {
|
|
||||||
t.Errorf("SMTPPort = %d, want 587", cfg.SMTPPort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoad_InvalidPollInterval(t *testing.T) {
|
|
||||||
os.Setenv("POLL_INTERVAL_MS", "500")
|
|
||||||
defer os.Unsetenv("POLL_INTERVAL_MS")
|
|
||||||
|
|
||||||
_, err := Load()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for poll interval < 1000")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDSN_DatabaseURL(t *testing.T) {
|
|
||||||
cfg := &Config{DatabaseURL: "postgres://user:pass@localhost/db"}
|
|
||||||
dsn := cfg.DSN()
|
|
||||||
if !strings.Contains(dsn, "sslmode=disable") {
|
|
||||||
t.Error("DSN should append sslmode=disable")
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(dsn, "postgres://user:pass@localhost/db") {
|
|
||||||
t.Error("DSN should preserve original URL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDSN_DatabaseURL_WithSSLMode(t *testing.T) {
|
|
||||||
cfg := &Config{DatabaseURL: "postgres://user:pass@localhost/db?sslmode=require"}
|
|
||||||
dsn := cfg.DSN()
|
|
||||||
if strings.Count(dsn, "sslmode=") != 1 {
|
|
||||||
t.Error("DSN should not duplicate sslmode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDSN_Components(t *testing.T) {
|
|
||||||
cfg := &Config{
|
|
||||||
DBHost: "myhost",
|
|
||||||
DBPort: 5433,
|
|
||||||
DBUser: "myuser",
|
|
||||||
DBPassword: "mypass",
|
|
||||||
DBName: "mydb",
|
|
||||||
}
|
|
||||||
dsn := cfg.DSN()
|
|
||||||
if !strings.Contains(dsn, "host=myhost") {
|
|
||||||
t.Error("DSN should contain host")
|
|
||||||
}
|
|
||||||
if !strings.Contains(dsn, "port=5433") {
|
|
||||||
t.Error("DSN should contain port")
|
|
||||||
}
|
|
||||||
if !strings.Contains(dsn, "user=myuser") {
|
|
||||||
t.Error("DSN should contain user")
|
|
||||||
}
|
|
||||||
if !strings.Contains(dsn, "dbname=mydb") {
|
|
||||||
t.Error("DSN should contain dbname")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetEnv(t *testing.T) {
|
|
||||||
os.Setenv("TEST_KEY_KOINPING", "value")
|
|
||||||
defer os.Unsetenv("TEST_KEY_KOINPING")
|
|
||||||
|
|
||||||
if got := getEnv("TEST_KEY_KOINPING", "fallback"); got != "value" {
|
|
||||||
t.Errorf("got %q, want %q", got, "value")
|
|
||||||
}
|
|
||||||
if got := getEnv("NONEXISTENT_KEY_KOINPING", "fallback"); got != "fallback" {
|
|
||||||
t.Errorf("got %q, want %q", got, "fallback")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetEnvInt(t *testing.T) {
|
|
||||||
os.Setenv("TEST_INT_KOINPING", "42")
|
|
||||||
defer os.Unsetenv("TEST_INT_KOINPING")
|
|
||||||
|
|
||||||
if got := getEnvInt("TEST_INT_KOINPING", 0); got != 42 {
|
|
||||||
t.Errorf("got %d, want 42", got)
|
|
||||||
}
|
|
||||||
if got := getEnvInt("NONEXISTENT_INT_KOINPING", 99); got != 99 {
|
|
||||||
t.Errorf("got %d, want 99", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Setenv("TEST_INT_KOINPING", "notanumber")
|
|
||||||
if got := getEnvInt("TEST_INT_KOINPING", 99); got != 99 {
|
|
||||||
t.Errorf("got %d, want 99 for invalid int", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,96 +26,116 @@ 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
|
||||||
SlackWebhookURL *string `json:"slack_webhook_url"`
|
TelegramChatID *string `json:"telegram_chat_id"` //nolint:tagliatelle
|
||||||
|
TelegramBotToken *string `json:"telegram_bot_token"` //nolint:tagliatelle
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
NotificationEnabled bool `json:"notification_enabled"`
|
SlackWebhookURL *string `json:"slack_webhook_url"` //nolint:tagliatelle
|
||||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
NotificationEnabled bool `json:"notification_enabled"` //nolint:tagliatelle
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
CreatedAt *time.Time `json:"created_at,omitempty"` //nolint:tagliatelle
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizedTx is a blockchain transaction normalized for internal use.
|
||||||
type NormalizedTx struct {
|
type NormalizedTx struct {
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
To *string `json:"to"`
|
To *string `json:"to"`
|
||||||
Value string `json:"value"` // Wei as string for precision
|
Value string `json:"value"` // Wei as string for precision
|
||||||
BlockNumber int `json:"block_number"`
|
BlockNumber int `json:"block_number"` //nolint:tagliatelle
|
||||||
BlockTimestamp int64 `json:"block_timestamp"`
|
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestIsValidAlertType(t *testing.T) {
|
|
||||||
valid := []string{"incoming_tx", "outgoing_tx", "large_transfer", "balance_below"}
|
|
||||||
for _, v := range valid {
|
|
||||||
if !IsValidAlertType(v) {
|
|
||||||
t.Errorf("IsValidAlertType(%q) = false, want true", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
invalid := []string{"", "invalid", "INCOMING_TX", "send", "receive"}
|
|
||||||
for _, v := range invalid {
|
|
||||||
if IsValidAlertType(v) {
|
|
||||||
t.Errorf("IsValidAlertType(%q) = true, want false", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsThresholdRequired(t *testing.T) {
|
|
||||||
required := []AlertType{AlertLargeTransfer, AlertBalanceBelow}
|
|
||||||
for _, at := range required {
|
|
||||||
if !IsThresholdRequired(at) {
|
|
||||||
t.Errorf("IsThresholdRequired(%q) = false, want true", at)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notRequired := []AlertType{AlertIncomingTx, AlertOutgoingTx}
|
|
||||||
for _, at := range notRequired {
|
|
||||||
if IsThresholdRequired(at) {
|
|
||||||
t.Errorf("IsThresholdRequired(%q) = true, want false", at)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,24 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AlertEventHandler handles HTTP requests for alert event history.
|
||||||
type AlertEventHandler struct {
|
type AlertEventHandler struct {
|
||||||
alertEvents *models.AlertEventModel
|
alertEvents *models.AlertEventModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewAlertEventHandler creates a new AlertEventHandler.
|
||||||
func NewAlertEventHandler(alertEvents *models.AlertEventModel) *AlertEventHandler {
|
func NewAlertEventHandler(alertEvents *models.AlertEventModel) *AlertEventHandler {
|
||||||
return &AlertEventHandler{alertEvents: alertEvents}
|
return &AlertEventHandler{alertEvents: alertEvents}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List handles GET requests to list recent alert events for the current user.
|
||||||
func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
|
func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.GetUserID(r.Context())
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
@@ -32,6 +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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,10 +45,51 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error listing alert events: %v", err)
|
log.Printf("Error listing alert events: %v", err)
|
||||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alert events")
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alert events")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Found %d alert events for user", len(events))
|
log.Printf("Found %d alert events for user", len(events))
|
||||||
|
|
||||||
|
// MVP scaffolding: return mock data if DB is empty
|
||||||
|
if len(events) == 0 {
|
||||||
|
events = mockEvents(limit)
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, events)
|
writeJSON(w, http.StatusOK, events)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mockEvents(limit int) []domain.AlertEvent {
|
||||||
|
label1 := "Treasury Wallet"
|
||||||
|
label2 := "Cold Storage"
|
||||||
|
|
||||||
|
mocks := []domain.AlertEvent{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
AlertRuleID: 1,
|
||||||
|
Message: "Incoming transaction detected: 5.5 ETH received",
|
||||||
|
AddressLabel: &label1,
|
||||||
|
Timestamp: time.Now().Add(-2 * time.Hour),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2, //nolint:mnd
|
||||||
|
AlertRuleID: 2, //nolint:mnd
|
||||||
|
Message: "Balance dropped below threshold: Current balance 8.2 ETH",
|
||||||
|
AddressLabel: &label1,
|
||||||
|
Timestamp: time.Now().Add(-5 * time.Hour),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3, //nolint:mnd
|
||||||
|
AlertRuleID: 3, //nolint:mnd
|
||||||
|
Message: "Outgoing transaction detected: 2.0 ETH sent",
|
||||||
|
AddressLabel: &label2,
|
||||||
|
Timestamp: time.Now().Add(-24 * time.Hour),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit < len(mocks) {
|
||||||
|
return mocks[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return mocks
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
101
backend-go/internal/handlers/email_digest.go
Normal file
101
backend-go/internal/handlers/email_digest.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailDigestHandler struct {
|
||||||
|
digestSvc *services.EmailDigestService
|
||||||
|
configs *models.NotificationConfigModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmailDigestHandler(
|
||||||
|
digestSvc *services.EmailDigestService,
|
||||||
|
configs *models.NotificationConfigModel,
|
||||||
|
) *EmailDigestHandler {
|
||||||
|
return &EmailDigestHandler{digestSvc: digestSvc, configs: configs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupEmail reads the user's email from their notification config and sends
|
||||||
|
// a confirmation message via Resend to verify the integration works.
|
||||||
|
func (h *EmailDigestHandler) SetupEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
if !h.digestSvc.Configured() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "EMAIL_NOT_CONFIGURED",
|
||||||
|
"Email service is not configured on the server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := h.configs.GetConfig(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting notification config for email setup: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR",
|
||||||
|
"Failed to load notification config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil || cfg.Email == nil || *cfg.Email == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
|
"Save an email address in notification settings first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.digestSvc.SetupEmail(*cfg.Email); err != nil {
|
||||||
|
log.Printf("Email setup failed for user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusBadGateway, "EMAIL_SEND_FAILED",
|
||||||
|
"Failed to send confirmation email — check server email config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Email setup confirmation sent to user %s (%s)", userID, *cfg.Email)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"email": *cfg.Email,
|
||||||
|
"message": "Confirmation email sent",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDigest compiles and sends a digest of recent alerts to the user's email.
|
||||||
|
func (h *EmailDigestHandler) SendDigest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
if !h.digestSvc.Configured() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "EMAIL_NOT_CONFIGURED",
|
||||||
|
"Email service is not configured on the server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := h.configs.GetConfig(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting notification config for digest: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR",
|
||||||
|
"Failed to load notification config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil || cfg.Email == nil || *cfg.Email == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
|
"No email address configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.digestSvc.SendDigest(r.Context(), userID, *cfg.Email); err != nil {
|
||||||
|
log.Printf("Digest send failed for user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusBadGateway, "DIGEST_SEND_FAILED",
|
||||||
|
"Failed to send digest email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Digest sent to user %s (%s)", userID, *cfg.Email)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"email": *cfg.Email,
|
||||||
|
"message": "Digest email sent",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
@@ -17,10 +18,11 @@ var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
|||||||
|
|
||||||
type NotificationConfigHandler struct {
|
type NotificationConfigHandler struct {
|
||||||
configs *models.NotificationConfigModel
|
configs *models.NotificationConfigModel
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNotificationConfigHandler(configs *models.NotificationConfigModel) *NotificationConfigHandler {
|
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, cfg *config.Config) *NotificationConfigHandler {
|
||||||
return &NotificationConfigHandler{configs: configs}
|
return &NotificationConfigHandler{configs: configs, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -51,8 +53,10 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
DiscordWebhookURL *string `json:"discord_webhook_url"`
|
DiscordWebhookURL *string `json:"discord_webhook_url"`
|
||||||
SlackWebhookURL *string `json:"slack_webhook_url"`
|
TelegramChatID *string `json:"telegram_chat_id"`
|
||||||
|
TelegramBotToken *string `json:"telegram_bot_token"`
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
|
SlackWebhookURL *string `json:"slack_webhook_url"`
|
||||||
NotificationEnabled *bool `json:"notification_enabled"`
|
NotificationEnabled *bool `json:"notification_enabled"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
@@ -63,8 +67,9 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
|
|
||||||
log.Printf("User %s updating notification config", userID)
|
log.Printf("User %s updating notification config", userID)
|
||||||
|
|
||||||
if body.DiscordWebhookURL == nil && body.SlackWebhookURL == nil &&
|
if body.DiscordWebhookURL == nil && body.TelegramChatID == nil &&
|
||||||
body.Email == nil && body.NotificationEnabled == nil {
|
body.TelegramBotToken == nil && body.Email == nil &&
|
||||||
|
body.SlackWebhookURL == nil && body.NotificationEnabled == nil {
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
"At least one configuration field must be provided")
|
"At least one configuration field must be provided")
|
||||||
return
|
return
|
||||||
@@ -97,8 +102,10 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
|
|
||||||
cfg := domain.NotificationConfig{
|
cfg := domain.NotificationConfig{
|
||||||
DiscordWebhookURL: body.DiscordWebhookURL,
|
DiscordWebhookURL: body.DiscordWebhookURL,
|
||||||
SlackWebhookURL: body.SlackWebhookURL,
|
TelegramChatID: body.TelegramChatID,
|
||||||
|
TelegramBotToken: body.TelegramBotToken,
|
||||||
Email: body.Email,
|
Email: body.Email,
|
||||||
|
SlackWebhookURL: body.SlackWebhookURL,
|
||||||
NotificationEnabled: enabled,
|
NotificationEnabled: enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,49 +142,75 @@ func (h *NotificationConfigHandler) DeleteConfig(w http.ResponseWriter, r *http.
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *NotificationConfigHandler) TestWebhook(w http.ResponseWriter, r *http.Request) {
|
// TestChannels sends a test message to all configured notification channels.
|
||||||
_ = middleware.GetUserID(r.Context())
|
func (h *NotificationConfigHandler) TestChannels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
var body struct {
|
log.Printf("User %s testing notification channels", userID)
|
||||||
Type string `json:"type"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if body.URL == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "URL is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var ok bool
|
|
||||||
var err error
|
|
||||||
|
|
||||||
switch body.Type {
|
|
||||||
case "slack":
|
|
||||||
if !strings.HasPrefix(body.URL, "https://hooks.slack.com/") {
|
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Slack webhook URL")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ok, err = notifications.TestSlackWebhook(body.URL)
|
|
||||||
case "discord":
|
|
||||||
if !strings.HasPrefix(body.URL, "https://discord.com/api/webhooks/") {
|
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Discord webhook URL")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ok, err = notifications.TestDiscordWebhook(body.URL)
|
|
||||||
default:
|
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Type must be 'slack' or 'discord'")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
cfg, err := h.configs.GetConfig(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Webhook test failed: %v", err)
|
log.Printf("Error getting notification config for test: %v", err)
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "error": err.Error()})
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get notification config")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"success": ok})
|
if cfg == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "No notification configuration found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelResult struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []channelResult
|
||||||
|
|
||||||
|
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||||
|
ok, testErr := notifications.TestDiscordWebhook(*cfg.DiscordWebhookURL)
|
||||||
|
res := channelResult{Channel: "discord", Success: ok}
|
||||||
|
if testErr != nil {
|
||||||
|
res.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||||
|
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||||
|
ok, testErr := notifications.TestTelegramWebhook(*cfg.TelegramBotToken, *cfg.TelegramChatID)
|
||||||
|
res := channelResult{Channel: "telegram", Success: ok}
|
||||||
|
if testErr != nil {
|
||||||
|
res.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
|
||||||
|
ok, testErr := notifications.TestSlackWebhook(*cfg.SlackWebhookURL)
|
||||||
|
res := channelResult{Channel: "slack", Success: ok}
|
||||||
|
if testErr != nil {
|
||||||
|
res.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Email != nil && *cfg.Email != "" {
|
||||||
|
ok, testErr := notifications.TestEmailNotification(
|
||||||
|
h.cfg.ResendAPIKey, h.cfg.EmailFrom, *cfg.Email,
|
||||||
|
)
|
||||||
|
res := channelResult{Channel: "email", Success: ok}
|
||||||
|
if testErr != nil {
|
||||||
|
res.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "NO_CHANNELS",
|
||||||
|
"No notification channels are configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -18,62 +13,11 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusHandler struct {
|
func SystemStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
eth ethereum.EthereumObserver
|
|
||||||
checkpoints *models.CheckpointModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStatusHandler(eth ethereum.EthereumObserver, checkpoints *models.CheckpointModel) *StatusHandler {
|
|
||||||
return &StatusHandler{eth: eth, checkpoints: checkpoints}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *StatusHandler) SystemStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
status := "healthy"
|
|
||||||
|
|
||||||
if h.eth == nil {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"latestBlock": 0,
|
|
||||||
"lag": 0,
|
|
||||||
"trackedAddresses": 0,
|
|
||||||
"status": "no_rpc",
|
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
latestBlock, err := h.eth.GetLatestBlockNumber(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to get latest block: %v", err)
|
|
||||||
status = "degraded"
|
|
||||||
latestBlock = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
lag := 0
|
|
||||||
checkpoints, err := h.checkpoints.ListAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to list checkpoints: %v", err)
|
|
||||||
} else if len(checkpoints) > 0 && latestBlock > 0 {
|
|
||||||
minChecked := checkpoints[0].LastCheckedBlock
|
|
||||||
for _, cp := range checkpoints[1:] {
|
|
||||||
if cp.LastCheckedBlock < minChecked {
|
|
||||||
minChecked = cp.LastCheckedBlock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lag = latestBlock - minChecked
|
|
||||||
}
|
|
||||||
|
|
||||||
if lag > 50 {
|
|
||||||
status = "syncing"
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"latestBlock": latestBlock,
|
"latestBlock": 0,
|
||||||
"lag": lag,
|
"lag": 0,
|
||||||
"trackedAddresses": len(checkpoints),
|
"status": "healthy",
|
||||||
"status": status,
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package middleware provides HTTP middleware for authentication and context injection.
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -18,15 +20,15 @@ func NewNotificationConfigModel(pool *pgxpool.Pool) *NotificationConfigModel {
|
|||||||
func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string) (*domain.NotificationConfig, error) {
|
func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string) (*domain.NotificationConfig, error) {
|
||||||
var c domain.NotificationConfig
|
var c domain.NotificationConfig
|
||||||
err := m.pool.QueryRow(ctx,
|
err := m.pool.QueryRow(ctx,
|
||||||
`SELECT user_id, discord_webhook_url, slack_webhook_url,
|
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||||
email, notification_enabled, created_at, updated_at
|
email, slack_webhook_url, notification_enabled, created_at, updated_at
|
||||||
FROM user_notification_configs
|
FROM user_notification_configs
|
||||||
WHERE user_id = $1`,
|
WHERE user_id = $1`,
|
||||||
userID,
|
userID,
|
||||||
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL,
|
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
||||||
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
&c.Email, &c.SlackWebhookURL, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "no rows in result set" {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -38,21 +40,24 @@ func (m *NotificationConfigModel) UpsertConfig(ctx context.Context, userID strin
|
|||||||
var c domain.NotificationConfig
|
var c domain.NotificationConfig
|
||||||
err := m.pool.QueryRow(ctx,
|
err := m.pool.QueryRow(ctx,
|
||||||
`INSERT INTO user_notification_configs
|
`INSERT INTO user_notification_configs
|
||||||
(user_id, discord_webhook_url, slack_webhook_url, email, notification_enabled, updated_at)
|
(user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
email, slack_webhook_url, notification_enabled, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
ON CONFLICT (user_id)
|
ON CONFLICT (user_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url),
|
discord_webhook_url = $2,
|
||||||
slack_webhook_url = COALESCE($3, user_notification_configs.slack_webhook_url),
|
telegram_chat_id = $3,
|
||||||
email = COALESCE($4, user_notification_configs.email),
|
telegram_bot_token = $4,
|
||||||
notification_enabled = $5,
|
email = $5,
|
||||||
|
slack_webhook_url = $6,
|
||||||
|
notification_enabled = $7,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING user_id, discord_webhook_url, slack_webhook_url,
|
RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||||
email, notification_enabled, created_at, updated_at`,
|
email, slack_webhook_url, notification_enabled, created_at, updated_at`,
|
||||||
userID, cfg.DiscordWebhookURL, cfg.SlackWebhookURL,
|
userID, cfg.DiscordWebhookURL, cfg.TelegramChatID, cfg.TelegramBotToken,
|
||||||
cfg.Email, cfg.NotificationEnabled,
|
cfg.Email, cfg.SlackWebhookURL, cfg.NotificationEnabled,
|
||||||
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL,
|
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
||||||
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
&c.Email, &c.SlackWebhookURL, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -72,7 +77,8 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
|
|||||||
|
|
||||||
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT user_id, discord_webhook_url, slack_webhook_url, email
|
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||||
|
email, slack_webhook_url
|
||||||
FROM user_notification_configs
|
FROM user_notification_configs
|
||||||
WHERE notification_enabled = TRUE`,
|
WHERE notification_enabled = TRUE`,
|
||||||
)
|
)
|
||||||
@@ -84,7 +90,8 @@ func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.Not
|
|||||||
var configs []domain.NotificationConfig
|
var configs []domain.NotificationConfig
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c domain.NotificationConfig
|
var c domain.NotificationConfig
|
||||||
if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL, &c.Email); err != nil {
|
if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID,
|
||||||
|
&c.TelegramBotToken, &c.Email, &c.SlackWebhookURL); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.NotificationEnabled = true
|
c.NotificationEnabled = true
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
package notifications
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSendDiscordNotification(t *testing.T) {
|
|
||||||
var receivedPayload discordPayload
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
t.Errorf("expected POST, got %s", r.Method)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
json.Unmarshal(body, &receivedPayload)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
meta := AlertMetadata{
|
|
||||||
TxHash: "0xabc123",
|
|
||||||
AddressLabel: "Treasury",
|
|
||||||
AlertType: "incoming_tx",
|
|
||||||
Address: "0x1234567890abcdef1234567890abcdef12345678",
|
|
||||||
}
|
|
||||||
|
|
||||||
sent, err := SendDiscordNotification(server.URL, "Incoming transaction: 5.5 ETH received", meta)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !sent {
|
|
||||||
t.Fatal("expected sent to be true")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(receivedPayload.Embeds) != 1 {
|
|
||||||
t.Fatalf("expected 1 embed, got %d", len(receivedPayload.Embeds))
|
|
||||||
}
|
|
||||||
embed := receivedPayload.Embeds[0]
|
|
||||||
if embed.Title != "Koin Ping Alert" {
|
|
||||||
t.Errorf("title = %q, want %q", embed.Title, "Koin Ping Alert")
|
|
||||||
}
|
|
||||||
if embed.Color != 0x00ff00 {
|
|
||||||
t.Errorf("color = %x, want %x (green for incoming_tx)", embed.Color, 0x00ff00)
|
|
||||||
}
|
|
||||||
if len(embed.Fields) != 3 {
|
|
||||||
t.Errorf("expected 3 fields (address, blockchain address, tx), got %d", len(embed.Fields))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendDiscordNotification_ServerError(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
sent, err := SendDiscordNotification(server.URL, "test", AlertMetadata{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for bad response")
|
|
||||||
}
|
|
||||||
if sent {
|
|
||||||
t.Fatal("expected sent to be false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTestDiscordWebhook(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
ok, err := TestDiscordWebhook(server.URL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected ok to be true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestColorForAlertType(t *testing.T) {
|
|
||||||
tests := map[string]int{
|
|
||||||
"incoming_tx": 0x00ff00,
|
|
||||||
"outgoing_tx": 0xff9900,
|
|
||||||
"large_transfer": 0xff0000,
|
|
||||||
"balance_below": 0xff0000,
|
|
||||||
"unknown": 0x0099ff,
|
|
||||||
}
|
|
||||||
for alertType, want := range tests {
|
|
||||||
if got := colorForAlertType(alertType); got != want {
|
|
||||||
t.Errorf("colorForAlertType(%q) = %x, want %x", alertType, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +1,132 @@
|
|||||||
package notifications
|
package notifications
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/smtp"
|
"net/http"
|
||||||
"strings"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SMTPConfig struct {
|
const emailHTTPTimeoutSeconds = 10
|
||||||
Host string
|
|
||||||
Port int
|
var emailHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
Username string
|
Timeout: emailHTTPTimeoutSeconds * time.Second,
|
||||||
Password string
|
|
||||||
From string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SMTPConfig) IsConfigured() bool {
|
type resendPayload struct {
|
||||||
return c.Host != "" && c.From != ""
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
HTML string `json:"html"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SMTPConfig) addr() string {
|
func SendEmailNotification(apiKey, fromAddress, toAddress, message string, meta AlertMetadata) (bool, error) {
|
||||||
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
if apiKey == "" {
|
||||||
}
|
log.Printf("Skipping email notification: RESEND_API_KEY not configured")
|
||||||
|
return false, nil
|
||||||
func SendEmailNotification(cfg SMTPConfig, toEmail, message string, meta AlertMetadata) (bool, error) {
|
|
||||||
if !cfg.IsConfigured() {
|
|
||||||
return false, fmt.Errorf("SMTP is not configured")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
subject := fmt.Sprintf("Koin Ping Alert: %s", humanAlertType(meta.AlertType))
|
subject := fmt.Sprintf("Koin Ping Alert: %s", alertTypeLabel(meta.AlertType))
|
||||||
|
|
||||||
body := buildEmailBody(message, meta)
|
txLink := ""
|
||||||
|
if meta.TxHash != "" {
|
||||||
msg := strings.Join([]string{
|
txLink = fmt.Sprintf(
|
||||||
fmt.Sprintf("From: %s", cfg.From),
|
`<p><a href="https://etherscan.io/tx/%s">View on Etherscan</a></p>`,
|
||||||
fmt.Sprintf("To: %s", toEmail),
|
meta.TxHash,
|
||||||
fmt.Sprintf("Subject: %s", subject),
|
)
|
||||||
"MIME-Version: 1.0",
|
|
||||||
"Content-Type: text/html; charset=UTF-8",
|
|
||||||
"",
|
|
||||||
body,
|
|
||||||
}, "\r\n")
|
|
||||||
|
|
||||||
var auth smtp.Auth
|
|
||||||
if cfg.Username != "" {
|
|
||||||
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := smtp.SendMail(cfg.addr(), auth, cfg.From, []string{toEmail}, []byte(msg))
|
html := fmt.Sprintf(`
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Koin Ping Alert</h2>
|
||||||
|
<p style="font-size: 16px;">%s</p>
|
||||||
|
<table style="margin: 16px 0; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 12px 4px 0; color: #666;">Address</td>
|
||||||
|
<td style="padding: 4px 0;">%s</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 12px 4px 0; color: #666;">Blockchain</td>
|
||||||
|
<td style="padding: 4px 0; font-family: monospace; font-size: 13px;">%s</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
%s
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||||
|
<p style="font-size: 12px; color: #999;">Sent by Koin Ping</p>
|
||||||
|
</div>`,
|
||||||
|
message, meta.AddressLabel, meta.Address, txLink)
|
||||||
|
|
||||||
|
payload := resendPayload{
|
||||||
|
From: fromAddress,
|
||||||
|
To: toAddress,
|
||||||
|
Subject: subject,
|
||||||
|
HTML: html,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to send email notification to %s: %v", toEmail, err)
|
return false, fmt.Errorf("marshal email payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("create email request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
resp, err := emailHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send email notification: %v", err)
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
|
||||||
|
return false, fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEmailNotification(cfg SMTPConfig, toEmail string) (bool, error) {
|
func TestEmailNotification(apiKey, fromAddress, toAddress string) (bool, error) {
|
||||||
meta := AlertMetadata{AlertType: "test"}
|
if apiKey == "" {
|
||||||
return SendEmailNotification(cfg, toEmail,
|
return false, fmt.Errorf("email not configured: RESEND_API_KEY not set") //nolint:err113
|
||||||
"This is a test notification from Koin Ping. Your email is configured correctly!", meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildEmailBody(message string, meta AlertMetadata) string {
|
|
||||||
txSection := ""
|
|
||||||
if meta.TxHash != "" {
|
|
||||||
txSection = fmt.Sprintf(
|
|
||||||
`<tr><td style="padding:8px 0;color:#666">Transaction</td>`+
|
|
||||||
`<td style="padding:8px 0"><a href="https://etherscan.io/tx/%s" style="color:#4f46e5">View on Etherscan</a></td></tr>`,
|
|
||||||
meta.TxHash,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
payload := resendPayload{
|
||||||
<html>
|
From: fromAddress,
|
||||||
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5">
|
To: toAddress,
|
||||||
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)">
|
Subject: "Koin Ping — Test Notification",
|
||||||
<div style="background:#4f46e5;padding:20px 24px">
|
HTML: `<p>Your email alerts are configured correctly!</p><p style="font-size:12px;color:#999;">Sent by Koin Ping</p>`,
|
||||||
<h1 style="margin:0;color:#fff;font-size:18px">Koin Ping Alert</h1>
|
}
|
||||||
</div>
|
|
||||||
<div style="padding:24px">
|
body, err := json.Marshal(payload)
|
||||||
<p style="margin:0 0 16px;font-size:15px;color:#333">%s</p>
|
if err != nil {
|
||||||
<table style="width:100%%;border-collapse:collapse;font-size:14px">
|
return false, err
|
||||||
<tr><td style="padding:8px 0;color:#666">Address</td><td style="padding:8px 0">%s</td></tr>
|
}
|
||||||
<tr><td style="padding:8px 0;color:#666">Blockchain Address</td><td style="padding:8px 0;font-family:monospace;font-size:12px">%s</td></tr>
|
|
||||||
%s
|
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
|
||||||
</table>
|
if err != nil {
|
||||||
</div>
|
return false, err
|
||||||
<div style="padding:16px 24px;background:#fafafa;font-size:12px;color:#999;text-align:center">
|
}
|
||||||
Sent by Koin Ping
|
req.Header.Set("Content-Type", "application/json")
|
||||||
</div>
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
</div>
|
|
||||||
</body>
|
resp, err := emailHTTPClient.Do(req)
|
||||||
</html>`, message, meta.AddressLabel, meta.Address, txSection)
|
if err != nil {
|
||||||
|
log.Printf("Email test failed: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func humanAlertType(alertType string) string {
|
func alertTypeLabel(alertType string) string {
|
||||||
switch alertType {
|
switch alertType {
|
||||||
case "incoming_tx":
|
case "incoming_tx":
|
||||||
return "Incoming Transaction"
|
return "Incoming Transaction"
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
package notifications
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSMTPConfig_IsConfigured(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cfg SMTPConfig
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"fully configured", SMTPConfig{Host: "smtp.example.com", Port: 587, From: "noreply@example.com"}, true},
|
|
||||||
{"missing host", SMTPConfig{Port: 587, From: "noreply@example.com"}, false},
|
|
||||||
{"missing from", SMTPConfig{Host: "smtp.example.com", Port: 587}, false},
|
|
||||||
{"empty", SMTPConfig{}, false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := tt.cfg.IsConfigured(); got != tt.want {
|
|
||||||
t.Errorf("IsConfigured() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSMTPConfig_Addr(t *testing.T) {
|
|
||||||
cfg := SMTPConfig{Host: "smtp.example.com", Port: 587}
|
|
||||||
if got := cfg.addr(); got != "smtp.example.com:587" {
|
|
||||||
t.Errorf("addr() = %q, want %q", got, "smtp.example.com:587")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildEmailBody(t *testing.T) {
|
|
||||||
meta := AlertMetadata{
|
|
||||||
TxHash: "0xabc123",
|
|
||||||
AddressLabel: "Treasury",
|
|
||||||
AlertType: "incoming_tx",
|
|
||||||
Address: "0x1234567890abcdef1234567890abcdef12345678",
|
|
||||||
}
|
|
||||||
|
|
||||||
body := buildEmailBody("Incoming transaction: 5.5 ETH received", meta)
|
|
||||||
|
|
||||||
if !strings.Contains(body, "Koin Ping Alert") {
|
|
||||||
t.Error("body should contain title")
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "Incoming transaction: 5.5 ETH received") {
|
|
||||||
t.Error("body should contain message")
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "Treasury") {
|
|
||||||
t.Error("body should contain address label")
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "0x1234567890abcdef") {
|
|
||||||
t.Error("body should contain blockchain address")
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "etherscan.io/tx/0xabc123") {
|
|
||||||
t.Error("body should contain etherscan link")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildEmailBody_NoTxHash(t *testing.T) {
|
|
||||||
meta := AlertMetadata{
|
|
||||||
AddressLabel: "Cold Storage",
|
|
||||||
AlertType: "balance_below",
|
|
||||||
Address: "0xabcdef",
|
|
||||||
}
|
|
||||||
|
|
||||||
body := buildEmailBody("Balance dropped below threshold", meta)
|
|
||||||
|
|
||||||
if strings.Contains(body, "etherscan.io") {
|
|
||||||
t.Error("body should not contain etherscan link when no tx hash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHumanAlertType(t *testing.T) {
|
|
||||||
tests := map[string]string{
|
|
||||||
"incoming_tx": "Incoming Transaction",
|
|
||||||
"outgoing_tx": "Outgoing Transaction",
|
|
||||||
"large_transfer": "Large Transfer",
|
|
||||||
"balance_below": "Balance Below Threshold",
|
|
||||||
"unknown": "Alert",
|
|
||||||
"test": "Alert",
|
|
||||||
}
|
|
||||||
for alertType, want := range tests {
|
|
||||||
if got := humanAlertType(alertType); got != want {
|
|
||||||
t.Errorf("humanAlertType(%q) = %q, want %q", alertType, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendEmailNotification_NotConfigured(t *testing.T) {
|
|
||||||
cfg := SMTPConfig{}
|
|
||||||
sent, err := SendEmailNotification(cfg, "user@example.com", "test", AlertMetadata{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for unconfigured SMTP")
|
|
||||||
}
|
|
||||||
if sent {
|
|
||||||
t.Fatal("expected sent to be false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,67 +9,65 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type slackBlock struct {
|
const slackHTTPTimeoutSeconds = 10
|
||||||
Type string `json:"type"`
|
|
||||||
Text *slackText `json:"text,omitempty"`
|
var slackHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
Fields []slackText `json:"fields,omitempty"`
|
Timeout: slackHTTPTimeoutSeconds * time.Second,
|
||||||
Elements []slackText `json:"elements,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type slackText struct {
|
type slackAttachment struct {
|
||||||
Type string `json:"type"`
|
Color string `json:"color"`
|
||||||
Text string `json:"text"`
|
Title string `json:"title"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Fields []slackField `json:"fields"`
|
||||||
|
Footer string `json:"footer"`
|
||||||
|
Ts int64 `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackField struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Short bool `json:"short"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type slackPayload struct {
|
type slackPayload struct {
|
||||||
Blocks []slackBlock `json:"blocks"`
|
Text string `json:"text,omitempty"`
|
||||||
|
Attachments []slackAttachment `json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) {
|
func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) {
|
||||||
etherscanLink := ""
|
fields := []slackField{
|
||||||
if meta.TxHash != "" {
|
{Title: "Address", Value: meta.AddressLabel, Short: true},
|
||||||
etherscanLink = fmt.Sprintf("<%s|View on Etherscan>", "https://etherscan.io/tx/"+meta.TxHash)
|
{Title: "Blockchain Address", Value: fmt.Sprintf("`%s`", meta.Address), Short: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
blocks := []slackBlock{
|
if meta.TxHash != "" {
|
||||||
{
|
fields = append(fields, slackField{
|
||||||
Type: "header",
|
Title: "Transaction",
|
||||||
Text: &slackText{Type: "plain_text", Text: emojiForAlertType(meta.AlertType) + " Koin Ping Alert"},
|
Value: fmt.Sprintf("<https://etherscan.io/tx/%s|View on Etherscan>", meta.TxHash),
|
||||||
},
|
Short: false,
|
||||||
{
|
})
|
||||||
Type: "section",
|
}
|
||||||
Text: &slackText{Type: "mrkdwn", Text: message},
|
|
||||||
},
|
payload := slackPayload{
|
||||||
{
|
Attachments: []slackAttachment{
|
||||||
Type: "section",
|
{
|
||||||
Fields: []slackText{
|
Color: slackColorForAlertType(meta.AlertType),
|
||||||
{Type: "mrkdwn", Text: fmt.Sprintf("*Address:*\n%s", meta.AddressLabel)},
|
Title: "Koin Ping Alert",
|
||||||
{Type: "mrkdwn", Text: fmt.Sprintf("*Blockchain Address:*\n`%s`", meta.Address)},
|
Text: message,
|
||||||
|
Fields: fields,
|
||||||
|
Footer: "Koin Ping",
|
||||||
|
Ts: time.Now().Unix(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if etherscanLink != "" {
|
|
||||||
blocks = append(blocks, slackBlock{
|
|
||||||
Type: "section",
|
|
||||||
Text: &slackText{Type: "mrkdwn", Text: fmt.Sprintf("*Transaction:* %s", etherscanLink)},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
blocks = append(blocks, slackBlock{
|
|
||||||
Type: "context",
|
|
||||||
Elements: []slackText{
|
|
||||||
{Type: "mrkdwn", Text: fmt.Sprintf("Koin Ping | %s", time.Now().UTC().Format(time.RFC3339))},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
payload := slackPayload{Blocks: blocks}
|
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("marshal slack payload: %w", err)
|
return false, fmt.Errorf("marshal slack payload: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
resp, err := slackHTTPClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to send Slack notification: %v", err)
|
log.Printf("Failed to send Slack notification: %v", err)
|
||||||
return false, err
|
return false, err
|
||||||
@@ -86,15 +84,7 @@ func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool
|
|||||||
|
|
||||||
func TestSlackWebhook(webhookURL string) (bool, error) {
|
func TestSlackWebhook(webhookURL string) (bool, error) {
|
||||||
payload := slackPayload{
|
payload := slackPayload{
|
||||||
Blocks: []slackBlock{
|
Text: "Koin Ping test notification — Your Slack alerts are configured correctly!",
|
||||||
{
|
|
||||||
Type: "section",
|
|
||||||
Text: &slackText{
|
|
||||||
Type: "mrkdwn",
|
|
||||||
Text: "Koin Ping test notification — Your Slack webhook is configured correctly!",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
@@ -102,7 +92,7 @@ func TestSlackWebhook(webhookURL string) (bool, error) {
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
resp, err := slackHTTPClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Slack webhook test failed: %v", err)
|
log.Printf("Slack webhook test failed: %v", err)
|
||||||
return false, err
|
return false, err
|
||||||
@@ -112,17 +102,15 @@ func TestSlackWebhook(webhookURL string) (bool, error) {
|
|||||||
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func emojiForAlertType(alertType string) string {
|
func slackColorForAlertType(alertType string) string {
|
||||||
switch alertType {
|
switch alertType {
|
||||||
case "incoming_tx":
|
case "incoming_tx":
|
||||||
return "📥"
|
return "#00ff00"
|
||||||
case "outgoing_tx":
|
case "outgoing_tx":
|
||||||
return "📤"
|
return "#ff9900"
|
||||||
case "large_transfer":
|
case "large_transfer", "balance_below":
|
||||||
return "🚨"
|
return "#ff0000"
|
||||||
case "balance_below":
|
|
||||||
return "⚠️"
|
|
||||||
default:
|
default:
|
||||||
return "🔔"
|
return "#0099ff"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
package notifications
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSendSlackNotification(t *testing.T) {
|
|
||||||
var receivedPayload slackPayload
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
t.Errorf("expected POST, got %s", r.Method)
|
|
||||||
}
|
|
||||||
if r.Header.Get("Content-Type") != "application/json" {
|
|
||||||
t.Errorf("expected application/json content type")
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
json.Unmarshal(body, &receivedPayload)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
meta := AlertMetadata{
|
|
||||||
TxHash: "0xabc123",
|
|
||||||
AddressLabel: "Treasury",
|
|
||||||
AlertType: "incoming_tx",
|
|
||||||
Address: "0x1234567890abcdef1234567890abcdef12345678",
|
|
||||||
}
|
|
||||||
|
|
||||||
sent, err := SendSlackNotification(server.URL, "Incoming transaction: 5.5 ETH received", meta)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !sent {
|
|
||||||
t.Fatal("expected sent to be true")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(receivedPayload.Blocks) < 3 {
|
|
||||||
t.Fatalf("expected at least 3 blocks, got %d", len(receivedPayload.Blocks))
|
|
||||||
}
|
|
||||||
if receivedPayload.Blocks[0].Type != "header" {
|
|
||||||
t.Errorf("first block should be header, got %s", receivedPayload.Blocks[0].Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendSlackNotification_ServerError(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
sent, err := SendSlackNotification(server.URL, "test", AlertMetadata{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for server error response")
|
|
||||||
}
|
|
||||||
if sent {
|
|
||||||
t.Fatal("expected sent to be false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendSlackNotification_NoTxHash(t *testing.T) {
|
|
||||||
var receivedPayload slackPayload
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
json.Unmarshal(body, &receivedPayload)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
meta := AlertMetadata{
|
|
||||||
AddressLabel: "Treasury",
|
|
||||||
AlertType: "balance_below",
|
|
||||||
Address: "0x1234567890abcdef1234567890abcdef12345678",
|
|
||||||
}
|
|
||||||
|
|
||||||
sent, err := SendSlackNotification(server.URL, "Balance dropped below threshold", meta)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !sent {
|
|
||||||
t.Fatal("expected sent to be true")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, block := range receivedPayload.Blocks {
|
|
||||||
if block.Text != nil && block.Type == "section" {
|
|
||||||
if block.Text.Text == "*Transaction:*" {
|
|
||||||
t.Error("should not include transaction block when TxHash is empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTestSlackWebhook(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
ok, err := TestSlackWebhook(server.URL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected ok to be true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmojiForAlertType(t *testing.T) {
|
|
||||||
tests := map[string]string{
|
|
||||||
"incoming_tx": "📥",
|
|
||||||
"outgoing_tx": "📤",
|
|
||||||
"large_transfer": "🚨",
|
|
||||||
"balance_below": "⚠️",
|
|
||||||
"unknown": "🔔",
|
|
||||||
}
|
|
||||||
for alertType, want := range tests {
|
|
||||||
if got := emojiForAlertType(alertType); got != want {
|
|
||||||
t.Errorf("emojiForAlertType(%q) = %q, want %q", alertType, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
109
backend-go/internal/notifications/telegram.go
Normal file
109
backend-go/internal/notifications/telegram.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const telegramHTTPTimeoutSeconds = 10
|
||||||
|
|
||||||
|
var telegramHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
|
Timeout: telegramHTTPTimeoutSeconds * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
type telegramPayload struct {
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
ParseMode string `json:"parse_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendTelegramNotification(botToken, chatID, message string, meta AlertMetadata) (bool, error) {
|
||||||
|
text := fmt.Sprintf("*Koin Ping Alert*\n\n%s\n\n*Address:* %s\n`%s`",
|
||||||
|
escapeMarkdown(message), escapeMarkdown(meta.AddressLabel), meta.Address)
|
||||||
|
|
||||||
|
if meta.TxHash != "" {
|
||||||
|
text += fmt.Sprintf("\n\n[View on Etherscan](https://etherscan.io/tx/%s)", meta.TxHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := telegramPayload{
|
||||||
|
ChatID: chatID,
|
||||||
|
Text: text,
|
||||||
|
ParseMode: "Markdown",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("marshal telegram payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||||
|
|
||||||
|
resp, err := telegramHTTPClient.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send Telegram notification: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
|
||||||
|
return false, fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTelegramWebhook(botToken, chatID string) (bool, error) {
|
||||||
|
payload := telegramPayload{
|
||||||
|
ChatID: chatID,
|
||||||
|
Text: "Koin Ping test notification — Your Telegram alerts are configured correctly!",
|
||||||
|
ParseMode: "Markdown",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||||
|
|
||||||
|
resp, err := telegramHTTPClient.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Telegram test failed: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeMarkdown(s string) string {
|
||||||
|
replacer := []struct{ old, new string }{
|
||||||
|
{"_", "\\_"}, {"*", "\\*"}, {"[", "\\["}, {"]", "\\]"},
|
||||||
|
{"(", "\\("}, {")", "\\)"}, {"~", "\\~"}, {"`", "\\`"},
|
||||||
|
{">", "\\>"}, {"#", "\\#"}, {"+", "\\+"}, {"-", "\\-"},
|
||||||
|
{"=", "\\="}, {"|", "\\|"}, {"{", "\\{"}, {"}", "\\}"},
|
||||||
|
{".", "\\."}, {"!", "\\!"},
|
||||||
|
}
|
||||||
|
result := s
|
||||||
|
for _, r := range replacer {
|
||||||
|
result = replaceAll(result, r.old, r.new)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceAll(s, old, new string) string {
|
||||||
|
out := ""
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if string(s[i]) == old {
|
||||||
|
out += new
|
||||||
|
} else {
|
||||||
|
out += string(s[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package ethereum
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math/big"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHexToInt(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
hex string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{"0x0", 0},
|
|
||||||
{"0x1", 1},
|
|
||||||
{"0xa", 10},
|
|
||||||
{"0xff", 255},
|
|
||||||
{"0x100", 256},
|
|
||||||
{"0x1234", 4660},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got, err := hexToInt(tt.hex)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("hexToInt(%q) error: %v", tt.hex, err)
|
|
||||||
}
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("hexToInt(%q) = %d, want %d", tt.hex, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHexToInt_Invalid(t *testing.T) {
|
|
||||||
_, err := hexToInt("xyz")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for invalid hex")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHexToInt64(t *testing.T) {
|
|
||||||
got, err := hexToInt64("0x5f5e100")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if got != 100000000 {
|
|
||||||
t.Errorf("got %d, want 100000000", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHexToDecimalString(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
hex string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"0x0", "0"},
|
|
||||||
{"0x", "0"},
|
|
||||||
{"", "0"},
|
|
||||||
{"0x1", "1"},
|
|
||||||
{"0xde0b6b3a7640000", "1000000000000000000"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := hexToDecimalString(tt.hex)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("hexToDecimalString(%q) = %q, want %q", tt.hex, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHexToDecimalString_LargeValues(t *testing.T) {
|
|
||||||
expected := new(big.Int).Mul(
|
|
||||||
big.NewInt(100),
|
|
||||||
new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil),
|
|
||||||
)
|
|
||||||
hex := "0x" + expected.Text(16)
|
|
||||||
got := hexToDecimalString(hex)
|
|
||||||
if got != expected.String() {
|
|
||||||
t.Errorf("got %q, want %q", got, expected.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewJsonRpcEthereum_EmptyURL(t *testing.T) {
|
|
||||||
_, err := NewJsonRpcEthereum("")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for empty URL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewJsonRpcEthereum_ValidURL(t *testing.T) {
|
|
||||||
eth, err := NewJsonRpcEthereum("https://example.com/rpc")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if eth == nil {
|
|
||||||
t.Fatal("expected non-nil client")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
199
backend-go/internal/services/email_digest.go
Normal file
199
backend-go/internal/services/email_digest.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
resendAPIURL = "https://api.resend.com/emails"
|
||||||
|
emailHTTPTimeout = 10 * time.Second
|
||||||
|
defaultDigestMaxItems = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
|
||||||
|
|
||||||
|
// EmailDigestService handles email setup and digest sending via Resend.
|
||||||
|
type EmailDigestService struct {
|
||||||
|
apiKey string
|
||||||
|
fromAddress string
|
||||||
|
alertEvents *models.AlertEventModel
|
||||||
|
notifCfgs *models.NotificationConfigModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmailDigestService(
|
||||||
|
apiKey, fromAddress string,
|
||||||
|
alertEvents *models.AlertEventModel,
|
||||||
|
notifCfgs *models.NotificationConfigModel,
|
||||||
|
) *EmailDigestService {
|
||||||
|
return &EmailDigestService{
|
||||||
|
apiKey: apiKey,
|
||||||
|
fromAddress: fromAddress,
|
||||||
|
alertEvents: alertEvents,
|
||||||
|
notifCfgs: notifCfgs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured returns true when the Resend API key is present.
|
||||||
|
func (s *EmailDigestService) Configured() bool {
|
||||||
|
return s.apiKey != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupEmail validates the email works by sending a welcome/confirmation
|
||||||
|
// message via Resend. Called when a user saves their email in notification settings.
|
||||||
|
func (s *EmailDigestService) SetupEmail(toAddress string) error {
|
||||||
|
if !s.Configured() {
|
||||||
|
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
html := `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Welcome to Koin Ping Email Alerts</h2>
|
||||||
|
<p>Your email has been successfully configured for alert notifications.</p>
|
||||||
|
<p>You will receive alert digests at this address when events are triggered
|
||||||
|
on your watched addresses.</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||||
|
<p style="font-size: 12px; color: #999;">Sent by Koin Ping</p>
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDigest compiles recent alert events for a user and sends a digest email.
|
||||||
|
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
|
||||||
|
if !s.Configured() {
|
||||||
|
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := s.alertEvents.ListRecentByUser(ctx, userID, defaultDigestMaxItems)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch alert events: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(events) == 0 {
|
||||||
|
log.Printf("No recent alerts for user %s — skipping digest", userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows string
|
||||||
|
for _, e := range events {
|
||||||
|
label := "—"
|
||||||
|
if e.AddressLabel != nil {
|
||||||
|
label = *e.AddressLabel
|
||||||
|
}
|
||||||
|
txLink := "—"
|
||||||
|
if e.TxHash != nil {
|
||||||
|
txLink = fmt.Sprintf(
|
||||||
|
`<a href="https://etherscan.io/tx/%s" style="color:#0066cc;">%s…</a>`,
|
||||||
|
*e.TxHash, (*e.TxHash)[:10],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rows += fmt.Sprintf(`
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
|
||||||
|
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
|
||||||
|
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
|
||||||
|
<td style="padding:6px 8px; border-bottom:1px solid #eee; font-size:12px; color:#666;">%s</td>
|
||||||
|
</tr>`,
|
||||||
|
label, e.Message, txLink,
|
||||||
|
e.Timestamp.Format("Jan 2 15:04 UTC"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
html := fmt.Sprintf(`
|
||||||
|
<div style="font-family: sans-serif; max-width: 700px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Koin Ping — Alert Digest</h2>
|
||||||
|
<p>Here are your recent alerts (%d total):</p>
|
||||||
|
<table style="width:100%%; border-collapse:collapse; font-size:14px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f5f5f5;">
|
||||||
|
<th style="padding:8px; text-align:left;">Address</th>
|
||||||
|
<th style="padding:8px; text-align:left;">Alert</th>
|
||||||
|
<th style="padding:8px; text-align:left;">Tx</th>
|
||||||
|
<th style="padding:8px; text-align:left;">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>%s</tbody>
|
||||||
|
</table>
|
||||||
|
<hr style="border:none; border-top:1px solid #eee; margin:24px 0;" />
|
||||||
|
<p style="font-size:12px; color:#999;">Sent by Koin Ping</p>
|
||||||
|
</div>`, len(events), rows)
|
||||||
|
|
||||||
|
subject := fmt.Sprintf("Koin Ping Digest — %d alerts", len(events))
|
||||||
|
|
||||||
|
return s.send(toAddress, subject, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDigestsForAllUsers sends a digest email to every user that has
|
||||||
|
// notifications enabled and an email configured.
|
||||||
|
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
|
||||||
|
if !s.Configured() {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, err := s.notifCfgs.ListEnabled(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("list enabled configs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := 0
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if cfg.Email == nil || *cfg.Email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.SendDigest(ctx, cfg.UserID, *cfg.Email); err != nil {
|
||||||
|
log.Printf("Failed to send digest to user %s: %v", cfg.UserID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sent++
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type resendEmailPayload struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EmailDigestService) send(to, subject, html string) error {
|
||||||
|
payload := resendEmailPayload{
|
||||||
|
From: s.fromAddress,
|
||||||
|
To: to,
|
||||||
|
Subject: subject,
|
||||||
|
HTML: html,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal email payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, resendAPIURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.apiKey)
|
||||||
|
|
||||||
|
resp, err := digestHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send email via Resend: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("Resend API returned HTTP %d", resp.StatusCode) //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ type EvaluatorService struct {
|
|||||||
alertEvents *models.AlertEventModel
|
alertEvents *models.AlertEventModel
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
notifConfigs *models.NotificationConfigModel
|
notifConfigs *models.NotificationConfigModel
|
||||||
smtpConfig notifications.SMTPConfig
|
resendAPIKey string
|
||||||
|
emailFrom string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEvaluatorService(
|
func NewEvaluatorService(
|
||||||
@@ -27,7 +28,8 @@ func NewEvaluatorService(
|
|||||||
alertEvents *models.AlertEventModel,
|
alertEvents *models.AlertEventModel,
|
||||||
addresses *models.AddressModel,
|
addresses *models.AddressModel,
|
||||||
notifConfigs *models.NotificationConfigModel,
|
notifConfigs *models.NotificationConfigModel,
|
||||||
smtpCfg notifications.SMTPConfig,
|
resendAPIKey string,
|
||||||
|
emailFrom string,
|
||||||
) *EvaluatorService {
|
) *EvaluatorService {
|
||||||
return &EvaluatorService{
|
return &EvaluatorService{
|
||||||
eth: eth,
|
eth: eth,
|
||||||
@@ -35,7 +37,8 @@ func NewEvaluatorService(
|
|||||||
alertEvents: alertEvents,
|
alertEvents: alertEvents,
|
||||||
addresses: addresses,
|
addresses: addresses,
|
||||||
notifConfigs: notifConfigs,
|
notifConfigs: notifConfigs,
|
||||||
smtpConfig: smtpCfg,
|
resendAPIKey: resendAPIKey,
|
||||||
|
emailFrom: emailFrom,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,14 +174,19 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
|||||||
|
|
||||||
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
|
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
|
||||||
|
|
||||||
|
// Send Discord notification (non-fatal on failure)
|
||||||
if addr != nil {
|
if addr != nil {
|
||||||
go s.sendNotifications(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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EvaluatorService) sendNotifications(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
|
func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
|
||||||
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
|
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to get notification config: %v", err)
|
log.Printf("Failed to get notification config: %v", err)
|
||||||
@@ -197,29 +205,43 @@ func (s *EvaluatorService) sendNotifications(ctx context.Context, userID, messag
|
|||||||
}
|
}
|
||||||
|
|
||||||
if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" {
|
if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" {
|
||||||
sent, err := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta)
|
sent, sendErr := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta)
|
||||||
if err != nil || !sent {
|
if sendErr != nil || !sent {
|
||||||
log.Printf("Discord notification failed for user %s: %v", userID, err)
|
log.Printf("Discord notification failed for user %s: %v", userID, sendErr)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Discord notification sent to user %s", userID)
|
log.Printf("Discord notification sent to user %s", userID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if notifConfig.TelegramBotToken != nil && *notifConfig.TelegramBotToken != "" &&
|
||||||
|
notifConfig.TelegramChatID != nil && *notifConfig.TelegramChatID != "" {
|
||||||
|
sent, sendErr := notifications.SendTelegramNotification(
|
||||||
|
*notifConfig.TelegramBotToken, *notifConfig.TelegramChatID, message, meta,
|
||||||
|
)
|
||||||
|
if sendErr != nil || !sent {
|
||||||
|
log.Printf("Telegram notification failed for user %s: %v", userID, sendErr)
|
||||||
|
} else {
|
||||||
|
log.Printf("Telegram notification sent to user %s", userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if notifConfig.SlackWebhookURL != nil && *notifConfig.SlackWebhookURL != "" {
|
if notifConfig.SlackWebhookURL != nil && *notifConfig.SlackWebhookURL != "" {
|
||||||
sent, err := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta)
|
sent, sendErr := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta)
|
||||||
if err != nil || !sent {
|
if sendErr != nil || !sent {
|
||||||
log.Printf("Slack notification failed for user %s: %v", userID, err)
|
log.Printf("Slack notification failed for user %s: %v", userID, sendErr)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Slack notification sent to user %s", userID)
|
log.Printf("Slack notification sent to user %s", userID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if notifConfig.Email != nil && *notifConfig.Email != "" && s.smtpConfig.IsConfigured() {
|
if notifConfig.Email != nil && *notifConfig.Email != "" {
|
||||||
sent, err := notifications.SendEmailNotification(s.smtpConfig, *notifConfig.Email, message, meta)
|
sent, sendErr := notifications.SendEmailNotification(
|
||||||
if err != nil || !sent {
|
s.resendAPIKey, s.emailFrom, *notifConfig.Email, message, meta,
|
||||||
log.Printf("Email notification failed for user %s: %v", userID, err)
|
)
|
||||||
|
if sendErr != nil || !sent {
|
||||||
|
log.Printf("Email notification failed for user %s: %v", userID, sendErr)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Email notification sent to user %s (%s)", userID, *notifConfig.Email)
|
log.Printf("Email notification sent to user %s", userID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFilterRelevantTransactions(t *testing.T) {
|
|
||||||
to1 := "0xaaaa"
|
|
||||||
to2 := "0xbbbb"
|
|
||||||
to3 := "0xcccc"
|
|
||||||
|
|
||||||
txs := []domain.NormalizedTx{
|
|
||||||
{Hash: "0x1", From: "0xaaaa", To: &to2, Value: "1000"},
|
|
||||||
{Hash: "0x2", From: "0xbbbb", To: &to1, Value: "2000"},
|
|
||||||
{Hash: "0x3", From: "0xcccc", To: &to3, Value: "3000"},
|
|
||||||
{Hash: "0x4", From: "0xdddd", To: nil, Value: "0"},
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("finds outgoing", func(t *testing.T) {
|
|
||||||
result := filterRelevantTransactions(txs, "0xAAAA")
|
|
||||||
if len(result) != 2 {
|
|
||||||
t.Fatalf("expected 2 relevant txs (1 from, 1 to), got %d", len(result))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("finds incoming", func(t *testing.T) {
|
|
||||||
result := filterRelevantTransactions(txs, "0xBBBB")
|
|
||||||
if len(result) != 2 {
|
|
||||||
t.Fatalf("expected 2 relevant txs, got %d", len(result))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no match", func(t *testing.T) {
|
|
||||||
result := filterRelevantTransactions(txs, "0x9999")
|
|
||||||
if len(result) != 0 {
|
|
||||||
t.Fatalf("expected 0 relevant txs, got %d", len(result))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("case insensitive", func(t *testing.T) {
|
|
||||||
result := filterRelevantTransactions(txs, "0xAAAA")
|
|
||||||
if len(result) == 0 {
|
|
||||||
t.Fatal("should match case-insensitively")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateObservedTx(t *testing.T) {
|
|
||||||
to := "0xaaaa"
|
|
||||||
tx := domain.NormalizedTx{
|
|
||||||
Hash: "0xhash",
|
|
||||||
From: "0xbbbb",
|
|
||||||
To: &to,
|
|
||||||
Value: "1000",
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("incoming direction", func(t *testing.T) {
|
|
||||||
addr := domain.Address{ID: 1, Address: "0xaaaa"}
|
|
||||||
obs := createObservedTx(tx, addr)
|
|
||||||
if obs.Direction != domain.DirectionIncoming {
|
|
||||||
t.Errorf("expected incoming, got %s", obs.Direction)
|
|
||||||
}
|
|
||||||
if obs.AddressID != 1 {
|
|
||||||
t.Errorf("expected address ID 1, got %d", obs.AddressID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("outgoing direction", func(t *testing.T) {
|
|
||||||
addr := domain.Address{ID: 2, Address: "0xbbbb"}
|
|
||||||
obs := createObservedTx(tx, addr)
|
|
||||||
if obs.Direction != domain.DirectionOutgoing {
|
|
||||||
t.Errorf("expected outgoing, got %s", obs.Direction)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetStartBlock(t *testing.T) {
|
|
||||||
s := &ObserverService{}
|
|
||||||
|
|
||||||
t.Run("no checkpoint uses latest", func(t *testing.T) {
|
|
||||||
got := s.getStartBlock(0, false, 1000)
|
|
||||||
if got != 1000 {
|
|
||||||
t.Errorf("expected 1000, got %d", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("with checkpoint uses next block", func(t *testing.T) {
|
|
||||||
got := s.getStartBlock(999, true, 1000)
|
|
||||||
if got != 1000 {
|
|
||||||
t.Errorf("expected 1000, got %d", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetEndBlock(t *testing.T) {
|
|
||||||
s := &ObserverService{}
|
|
||||||
|
|
||||||
t.Run("caps at latest", func(t *testing.T) {
|
|
||||||
got := s.getEndBlock(990, 1000)
|
|
||||||
if got != 1000 {
|
|
||||||
t.Errorf("expected 1000, got %d", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("caps at maxBlocksPerRun", func(t *testing.T) {
|
|
||||||
got := s.getEndBlock(0, 200)
|
|
||||||
if got != maxBlocksPerRun-1 {
|
|
||||||
t.Errorf("expected %d, got %d", maxBlocksPerRun-1, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
package wei
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestToEth(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
wei string
|
|
||||||
want float64
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"zero string", "0", 0, false},
|
|
||||||
{"empty string", "", 0, false},
|
|
||||||
{"1 ETH", "1000000000000000000", 1.0, false},
|
|
||||||
{"0.5 ETH", "500000000000000000", 0.5, false},
|
|
||||||
{"10 ETH", "10000000000000000000", 10.0, false},
|
|
||||||
{"small amount", "1000000000000000", 0.001, false},
|
|
||||||
{"1 Wei", "1", 1e-18, false},
|
|
||||||
{"invalid", "notanumber", 0, true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := ToEth(tt.wei)
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if math.Abs(got-tt.want) > 1e-10 {
|
|
||||||
t.Fatalf("ToEth(%q) = %v, want %v", tt.wei, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromEth(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
eth float64
|
|
||||||
want string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"zero", 0, "0", false},
|
|
||||||
{"1 ETH", 1.0, "1000000000000000000", false},
|
|
||||||
{"0.5 ETH", 0.5, "500000000000000000", false},
|
|
||||||
{"10 ETH", 10.0, "10000000000000000000", false},
|
|
||||||
{"0.001 ETH", 0.001, "1000000000000000", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := FromEth(tt.eth)
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("FromEth(%v) = %q, want %q", tt.eth, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRoundTrip(t *testing.T) {
|
|
||||||
values := []float64{0.001, 0.1, 1.0, 5.5, 10.0, 100.0}
|
|
||||||
for _, eth := range values {
|
|
||||||
weiStr, err := FromEth(eth)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("FromEth(%v) error: %v", eth, err)
|
|
||||||
}
|
|
||||||
back, err := ToEth(weiStr)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ToEth(%q) error: %v", weiStr, err)
|
|
||||||
}
|
|
||||||
if math.Abs(back-eth) > 1e-10 {
|
|
||||||
t.Fatalf("round-trip %v → %q → %v (diff: %v)", eth, weiStr, back, back-eth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompare(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
a, b string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{"equal", "1000", "1000", 0},
|
|
||||||
{"a > b", "2000", "1000", 1},
|
|
||||||
{"a < b", "1000", "2000", -1},
|
|
||||||
{"zero equal", "0", "0", 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := Compare(tt.a, tt.b)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("Compare(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGreaterThanOrEqual(t *testing.T) {
|
|
||||||
ok, err := GreaterThanOrEqual("2000", "1000")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected true")
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err = GreaterThanOrEqual("1000", "1000")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected true for equal")
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err = GreaterThanOrEqual("500", "1000")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
t.Fatal("expected false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLessThan(t *testing.T) {
|
|
||||||
ok, err := LessThan("500", "1000")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected true")
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err = LessThan("1000", "1000")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
t.Fatal("expected false for equal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatAsEth(t *testing.T) {
|
|
||||||
got, err := FormatAsEth("1000000000000000000", 4)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got != "1.0000 ETH" {
|
|
||||||
t.Fatalf("got %q, want %q", got, "1.0000 ETH")
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err = FormatAsEth("500000000000000000", 2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got != "0.50 ETH" {
|
|
||||||
t.Fatalf("got %q, want %q", got, "0.50 ETH")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,24 +5,27 @@ React + Vite frontend for the on-chain monitoring and alerting system.
|
|||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
1. Install dependencies:
|
1. Install dependencies:
|
||||||
```bash
|
|
||||||
npm install
|
```bash
|
||||||
```
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
2. Run the development server:
|
2. Run the development server:
|
||||||
```bash
|
|
||||||
npm run dev
|
```bash
|
||||||
```
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
3. Build for production:
|
3. Build for production:
|
||||||
```bash
|
|
||||||
npm run build
|
```bash
|
||||||
```
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
4. Preview production build:
|
4. Preview production build:
|
||||||
```bash
|
```bash
|
||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
5767
frontend/package-lock.json
generated
5767
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,36 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
"keywords": [
|
"lint": "prettier --check 'src/**/*.{js,jsx,ts,tsx,css}'",
|
||||||
"cryptocurrency",
|
"fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx,css}'"
|
||||||
"blockchain",
|
},
|
||||||
"blockchain event moitoring"
|
"keywords": [
|
||||||
],
|
"cryptocurrency",
|
||||||
"author": "Steven Jannette",
|
"blockchain",
|
||||||
"license": "MIT",
|
"blockchain event monitoring"
|
||||||
"type": "module",
|
],
|
||||||
"dependencies": {
|
"author": "Steven Jannette",
|
||||||
"firebase": "^12.7.0",
|
"license": "MIT",
|
||||||
"react": "^19.2.3",
|
"type": "module",
|
||||||
"react-dom": "^19.2.3",
|
"dependencies": {
|
||||||
"react-router-dom": "^7.11.0"
|
"firebase": "^12.7.0",
|
||||||
},
|
"react": "^19.2.3",
|
||||||
"devDependencies": {
|
"react-dom": "^19.2.3",
|
||||||
"@types/react": "^19.2.7",
|
"react-router-dom": "^7.11.0"
|
||||||
"@types/react-dom": "^19.2.3",
|
},
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"devDependencies": {
|
||||||
"typescript": "^5.9.3",
|
"@types/react": "^19.2.7",
|
||||||
"vite": "^7.3.0"
|
"@types/react-dom": "^19.2.3",
|
||||||
}
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
4
frontend/prettierrc
Normal file
4
frontend/prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"proseWrap": "always"
|
||||||
|
}
|
||||||
@@ -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,56 +8,28 @@ 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();
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/signup" element={<Signup />} />
|
||||||
|
<Route path="*" element={<Navigate to="/login" />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Show login/signup routes if not authenticated
|
|
||||||
if (!currentUser) {
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route path="/signup" element={<Signup />} />
|
|
||||||
<Route path="*" element={<Navigate to="/login" />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show main app if authenticated
|
|
||||||
return (
|
|
||||||
<div style={{ padding: "1rem" }}>
|
|
||||||
<nav style={{ marginBottom: "1rem", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
||||||
<div>
|
<div>
|
||||||
<Link to="/addresses">Addresses</Link>{" | "}
|
<Navbar />
|
||||||
<Link to="/alerts">Alerts</Link>{" | "}
|
<Routes>
|
||||||
<Link to="/history">History</Link>
|
<Route path="/" element={<Addresses />} />
|
||||||
|
<Route path="/addresses" element={<Addresses />} />
|
||||||
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
|
<Route path="/history" element={<AlertHistory />} />
|
||||||
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
);
|
||||||
<span style={{ fontSize: "0.9rem", color: "#999" }}>
|
|
||||||
{currentUser.email}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={logout}
|
|
||||||
style={{
|
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
backgroundColor: "#333",
|
|
||||||
color: "white",
|
|
||||||
border: "1px solid #555",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Addresses />} />
|
|
||||||
<Route path="/addresses" element={<Addresses />} />
|
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
|
||||||
<Route path="/history" element={<AlertHistory />} />
|
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,35 +11,37 @@ import { API_BASE } from './config';
|
|||||||
* @returns {Promise<Object>} Created address with id and metadata
|
* @returns {Promise<Object>} Created address with id and metadata
|
||||||
*/
|
*/
|
||||||
export async function createAddress(data) {
|
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;
|
||||||
} catch {
|
} catch {
|
||||||
// If JSON parse fails, use status text
|
// If JSON parse fails, use status text
|
||||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,33 +49,35 @@ export async function createAddress(data) {
|
|||||||
* @returns {Promise<Array>} List of all addresses
|
* @returns {Promise<Array>} List of all addresses
|
||||||
*/
|
*/
|
||||||
export async function getAddresses() {
|
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;
|
||||||
} catch {
|
} catch {
|
||||||
// If JSON parse fails, use status text
|
// If JSON parse fails, use status text
|
||||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,31 +86,32 @@ export async function getAddresses() {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function deleteAddress(addressId) {
|
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;
|
||||||
} catch {
|
} catch {
|
||||||
// If JSON parse fails, use status text
|
// If JSON parse fails, use status text
|
||||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Handle network errors
|
||||||
|
if (error.message.includes("fetch")) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
// Handle network errors
|
|
||||||
if (error.message.includes('fetch')) {
|
|
||||||
throw new Error('Cannot connect to server. Is the backend running?');
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,37 @@
|
|||||||
// 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)
|
||||||
* @returns {Promise<Array>} List of alert events, sorted by most recent first
|
* @returns {Promise<Array>} List of alert events, sorted by most recent first
|
||||||
*/
|
*/
|
||||||
export async function getAlertEvents() {
|
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;
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,32 +22,37 @@ export const ALERT_TYPES = {
|
|||||||
* @returns {Promise<Object>} Created alert rule
|
* @returns {Promise<Object>} Created alert rule
|
||||||
*/
|
*/
|
||||||
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`,
|
||||||
headers: headers,
|
{
|
||||||
body: JSON.stringify(data)
|
method: "POST",
|
||||||
});
|
headers: headers,
|
||||||
|
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;
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,30 +61,35 @@ export async function createAlert(addressId, data) {
|
|||||||
* @returns {Promise<Array>} List of alert rules
|
* @returns {Promise<Array>} List of alert rules
|
||||||
*/
|
*/
|
||||||
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;
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,32 +99,34 @@ export async function getAlerts(addressId) {
|
|||||||
* @returns {Promise<Object>} Updated alert rule
|
* @returns {Promise<Object>} Updated alert rule
|
||||||
*/
|
*/
|
||||||
export async function updateAlertStatus(alertId, enabled) {
|
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;
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,27 +135,29 @@ export async function updateAlertStatus(alertId, enabled) {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function deleteAlert(alertId) {
|
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;
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("fetch")) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
if (error.message.includes('fetch')) {
|
|
||||||
throw new Error('Cannot connect to server. Is the backend running?');
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,26 @@
|
|||||||
* 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
|
||||||
* @returns {Promise<Object>} Headers object with Authorization
|
* @returns {Promise<Object>} Headers object with Authorization
|
||||||
*/
|
*/
|
||||||
export async function getAuthHeaders() {
|
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}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,16 +32,15 @@ export async function getAuthHeaders() {
|
|||||||
* @returns {Promise<Object>} Headers object with Authorization
|
* @returns {Promise<Object>} Headers object with Authorization
|
||||||
*/
|
*/
|
||||||
export async function getAuthHeadersSimple() {
|
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}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const API_BASE = import.meta.env.VITE_API_BASE || '/v1';
|
export const API_BASE = import.meta.env.VITE_API_BASE || "/v1";
|
||||||
|
|||||||
@@ -1,116 +1,183 @@
|
|||||||
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
|
||||||
* @returns {Promise<Object>} Notification config
|
* @returns {Promise<Object>} Notification config
|
||||||
*/
|
*/
|
||||||
export async function getNotificationConfig() {
|
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;
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update notification configuration
|
* Update notification configuration
|
||||||
* @param {Object} config - Configuration to update
|
* @param {Object} config
|
||||||
* @param {string} [config.discord_webhook_url] - Discord webhook URL
|
* @param {string} [config.discord_webhook_url]
|
||||||
* @param {string} [config.slack_webhook_url] - Slack webhook URL
|
* @param {string} [config.telegram_chat_id]
|
||||||
* @param {string} [config.email] - Email address
|
* @param {string} [config.telegram_bot_token]
|
||||||
* @param {boolean} [config.notification_enabled] - Enable/disable notifications
|
* @param {string} [config.email]
|
||||||
|
* @param {string} [config.slack_webhook_url]
|
||||||
|
* @param {boolean} [config.notification_enabled]
|
||||||
* @returns {Promise<Object>} Updated config
|
* @returns {Promise<Object>} Updated config
|
||||||
*/
|
*/
|
||||||
export async function updateNotificationConfig(config) {
|
export async function updateNotificationConfig(config) {
|
||||||
try {
|
try {
|
||||||
const headers = await getAuthHeaders();
|
const headers = await getAuthHeaders();
|
||||||
const response = await fetch(`${API_BASE}/notification-config`, {
|
const response = await fetch(`${API_BASE}/notification-config`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: JSON.stringify(config)
|
body: JSON.stringify(config),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorMessage = 'Failed to update notification config';
|
let errorMessage = "Failed to update notification config";
|
||||||
try {
|
try {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
errorMessage = error.message || errorMessage;
|
errorMessage = error.message || errorMessage;
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message.includes('fetch')) {
|
if (error.message.includes("fetch")) {
|
||||||
throw new Error('Cannot connect to server. Is the backend running?');
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test a webhook URL by sending a test message via the backend
|
* Set up email notifications via Resend.
|
||||||
* @param {string} type - 'discord' or 'slack'
|
* Reads the user's saved email from their config and sends a confirmation.
|
||||||
* @param {string} webhookUrl - Webhook URL to test
|
* @returns {Promise<Object>} Setup result
|
||||||
* @returns {Promise<boolean>} True if test successful
|
|
||||||
*/
|
*/
|
||||||
async function testWebhook(type, webhookUrl) {
|
export async function setupEmail() {
|
||||||
try {
|
try {
|
||||||
const headers = await getAuthHeaders();
|
const headers = await getAuthHeaders();
|
||||||
const response = await fetch(`${API_BASE}/notification-config/test`, {
|
const response = await fetch(`${API_BASE}/email/setup`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: JSON.stringify({ type, url: webhookUrl })
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return false;
|
let errorMessage = "Failed to set up email";
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("fetch")) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
return result.success === true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`${type} webhook test failed:`, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test a Discord webhook URL
|
* Send an alert digest email to the user's configured email address.
|
||||||
* @param {string} webhookUrl - Discord webhook URL to test
|
* @returns {Promise<Object>} Digest send result
|
||||||
* @returns {Promise<boolean>} True if test successful
|
|
||||||
*/
|
*/
|
||||||
export async function testDiscordWebhook(webhookUrl) {
|
export async function sendEmailDigest() {
|
||||||
return testWebhook('discord', webhookUrl);
|
try {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetch(`${API_BASE}/email/digest`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = "Failed to send digest";
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("fetch")) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test a Slack webhook URL
|
* Test all configured notification channels via the backend
|
||||||
* @param {string} webhookUrl - Slack webhook URL to test
|
* @returns {Promise<Object>} Results per channel
|
||||||
* @returns {Promise<boolean>} True if test successful
|
|
||||||
*/
|
*/
|
||||||
export async function testSlackWebhook(webhookUrl) {
|
export async function testNotificationChannels() {
|
||||||
return testWebhook('slack', webhookUrl);
|
try {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/notification-config/test`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = "Failed to test notification channels";
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("fetch")) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
// 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
|
||||||
* @returns {Promise<Object>} System status
|
* @returns {Promise<Object>} System status
|
||||||
*/
|
*/
|
||||||
export async function getSystemStatus() {
|
export async function getSystemStatus() {
|
||||||
const response = await fetch(`${API_BASE}/status`);
|
const response = await fetch(`${API_BASE}/status`);
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,43 +3,43 @@ import Input from "./Input";
|
|||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
|
|
||||||
export default function AddressForm({ onSubmit }) {
|
export default function AddressForm({ onSubmit }) {
|
||||||
const [address, setAddress] = useState("");
|
const [address, setAddress] = useState("");
|
||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
|
|
||||||
const canSubmit = address.trim().length > 0;
|
const canSubmit = address.trim().length > 0;
|
||||||
|
|
||||||
function handleSubmit(e) {
|
function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
|
|
||||||
onSubmit({
|
onSubmit({
|
||||||
address: address.trim(),
|
address: address.trim(),
|
||||||
label: label.trim(),
|
label: label.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
setAddress("");
|
setAddress("");
|
||||||
setLabel("");
|
setLabel("");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Input
|
<Input
|
||||||
label="Blockchain Address"
|
label="Blockchain Address"
|
||||||
value={address}
|
value={address}
|
||||||
onChange={setAddress}
|
onChange={setAddress}
|
||||||
placeholder="0x..."
|
placeholder="0x..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Label (optional)"
|
label="Label (optional)"
|
||||||
value={label}
|
value={label}
|
||||||
onChange={setLabel}
|
onChange={setLabel}
|
||||||
placeholder="Treasury, Cold Wallet, etc."
|
placeholder="Treasury, Cold Wallet, etc."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" disabled={!canSubmit}>
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
Add Address
|
Add Address
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,69 +3,69 @@ import Input from "./Input";
|
|||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
|
|
||||||
const ALERT_TYPES = [
|
const ALERT_TYPES = [
|
||||||
{ value: "incoming_tx", label: "Incoming transaction" },
|
{ value: "incoming_tx", label: "Incoming transaction" },
|
||||||
{ value: "outgoing_tx", label: "Outgoing transaction" },
|
{ value: "outgoing_tx", label: "Outgoing transaction" },
|
||||||
{ value: "large_transfer", label: "Large transfer" },
|
{ value: "large_transfer", label: "Large transfer" },
|
||||||
{ value: "balance_below", label: "Balance below threshold" },
|
{ value: "balance_below", label: "Balance below threshold" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AlertForm({ onSubmit }) {
|
export default function AlertForm({ onSubmit }) {
|
||||||
const [type, setType] = useState("incoming_tx");
|
const [type, setType] = useState("incoming_tx");
|
||||||
const [threshold, setThreshold] = useState("");
|
const [threshold, setThreshold] = useState("");
|
||||||
|
|
||||||
const needsThreshold =
|
const needsThreshold =
|
||||||
type === "large_transfer" || type === "balance_below";
|
type === "large_transfer" || type === "balance_below";
|
||||||
|
|
||||||
const canSubmit = needsThreshold
|
const canSubmit = needsThreshold
|
||||||
? threshold.trim() !== "" &&
|
? threshold.trim() !== "" &&
|
||||||
!isNaN(Number(threshold)) &&
|
!isNaN(Number(threshold)) &&
|
||||||
Number(threshold) > 0
|
Number(threshold) > 0
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
function handleSubmit(e) {
|
function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
|
|
||||||
onSubmit({
|
onSubmit({
|
||||||
type,
|
type,
|
||||||
threshold: needsThreshold ? Number(threshold) : undefined,
|
threshold: needsThreshold ? Number(threshold) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setThreshold("");
|
setThreshold("");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
{ALERT_TYPES.map((opt) => (
|
{ALERT_TYPES.map((opt) => (
|
||||||
<label key={opt.value} style={{ display: "block" }}>
|
<label key={opt.value} style={{ display: "block" }}>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="alertType"
|
name="alertType"
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
checked={type === opt.value}
|
checked={type === opt.value}
|
||||||
onChange={() => setType(opt.value)}
|
onChange={() => setType(opt.value)}
|
||||||
/>
|
/>{" "}
|
||||||
{" "}{opt.label}
|
{opt.label}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{needsThreshold && (
|
{needsThreshold && (
|
||||||
<Input
|
<Input
|
||||||
label="Amount (ETH)"
|
label="Amount (ETH)"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.000001"
|
step="0.000001"
|
||||||
min="0"
|
min="0"
|
||||||
value={threshold}
|
value={threshold}
|
||||||
onChange={setThreshold}
|
onChange={setThreshold}
|
||||||
placeholder="e.g. 10"
|
placeholder="e.g. 10"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" disabled={!canSubmit}>
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
Create Alert
|
Create Alert
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,20 @@ export default function Button({
|
|||||||
onClick,
|
onClick,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
type = "button",
|
type = "button",
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type={type}
|
type={type}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.5rem 1rem",
|
padding: "0.5rem 1rem",
|
||||||
fontSize: "1rem",
|
fontSize: "1rem",
|
||||||
cursor: disabled ? "not-allowed" : "pointer",
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
opacity: disabled ? 0.6 : 1,
|
opacity: disabled ? 0.6 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,27 +7,26 @@ 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" }}>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
step={step}
|
step={step}
|
||||||
min={min}
|
min={min}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "0.5rem",
|
padding: "0.5rem",
|
||||||
fontSize: "1rem",
|
fontSize: "1rem",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
85
frontend/src/components/Navbar.jsx
Normal file
85
frontend/src/components/Navbar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
@@ -20,88 +20,95 @@ const AuthContext = createContext();
|
|||||||
* @returns {Object} Auth context value
|
* @returns {Object} Auth context value
|
||||||
*/
|
*/
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AuthProvider - Wraps app and provides auth state
|
* AuthProvider - Wraps app and provides auth state
|
||||||
*/
|
*/
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const [currentUser, setCurrentUser] = useState(null);
|
const [currentUser, setCurrentUser] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign up with email and password
|
* Sign up with email and password
|
||||||
*/
|
*/
|
||||||
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(
|
||||||
return result.user;
|
auth,
|
||||||
} catch (err) {
|
email,
|
||||||
setError(err.message);
|
password,
|
||||||
throw err;
|
);
|
||||||
|
return result.user;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log in with email and password
|
* Log in with email and password
|
||||||
*/
|
*/
|
||||||
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(
|
||||||
return result.user;
|
auth,
|
||||||
} catch (err) {
|
email,
|
||||||
setError(err.message);
|
password,
|
||||||
throw err;
|
);
|
||||||
|
return result.user;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log out current user
|
* Log out current user
|
||||||
*/
|
*/
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
await signOut(auth);
|
await signOut(auth);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
throw err;
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listen for auth state changes
|
* Listen for auth state changes
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||||
setCurrentUser(user);
|
setCurrentUser(user);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup subscription
|
// Cleanup subscription
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
currentUser,
|
currentUser,
|
||||||
signup,
|
signup,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
error,
|
error,
|
||||||
loading
|
loading,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={value}>
|
<AuthContext.Provider value={value}>
|
||||||
{!loading && children}
|
{!loading && children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
33
frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -3,13 +3,14 @@ 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>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,118 +1,95 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import AddressForm from "../components/AddressForm";
|
import AddressForm from "../components/AddressForm";
|
||||||
import Button from "../components/Button";
|
import { getAddresses, createAddress } from "../api/addresses";
|
||||||
import { getAddresses, createAddress, deleteAddress } from "../api/addresses";
|
|
||||||
|
|
||||||
export default function Addresses() {
|
export default function Addresses() {
|
||||||
const [addresses, setAddresses] = useState([]);
|
const [addresses, setAddresses] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [deletingId, setDeletingId] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Load addresses on mount
|
||||||
async function fetchAddresses() {
|
useEffect(() => {
|
||||||
try {
|
async function fetchAddresses() {
|
||||||
setLoading(true);
|
try {
|
||||||
const data = await getAddresses();
|
setLoading(true);
|
||||||
setAddresses(data);
|
const data = await getAddresses();
|
||||||
} catch (err) {
|
setAddresses(data);
|
||||||
setError(err.message);
|
} catch (err) {
|
||||||
console.error("Failed to fetch addresses:", err);
|
setError(err.message);
|
||||||
} finally {
|
console.error("Failed to fetch addresses:", err);
|
||||||
setLoading(false);
|
} finally {
|
||||||
}
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAddresses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle new address submission
|
||||||
|
async function handleAddressSubmit(data) {
|
||||||
|
try {
|
||||||
|
const newAddress = await createAddress(data);
|
||||||
|
// Append new address to state
|
||||||
|
setAddresses((prev) => [...prev, newAddress]);
|
||||||
|
setError(null); // Clear any previous errors
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
console.error("Failed to create address:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchAddresses();
|
return (
|
||||||
}, []);
|
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
||||||
|
<h1>Tracked Addresses</h1>
|
||||||
|
|
||||||
async function handleAddressSubmit(data) {
|
<div style={{ marginBottom: "2rem" }}>
|
||||||
try {
|
<AddressForm onSubmit={handleAddressSubmit} />
|
||||||
const newAddress = await createAddress(data);
|
</div>
|
||||||
setAddresses((prev) => [...prev, newAddress]);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to create address:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(addressId) {
|
<div>
|
||||||
if (!window.confirm("Delete this address? All associated alert rules and events will also be removed.")) {
|
<h2>Existing Addresses</h2>
|
||||||
return;
|
{loading && <p>Loading addresses...</p>}
|
||||||
}
|
{error && <p style={{ color: "red" }}>Error: {error}</p>}
|
||||||
|
{!loading && !error && addresses.length === 0 && (
|
||||||
try {
|
<p style={{ color: "#808080" }}>
|
||||||
setDeletingId(addressId);
|
No addresses tracked yet. Add one above to get started.
|
||||||
await deleteAddress(addressId);
|
</p>
|
||||||
setAddresses((prev) => prev.filter((a) => a.id !== addressId));
|
)}
|
||||||
setError(null);
|
{addresses.length > 0 && (
|
||||||
} catch (err) {
|
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||||
setError(err.message);
|
{addresses.map((addr, index) => (
|
||||||
console.error("Failed to delete address:", err);
|
<li
|
||||||
} finally {
|
key={addr.id || index}
|
||||||
setDeletingId(null);
|
style={{
|
||||||
}
|
padding: "1rem",
|
||||||
}
|
marginBottom: "0.5rem",
|
||||||
|
border: "1px solid #444",
|
||||||
return (
|
borderRadius: "4px",
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
backgroundColor: "#333",
|
||||||
<h1>Tracked Addresses</h1>
|
}}
|
||||||
|
>
|
||||||
<div style={{ marginBottom: "2rem" }}>
|
<div
|
||||||
<AddressForm onSubmit={handleAddressSubmit} />
|
style={{
|
||||||
</div>
|
fontWeight: "bold",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
<div>
|
}}
|
||||||
<h2>Existing Addresses</h2>
|
>
|
||||||
{loading && <p>Loading addresses...</p>}
|
{addr.label || "Unlabeled"}
|
||||||
{error && <p style={{ color: "red" }}>Error: {error}</p>}
|
</div>
|
||||||
{!loading && !error && addresses.length === 0 && (
|
<div
|
||||||
<p style={{ color: "#666" }}>
|
style={{
|
||||||
No addresses tracked yet. Add one above to get started.
|
fontFamily: "monospace",
|
||||||
</p>
|
fontSize: "1.035rem",
|
||||||
)}
|
color: "#b3b3b3",
|
||||||
{addresses.length > 0 && (
|
}}
|
||||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
>
|
||||||
{addresses.map((addr, index) => (
|
{addr.address}
|
||||||
<li
|
</div>
|
||||||
key={addr.id || index}
|
</li>
|
||||||
style={{
|
))}
|
||||||
padding: "1rem",
|
</ul>
|
||||||
marginBottom: "0.5rem",
|
)}
|
||||||
border: "1px solid #444",
|
</div>
|
||||||
borderRadius: "4px",
|
</div>
|
||||||
backgroundColor: "#2a2a2a",
|
);
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: "bold", marginBottom: "0.25rem" }}>
|
|
||||||
{addr.label || "Unlabeled"}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontFamily: "monospace", fontSize: "0.9rem", color: "#999" }}>
|
|
||||||
{addr.address}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleDelete(addr.id)}
|
|
||||||
disabled={deletingId === addr.id}
|
|
||||||
style={{
|
|
||||||
backgroundColor: deletingId === addr.id ? "#333" : "#dc3545",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
cursor: deletingId === addr.id ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{deletingId === addr.id ? "Deleting..." : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,73 +2,84 @@ import { useState, useEffect } from "react";
|
|||||||
import { getAlertEvents } from "../api/alertEvents";
|
import { getAlertEvents } from "../api/alertEvents";
|
||||||
|
|
||||||
export default function AlertHistory() {
|
export default function AlertHistory() {
|
||||||
const [alertEvents, setAlertEvents] = useState([]);
|
const [alertEvents, setAlertEvents] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// Fetch alert events on mount
|
// Fetch alert events on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchAlertEvents() {
|
async function fetchAlertEvents() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getAlertEvents();
|
const data = await getAlertEvents();
|
||||||
setAlertEvents(data);
|
setAlertEvents(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to fetch alert events:", err);
|
console.error("Failed to fetch alert events:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAlertEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ padding: "2rem" }}>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchAlertEvents();
|
if (error) {
|
||||||
}, []);
|
return (
|
||||||
|
<div style={{ padding: "2rem", color: "red" }}>Error: {error}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
return (
|
||||||
return <div style={{ padding: "2rem" }}>Loading...</div>;
|
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
||||||
}
|
<h1>Recent Alerts</h1>
|
||||||
|
|
||||||
if (error) {
|
{alertEvents.length === 0 ? (
|
||||||
return <div style={{ padding: "2rem", color: "red" }}>Error: {error}</div>;
|
<p style={{ color: "#808080" }}>No alerts yet</p>
|
||||||
}
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||||
return (
|
{alertEvents.map((event) => (
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
<li
|
||||||
<h1>Recent Alerts</h1>
|
key={event.id}
|
||||||
|
style={{
|
||||||
{alertEvents.length === 0 ? (
|
padding: "1rem",
|
||||||
<p style={{ color: "#666" }}>No alerts yet</p>
|
marginBottom: "0.75rem",
|
||||||
) : (
|
border: "1px solid #444",
|
||||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
borderRadius: "4px",
|
||||||
{alertEvents.map((event) => (
|
backgroundColor: "#333",
|
||||||
<li
|
}}
|
||||||
key={event.id}
|
>
|
||||||
style={{
|
<div style={{ marginBottom: "0.5rem" }}>
|
||||||
padding: "1rem",
|
{event.message}
|
||||||
marginBottom: "0.75rem",
|
</div>
|
||||||
border: "1px solid #ddd",
|
{event.address_label && (
|
||||||
borderRadius: "4px",
|
<div
|
||||||
}}
|
style={{
|
||||||
>
|
fontSize: "1.035rem",
|
||||||
<div style={{ marginBottom: "0.5rem" }}>{event.message}</div>
|
color: "#808080",
|
||||||
{event.address_label && (
|
marginBottom: "0.25rem",
|
||||||
<div style={{ fontSize: "0.9rem", color: "#666", marginBottom: "0.25rem" }}>
|
}}
|
||||||
Address: {event.address_label}
|
>
|
||||||
</div>
|
Address: {event.address_label}
|
||||||
)}
|
</div>
|
||||||
<small style={{ color: "#999" }}>
|
)}
|
||||||
{formatTimestamp(event.timestamp)}
|
<small style={{ color: "#b3b3b3" }}>
|
||||||
</small>
|
{formatTimestamp(event.timestamp)}
|
||||||
</li>
|
</small>
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
)}
|
</ul>
|
||||||
</div>
|
)}
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to format timestamp for display
|
// Helper to format timestamp for display
|
||||||
function formatTimestamp(timestamp) {
|
function formatTimestamp(timestamp) {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,140 +4,143 @@
|
|||||||
* 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();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
setError('Please fill in all fields');
|
setError("Please fill in all fields");
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
await login(email, password);
|
||||||
|
navigate("/addresses"); // Redirect to main app
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to log in: " + err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return (
|
||||||
setError('');
|
<div
|
||||||
setLoading(true);
|
|
||||||
await login(email, password);
|
|
||||||
navigate('/addresses'); // Redirect to main app
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to log in: ' + err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
maxWidth: '400px',
|
|
||||||
margin: '4rem auto',
|
|
||||||
padding: '2rem',
|
|
||||||
border: '1px solid #333',
|
|
||||||
borderRadius: '8px'
|
|
||||||
}}>
|
|
||||||
<h1 style={{ marginBottom: '2rem', textAlign: 'center' }}>
|
|
||||||
Koin Ping - Login
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div style={{
|
|
||||||
padding: '0.75rem',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
backgroundColor: '#ff000020',
|
|
||||||
border: '1px solid #ff0000',
|
|
||||||
borderRadius: '4px',
|
|
||||||
color: '#ff6666'
|
|
||||||
}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
maxWidth: "400px",
|
||||||
padding: '0.5rem',
|
margin: "4rem auto",
|
||||||
fontSize: '1rem',
|
padding: "2rem",
|
||||||
backgroundColor: '#1a1a1a',
|
border: "1px solid #333",
|
||||||
border: '1px solid #444',
|
borderRadius: "8px",
|
||||||
borderRadius: '4px',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
}}
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
|
||||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.5rem',
|
|
||||||
fontSize: '1rem',
|
|
||||||
backgroundColor: '#1a1a1a',
|
|
||||||
border: '1px solid #444',
|
|
||||||
borderRadius: '4px',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.75rem',
|
|
||||||
fontSize: '1rem',
|
|
||||||
backgroundColor: loading ? '#333' : '#0066cc',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: loading ? 'not-allowed' : 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{loading ? 'Logging in...' : 'Log In'}
|
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
|
||||||
</button>
|
Koin Ping - Login
|
||||||
</form>
|
</h1>
|
||||||
|
|
||||||
<div style={{ marginTop: '1.5rem', textAlign: 'center' }}>
|
{error && (
|
||||||
<p style={{ color: '#999' }}>
|
<div
|
||||||
Don't have an account?{' '}
|
style={{
|
||||||
<Link
|
padding: "0.75rem",
|
||||||
to="/signup"
|
marginBottom: "1rem",
|
||||||
style={{ color: '#0066cc', textDecoration: 'none' }}
|
backgroundColor: "#ff000020",
|
||||||
>
|
border: "1px solid #ff0000",
|
||||||
Sign up here
|
borderRadius: "4px",
|
||||||
</Link>
|
color: "#ff6666",
|
||||||
</p>
|
}}
|
||||||
</div>
|
>
|
||||||
</div>
|
{error}
|
||||||
);
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
backgroundColor: "#242424",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
backgroundColor: "#242424",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.75rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
backgroundColor: loading ? "#333" : "#0066cc",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Logging in..." : "Log In"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
|
||||||
|
<p style={{ color: "#b3b3b3" }}>
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
to="/signup"
|
||||||
|
style={{ color: "#0066cc", textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
Sign up here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,182 +4,185 @@
|
|||||||
* 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();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// 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) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError("Password must be at least 6 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
await signup(email, password);
|
||||||
|
navigate("/addresses"); // Auto-login and redirect
|
||||||
|
} catch (err) {
|
||||||
|
// Firebase-specific error messages
|
||||||
|
if (err.code === "auth/email-already-in-use") {
|
||||||
|
setError("Email already in use. Try logging in instead.");
|
||||||
|
} else if (err.code === "auth/invalid-email") {
|
||||||
|
setError("Invalid email address");
|
||||||
|
} else if (err.code === "auth/weak-password") {
|
||||||
|
setError("Password is too weak");
|
||||||
|
} else {
|
||||||
|
setError("Failed to create account: " + err.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
return (
|
||||||
setError('Passwords do not match');
|
<div
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 6) {
|
|
||||||
setError('Password must be at least 6 characters');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setError('');
|
|
||||||
setLoading(true);
|
|
||||||
await signup(email, password);
|
|
||||||
navigate('/addresses'); // Auto-login and redirect
|
|
||||||
} catch (err) {
|
|
||||||
// Firebase-specific error messages
|
|
||||||
if (err.code === 'auth/email-already-in-use') {
|
|
||||||
setError('Email already in use. Try logging in instead.');
|
|
||||||
} else if (err.code === 'auth/invalid-email') {
|
|
||||||
setError('Invalid email address');
|
|
||||||
} else if (err.code === 'auth/weak-password') {
|
|
||||||
setError('Password is too weak');
|
|
||||||
} else {
|
|
||||||
setError('Failed to create account: ' + err.message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
maxWidth: '400px',
|
|
||||||
margin: '4rem auto',
|
|
||||||
padding: '2rem',
|
|
||||||
border: '1px solid #333',
|
|
||||||
borderRadius: '8px'
|
|
||||||
}}>
|
|
||||||
<h1 style={{ marginBottom: '2rem', textAlign: 'center' }}>
|
|
||||||
Koin Ping - Sign Up
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div style={{
|
|
||||||
padding: '0.75rem',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
backgroundColor: '#ff000020',
|
|
||||||
border: '1px solid #ff0000',
|
|
||||||
borderRadius: '4px',
|
|
||||||
color: '#ff6666'
|
|
||||||
}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
maxWidth: "400px",
|
||||||
padding: '0.5rem',
|
margin: "4rem auto",
|
||||||
fontSize: '1rem',
|
padding: "2rem",
|
||||||
backgroundColor: '#1a1a1a',
|
border: "1px solid #333",
|
||||||
border: '1px solid #444',
|
borderRadius: "8px",
|
||||||
borderRadius: '4px',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
}}
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.5rem',
|
|
||||||
fontSize: '1rem',
|
|
||||||
backgroundColor: '#1a1a1a',
|
|
||||||
border: '1px solid #444',
|
|
||||||
borderRadius: '4px',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
|
||||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
|
||||||
Confirm Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.5rem',
|
|
||||||
fontSize: '1rem',
|
|
||||||
backgroundColor: '#1a1a1a',
|
|
||||||
border: '1px solid #444',
|
|
||||||
borderRadius: '4px',
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.75rem',
|
|
||||||
fontSize: '1rem',
|
|
||||||
backgroundColor: loading ? '#333' : '#0066cc',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: loading ? 'not-allowed' : 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{loading ? 'Creating account...' : 'Sign Up'}
|
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
|
||||||
</button>
|
Koin Ping - Sign Up
|
||||||
</form>
|
</h1>
|
||||||
|
|
||||||
<div style={{ marginTop: '1.5rem', textAlign: 'center' }}>
|
{error && (
|
||||||
<p style={{ color: '#999' }}>
|
<div
|
||||||
Already have an account?{' '}
|
style={{
|
||||||
<Link
|
padding: "0.75rem",
|
||||||
to="/login"
|
marginBottom: "1rem",
|
||||||
style={{ color: '#0066cc', textDecoration: 'none' }}
|
backgroundColor: "#ff000020",
|
||||||
>
|
border: "1px solid #ff0000",
|
||||||
Log in here
|
borderRadius: "4px",
|
||||||
</Link>
|
color: "#ff6666",
|
||||||
</p>
|
}}
|
||||||
</div>
|
>
|
||||||
</div>
|
{error}
|
||||||
);
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
backgroundColor: "#242424",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
backgroundColor: "#242424",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
backgroundColor: "#242424",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.75rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
backgroundColor: loading ? "#333" : "#0066cc",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Creating account..." : "Sign Up"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
|
||||||
|
<p style={{ color: "#b3b3b3" }}>
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
style={{ color: "#0066cc", textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
Log in here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
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({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/v1': {
|
"/v1": {
|
||||||
target: 'http://localhost:3001',
|
target: "http://localhost:3001",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
})
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user