Compare commits
72 Commits
resend-sdk
...
mega-blast
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ed4f6afd | ||
|
|
26324150d2 | ||
|
|
b0572451d3 | ||
|
|
d9c3bd1db5 | ||
|
|
615dd1dddc | ||
|
|
288092e4b4 | ||
|
|
69a2112df9 | ||
|
|
4ba91c7d9b | ||
|
|
f9fa7def2b | ||
|
|
7b45c5b2ab | ||
|
|
0fb4dcd7d1 | ||
|
|
d3530deb46 | ||
|
|
aadcdc628e | ||
|
|
2d4fcefdcf | ||
|
|
81303573fd | ||
|
|
76fadf05d9 | ||
|
|
b9e3d8ae82 | ||
|
|
6c5ffc2340 | ||
|
|
e32aa4b6aa | ||
|
|
682c168250 | ||
|
|
e5c8f91e3f | ||
|
|
f6255ec1f1 | ||
|
|
4b77e7a4d4 | ||
|
|
8f1212813f | ||
|
|
95ec1a520a | ||
|
|
4b228d9b57 | ||
|
|
08bc3e1ea2 | ||
|
|
a091d39c08 | ||
|
|
35699a41bd | ||
|
|
2e96982d3a | ||
|
|
e48de2d6b6 | ||
|
|
9414af8108 | ||
|
|
068ca2f834 | ||
|
|
7f10bcb7de | ||
|
|
f91ca33752 | ||
|
|
2c86bba235 | ||
|
|
44dad43f1d | ||
|
|
2fe0e5b8e9 | ||
|
|
8f08105246 | ||
|
|
2bc06669e9 | ||
|
|
a2d1ff9d76 | ||
|
|
dcbf0756d2 | ||
|
|
239ea7db85 | ||
|
|
9ad414313b | ||
|
|
3ee1598478 | ||
|
|
91336130b9 | ||
|
|
e4e859d051 | ||
|
|
6f6a2c6d18 | ||
|
|
63eecaaf72 | ||
|
|
8a5f5db903 | ||
|
|
c2515472b2 | ||
|
|
5ad51271af | ||
|
|
b3a08e8f25 | ||
|
|
8716be0201 | ||
|
|
647be874e5 | ||
|
|
c12c4fe742 | ||
|
|
5f5253d495 | ||
|
|
bbe2374703 | ||
|
|
205317c920 | ||
|
|
25850825b4 | ||
|
|
f01115de86 | ||
|
|
6735000050 | ||
|
|
42a64fc043 | ||
|
|
9935817fa8 | ||
|
|
8d5716bdb7 | ||
|
|
aafe96d9a1 | ||
|
|
93168209b5 | ||
|
|
6840af1a47 | ||
|
|
7c36f3c214 | ||
|
|
c707b82f36 | ||
|
|
37ff644aa7 | ||
|
|
c80ed89c0a |
@@ -1,9 +0,0 @@
|
|||||||
name: check
|
|
||||||
on: [push]
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
||||||
- run: docker build .
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -21,7 +21,9 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Go build artifacts
|
# Go build artifacts
|
||||||
backend-go/bin/
|
backend/bin/
|
||||||
|
backend/api
|
||||||
|
backend/poller
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/build/
|
frontend/build/
|
||||||
backend-go/bin/
|
backend/bin/
|
||||||
*.lock
|
*.lock
|
||||||
Prompts/
|
Prompts/
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
3
LICENSE
3
LICENSE
@@ -2,8 +2,7 @@ MIT License
|
|||||||
|
|
||||||
Copyright (c) 2026 Steven Jannette
|
Copyright (c) 2026 Steven Jannette
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -2,7 +2,7 @@
|
|||||||
test-go test-js lint-go lint-js fmt-go fmt-js fmt-check-go fmt-check-js \
|
test-go test-js lint-go lint-js fmt-go fmt-js fmt-check-go fmt-check-js \
|
||||||
build-go build-js
|
build-go build-js
|
||||||
|
|
||||||
GODIR := backend-go
|
GODIR := backend
|
||||||
JSDIR := frontend
|
JSDIR := frontend
|
||||||
PRETTIER := $(JSDIR)/node_modules/.bin/prettier
|
PRETTIER := $(JSDIR)/node_modules/.bin/prettier
|
||||||
|
|
||||||
|
|||||||
78
README.md
78
README.md
@@ -1,9 +1,9 @@
|
|||||||
# 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
|
# Overview
|
||||||
that polls Ethereum addresses for on-chain activity and delivers real-time
|
|
||||||
alerts to users via a Go REST API backend and a React single-page application
|
Koin Ping observes on-chain activity and notifies users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
|
||||||
frontend.
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -28,8 +28,8 @@ make hooks
|
|||||||
cd frontend && npm install && cd ..
|
cd frontend && npm install && cd ..
|
||||||
|
|
||||||
# Copy and fill in environment variables
|
# Copy and fill in environment variables
|
||||||
cp backend-go/.env.example backend-go/.env
|
cp backend/.env.example backend/.env
|
||||||
# edit backend-go/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
|
# edit backend/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
|
||||||
|
|
||||||
# Run checks (requires golangci-lint)
|
# Run checks (requires golangci-lint)
|
||||||
make check
|
make check
|
||||||
@@ -38,23 +38,23 @@ make check
|
|||||||
make run
|
make run
|
||||||
|
|
||||||
# Start the poller (separate terminal)
|
# Start the poller (separate terminal)
|
||||||
cd backend-go && go run ./cmd/poller
|
cd backend && go run ./cmd/poller
|
||||||
|
|
||||||
# Start the frontend dev server (separate terminal)
|
# Start the frontend dev server (separate terminal)
|
||||||
cd frontend && npm run dev
|
cd frontend && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The API listens on `http://localhost:3001` and the frontend on
|
The API listens on `http://localhost:3001` and the frontend on
|
||||||
`http://localhost:3000` by default.
|
`http://localhost:3000` by default for development.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Crypto users who hold or actively monitor addresses need a lightweight, reliable
|
Crypto users may actively monitor addresses with webhooks integrating popular
|
||||||
way to know when on-chain activity occurs without polling block explorers
|
messaging platforms: Discord, Slack, Telegram.
|
||||||
manually. Koin Ping fills that gap: it watches a set of Ethereum addresses,
|
This lightweight, reliable framework makes instant awareness of on-chain
|
||||||
evaluates configurable alert rules (incoming transactions, outgoing
|
activity trivial, without polling block explorers manually. Koin Ping watches
|
||||||
transactions, large transfers, balance thresholds), and notifies the user
|
addresses, evaluates configurable alert rules (incoming transactions, outgoing
|
||||||
through Discord webhooks.
|
transactions, "large" transfers, balance thresholds), and sends notifications.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ frontend:
|
|||||||
|
|
||||||
```
|
```
|
||||||
koin_ping_0.2.0/
|
koin_ping_0.2.0/
|
||||||
├── backend-go/ # Go monorepo root
|
├── backend/ # Go monorepo root
|
||||||
│ ├── cmd/api/ # HTTP REST API server
|
│ ├── cmd/api/ # HTTP REST API server
|
||||||
│ ├── cmd/poller/ # Blockchain polling daemon
|
│ ├── cmd/poller/ # Blockchain polling daemon
|
||||||
│ └── internal/
|
│ └── internal/
|
||||||
@@ -99,6 +99,52 @@ to the database, and dispatches Discord notifications.
|
|||||||
Firebase, communicates with the API via fetch, and renders the address/alert
|
Firebase, communicates with the API via fetch, and renders the address/alert
|
||||||
management UI.
|
management UI.
|
||||||
|
|
||||||
|
## Setting Up Your Alert Platforms
|
||||||
|
|
||||||
|
Koin Ping can send real-time alerts to **Telegram**, **Discord**, **Slack**, and **Email**. Each channel is configured per-user through the **Notification Settings** panel on the Alerts page.
|
||||||
|
|
||||||
|
Below are step-by-step guides for setting up each platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
|
||||||
|
To receive alerts via Telegram, you need to create a bot and get your chat ID.
|
||||||
|
|
||||||
|
#### 1. Create a Telegram Bot
|
||||||
|
|
||||||
|
1. Open Telegram and search for **@BotFather** (look for the blue verified checkmark).
|
||||||
|
2. Open the conversation with BotFather and send: `/newbot`
|
||||||
|
3. BotFather will ask for a **display name** — enter something like `Koin Ping Alerts`.
|
||||||
|
4. BotFather will ask for a **username** — it must end in `bot`, e.g. `MyKoinPingBot`.
|
||||||
|
5. BotFather will reply with your **Bot Token** — a string that looks like `123456789:ABCdefGHIjklMNOpqrSTUvwxYZ`. Copy it.
|
||||||
|
|
||||||
|
#### 2. Get Your Chat ID
|
||||||
|
|
||||||
|
1. In Telegram, search for the bot username you just created and open the chat.
|
||||||
|
2. Tap **Start** or send any message (e.g. `hello`).
|
||||||
|
3. Open the following URL in your browser, replacing `YOUR_BOT_TOKEN` with the token from step 1:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates
|
||||||
|
```
|
||||||
|
|
||||||
|
4. In the JSON response, find the `"chat"` object — the `"id"` field is your **Chat ID** (a numeric value).
|
||||||
|
|
||||||
|
> **Tip:** If the `'result"` array is empty, make sure you sent a message to your bot first, then refresh the page.
|
||||||
|
|
||||||
|
#### 3. Save in Koin Ping
|
||||||
|
|
||||||
|
1. Go to the **Alerts** page in Koin Ping.
|
||||||
|
2. In the **Notification Settings** panel, find the **Telegram** section.
|
||||||
|
3. Paste your **Bot Token** and **Chat ID** into the corresponding fields.
|
||||||
|
4. Click **Save Settings**.
|
||||||
|
5. Click **Test All Channels** to verify — you should receive a test message from your bot in Telegram.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Guides for Discord, Slack, and Email coming soon.*
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT. See [LICENSE](LICENSE).
|
MIT. See [LICENSE](LICENSE).
|
||||||
@@ -106,3 +152,5 @@ MIT. See [LICENSE](LICENSE).
|
|||||||
## Author
|
## Author
|
||||||
|
|
||||||
Steven Jannette
|
Steven Jannette
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
188
REPO_POLICIES.md
188
REPO_POLICIES.md
@@ -1,188 +0,0 @@
|
|||||||
---
|
|
||||||
title: Repository Policies
|
|
||||||
last_modified: 2026-02-22
|
|
||||||
---
|
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
|
||||||
style conventions are in separate documents:
|
|
||||||
|
|
||||||
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
|
||||||
(general, bash, Docker)
|
|
||||||
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
|
||||||
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
|
||||||
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
|
||||||
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- Cross-project documentation (such as this file) must include
|
|
||||||
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
|
||||||
with the authoritative source as policies evolve.
|
|
||||||
|
|
||||||
- **ALL external references must be pinned by cryptographic hash.** This
|
|
||||||
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
|
||||||
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
|
||||||
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
|
||||||
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
|
||||||
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
|
||||||
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
|
||||||
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
|
||||||
etc. Instead, download a specific release archive from GitHub, verify its hash
|
|
||||||
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
|
||||||
install scripts are arbitrary remote code execution. This is the single most
|
|
||||||
important rule in this document. Double-check every external reference in
|
|
||||||
every file before committing. There are zero exceptions to this rule.
|
|
||||||
|
|
||||||
- Every repo with software must have a root `Makefile` with these targets:
|
|
||||||
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
|
||||||
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
|
||||||
`make hooks` (installs pre-commit hook). A model Makefile is at
|
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
|
||||||
|
|
||||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
|
||||||
instead of invoking the underlying tools directly. The Makefile is the single
|
|
||||||
source of truth for how these operations are run.
|
|
||||||
|
|
||||||
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
|
||||||
the required targets above, it should have targets for every common operation:
|
|
||||||
running a local development server (`make run`, `make dev`), re-initializing
|
|
||||||
or migrating the database (`make db-reset`, `make migrate`), building
|
|
||||||
artifacts (`make build`), generating code, seeding data, or anything else a
|
|
||||||
developer would do regularly. If someone checks out the repo and types
|
|
||||||
`make<tab>`, they should see every meaningful operation available. A new
|
|
||||||
contributor should be able to understand the entire development workflow by
|
|
||||||
reading the Makefile.
|
|
||||||
|
|
||||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
|
||||||
as a build step so the build fails if the branch is not green. For non-server
|
|
||||||
repos, the Dockerfile should bring up a development environment and run
|
|
||||||
`make check`. For server repos, `make check` should run as an early build
|
|
||||||
stage before the final image is assembled.
|
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
|
||||||
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
|
|
||||||
a successful build implies all checks pass.
|
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
|
||||||
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
|
||||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
|
||||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
|
||||||
|
|
||||||
- Pre-commit hook: `make check` if local testing is possible, otherwise
|
|
||||||
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
|
|
||||||
target to install the pre-commit hook.
|
|
||||||
|
|
||||||
- All repos with software must have tests that run via the platform-standard
|
|
||||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
|
||||||
tests exist yet, add the most minimal test possible — e.g. importing the
|
|
||||||
module under test to verify it compiles/parses. There is no excuse for
|
|
||||||
`make test` to be a no-op.
|
|
||||||
|
|
||||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
|
||||||
Makefile.
|
|
||||||
|
|
||||||
- Docker builds must complete in under 5 minutes.
|
|
||||||
|
|
||||||
- `make check` must not modify any files in the repo. Tests may use temporary
|
|
||||||
directories.
|
|
||||||
|
|
||||||
- `main` must always pass `make check`, no exceptions.
|
|
||||||
|
|
||||||
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
|
||||||
must be in `.gitignore`. No exceptions.
|
|
||||||
|
|
||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
|
||||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
|
||||||
Fetch the standard `.gitignore` from
|
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
|
||||||
a new repo.
|
|
||||||
|
|
||||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
|
||||||
|
|
||||||
- Never force-push to `main`.
|
|
||||||
|
|
||||||
- Make all changes on a feature branch. You can do whatever you want on a
|
|
||||||
feature branch.
|
|
||||||
|
|
||||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
|
||||||
manually by the user. Fetch from
|
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
|
||||||
|
|
||||||
- When pinning images or packages by hash, add a comment above the reference
|
|
||||||
with the version and date (YYYY-MM-DD).
|
|
||||||
|
|
||||||
- Use `yarn`, not `npm`.
|
|
||||||
|
|
||||||
- Write all dates as YYYY-MM-DD (ISO 8601).
|
|
||||||
|
|
||||||
- Simple projects should be configured with environment variables.
|
|
||||||
|
|
||||||
- Dockerized web services listen on port 8080 by default, overridable with
|
|
||||||
`PORT`.
|
|
||||||
|
|
||||||
- `README.md` is the primary documentation. Required sections:
|
|
||||||
- **Description**: First line must include the project name, purpose,
|
|
||||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
|
||||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
|
||||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
|
||||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
|
||||||
- **Rationale**: Why does this exist?
|
|
||||||
- **Design**: How is the program structured?
|
|
||||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
|
||||||
the todo list in the README so a new agent can pick up where the last one
|
|
||||||
left off.
|
|
||||||
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
|
||||||
`LICENSE` file in the repo root and a License section in the README.
|
|
||||||
- **Author**: [@sneak](https://sneak.berlin).
|
|
||||||
|
|
||||||
- First commit of a new repo should contain only `README.md`.
|
|
||||||
|
|
||||||
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
|
||||||
committing.
|
|
||||||
|
|
||||||
- Use SemVer.
|
|
||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
|
||||||
the binary.
|
|
||||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
|
||||||
tracking table itself. Nothing else.
|
|
||||||
- `001_schema.sql` — the full application schema.
|
|
||||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
|
||||||
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
|
||||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
|
||||||
Never edit existing migrations after release.
|
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
|
||||||
settings.
|
|
||||||
|
|
||||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
|
||||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
|
||||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
|
||||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
|
||||||
subdirectory names:
|
|
||||||
- `bin/` — executable scripts and tools
|
|
||||||
- `cmd/` — Go command entrypoints
|
|
||||||
- `configs/` — configuration templates and examples
|
|
||||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
|
||||||
- `docs/` — documentation and markdown (README.md stays in root)
|
|
||||||
- `internal/` — Go internal packages
|
|
||||||
- `internal/db/migrations/` — database migrations
|
|
||||||
- `pkg/` — Go library packages
|
|
||||||
- `share/` — systemd units, data files
|
|
||||||
- `static/` — static assets (images, fonts, etc.)
|
|
||||||
- `web/` — web frontend source
|
|
||||||
|
|
||||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
|
||||||
templates. Fetch them from
|
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
|
||||||
|
|
||||||
- New repos must contain at minimum:
|
|
||||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
|
||||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
|
||||||
- `Makefile`
|
|
||||||
- `Dockerfile`, `.dockerignore`
|
|
||||||
- `.gitea/workflows/check.yml`
|
|
||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
|
||||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
|
||||||
- Python: `pyproject.toml`
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Server
|
|
||||||
PORT=3001
|
|
||||||
API_BASE_PATH=/v1
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/koin_ping
|
|
||||||
|
|
||||||
# Ethereum JSON-RPC
|
|
||||||
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR-PROJECT-ID
|
|
||||||
|
|
||||||
# Polling interval (ms, minimum 1000)
|
|
||||||
POLL_INTERVAL_MS=60000
|
|
||||||
|
|
||||||
# Firebase
|
|
||||||
FIREBASE_PROJECT_ID=koin-ping
|
|
||||||
|
|
||||||
# Email notifications (Resend — https://resend.com)
|
|
||||||
# RESEND_API_KEY=re_xxxxxxxxxxxx
|
|
||||||
# EMAIL_FROM=Koin Ping <alerts@yourdomain.com>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
Start DB:
|
|
||||||
|
|
||||||
brew services start postgresql@15
|
|
||||||
|
|
||||||
Run Backend:
|
|
||||||
|
|
||||||
cd /Users/kjannette/workspace/koin_ping/backend-go go run ./cmd/api
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
// Package main is the entry point for the API server.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"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/firebase"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// max duration to read a request.
|
|
||||||
serverReadTimeoutSeconds = 5
|
|
||||||
// maxiduration to write a response.
|
|
||||||
serverWriteTimeoutSeconds = 10
|
|
||||||
)
|
|
||||||
|
|
||||||
//nolint:funlen
|
|
||||||
func main() {
|
|
||||||
_ = godotenv.Load() // .env is optional; env vars can also be set externally
|
|
||||||
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := database.Connect(cfg.DSN())
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to connect to database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
|
|
||||||
log.Fatalf("Failed to initialize Firebase: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
addressModel := models.NewAddressModel(pool)
|
|
||||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
|
||||||
alertEventModel := models.NewAlertEventModel(pool)
|
|
||||||
notifConfigModel := models.NewNotificationConfigModel(pool)
|
|
||||||
|
|
||||||
emailDigestSvc := services.NewEmailDigestService(
|
|
||||||
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
|
|
||||||
)
|
|
||||||
|
|
||||||
addressHandler := handlers.NewAddressHandler(addressModel)
|
|
||||||
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
|
|
||||||
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
|
||||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
|
|
||||||
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
b := cfg.APIBasePath // e.g. "/v1"
|
|
||||||
|
|
||||||
// Public routes
|
|
||||||
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
|
||||||
mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus)
|
|
||||||
|
|
||||||
// Authenticated routes — addresses
|
|
||||||
mux.Handle("POST "+b+"/addresses",
|
|
||||||
middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
|
|
||||||
mux.Handle("GET "+b+"/addresses",
|
|
||||||
middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
|
|
||||||
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
|
||||||
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
|
|
||||||
|
|
||||||
// Authenticated routes for alert rules
|
|
||||||
mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
|
|
||||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
|
|
||||||
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
|
||||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
|
||||||
mux.Handle("PATCH "+b+"/alerts/{alertId}",
|
|
||||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
|
||||||
mux.Handle("DELETE "+b+"/alerts/{alertId}",
|
|
||||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
|
|
||||||
|
|
||||||
// Authenticated routes — alert events
|
|
||||||
mux.Handle("GET "+b+"/alert-events",
|
|
||||||
middleware.Authenticate(http.HandlerFunc(alertEventHandler.List)))
|
|
||||||
|
|
||||||
// Authenticated routes — notification config
|
|
||||||
mux.Handle("GET "+b+"/notification-config",
|
|
||||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
|
||||||
mux.Handle("PUT "+b+"/notification-config",
|
|
||||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
|
||||||
mux.Handle("DELETE "+b+"/notification-config",
|
|
||||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
|
||||||
mux.Handle("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)
|
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
|
||||||
log.Printf("Server running on port %d", cfg.Port)
|
|
||||||
log.Printf("API base path: %s", cfg.APIBasePath)
|
|
||||||
log.Printf("Environment: %s", cfg.NodeEnv)
|
|
||||||
|
|
||||||
server := &http.Server{
|
|
||||||
Addr: addr,
|
|
||||||
Handler: handler,
|
|
||||||
ReadTimeout: serverReadTimeoutSeconds * time.Second,
|
|
||||||
WriteTimeout: serverWriteTimeoutSeconds * time.Second,
|
|
||||||
}
|
|
||||||
if err := server.ListenAndServe(); err != nil {
|
|
||||||
log.Fatalf("Server failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func corsMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
||||||
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"status": "ok",
|
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
"service": "koin-ping-backend",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func SystemStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"latestBlock": 0,
|
|
||||||
"lag": 0,
|
|
||||||
"status": "healthy",
|
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
// Package middleware provides HTTP middleware for authentication and context injection.
|
|
||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
|
||||||
)
|
|
||||||
|
|
||||||
type contextKey string
|
|
||||||
|
|
||||||
const (
|
|
||||||
UserIDKey contextKey = "user_id"
|
|
||||||
UserEmailKey contextKey = "user_email"
|
|
||||||
)
|
|
||||||
|
|
||||||
type errorResponse struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Authenticate(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
|
||||||
log.Println("No Authorization header or invalid format")
|
|
||||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
||||||
Error: "UNAUTHORIZED",
|
|
||||||
Message: "No authentication token provided",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
||||||
if token == "" {
|
|
||||||
log.Println("Empty token")
|
|
||||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
||||||
Error: "UNAUTHORIZED",
|
|
||||||
Message: "Invalid token format",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Verifying Firebase token...")
|
|
||||||
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Token verification failed: %v", err)
|
|
||||||
|
|
||||||
errMsg := err.Error()
|
|
||||||
if strings.Contains(errMsg, "expired") {
|
|
||||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
||||||
Error: "TOKEN_EXPIRED",
|
|
||||||
Message: "Authentication token has expired",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
||||||
Error: "UNAUTHORIZED",
|
|
||||||
Message: "Failed to verify authentication token",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := decoded.UID
|
|
||||||
email, _ := decoded.Claims["email"].(string)
|
|
||||||
|
|
||||||
log.Printf("Token verified! User ID: %s, Email: %s", userID, email)
|
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
|
||||||
ctx = context.WithValue(ctx, UserEmailKey, email)
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetUserID(ctx context.Context) string {
|
|
||||||
if v, ok := ctx.Value(UserIDKey).(string); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetUserEmail(ctx context.Context) string {
|
|
||||||
if v, ok := ctx.Value(UserEmailKey).(string); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertRuleModel struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel {
|
|
||||||
return &AlertRuleModel{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold *float64) (*domain.AlertRule, error) {
|
|
||||||
var r domain.AlertRule
|
|
||||||
err := m.pool.QueryRow(ctx,
|
|
||||||
`INSERT INTO alert_rules (address_id, type, threshold, enabled)
|
|
||||||
VALUES ($1, $2, $3, TRUE)
|
|
||||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
|
||||||
addressID, alertType.String(), threshold,
|
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
|
|
||||||
rows, err := m.pool.Query(ctx,
|
|
||||||
`SELECT id, address_id, type, threshold, enabled, created_at
|
|
||||||
FROM alert_rules
|
|
||||||
WHERE address_id = $1
|
|
||||||
ORDER BY created_at DESC`,
|
|
||||||
addressID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var rules []domain.AlertRule
|
|
||||||
for rows.Next() {
|
|
||||||
var r domain.AlertRule
|
|
||||||
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rules = append(rules, r)
|
|
||||||
}
|
|
||||||
return rules, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (*domain.AlertRule, error) {
|
|
||||||
var r domain.AlertRule
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if userID != nil {
|
|
||||||
err = m.pool.QueryRow(ctx,
|
|
||||||
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.enabled, ar.created_at
|
|
||||||
FROM alert_rules ar
|
|
||||||
JOIN addresses a ON a.id = ar.address_id
|
|
||||||
WHERE ar.id = $1 AND a.user_id = $2`,
|
|
||||||
id, *userID,
|
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
|
||||||
} else {
|
|
||||||
err = m.pool.QueryRow(ctx,
|
|
||||||
`SELECT id, address_id, type, threshold, enabled, created_at
|
|
||||||
FROM alert_rules
|
|
||||||
WHERE id = $1`,
|
|
||||||
id,
|
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool) (*domain.AlertRule, error) {
|
|
||||||
var r domain.AlertRule
|
|
||||||
err := m.pool.QueryRow(ctx,
|
|
||||||
`UPDATE alert_rules
|
|
||||||
SET enabled = $2
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
|
||||||
id, enabled,
|
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
|
|
||||||
tag, err := m.pool.Exec(ctx,
|
|
||||||
`DELETE FROM alert_rules WHERE id = $1`,
|
|
||||||
id,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return tag.RowsAffected() > 0, nil
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
package ethereum
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
const rpcTimeoutMS = 30000
|
|
||||||
|
|
||||||
type JsonRpcEthereum struct {
|
|
||||||
rpcURL string
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
|
|
||||||
if rpcURL == "" {
|
|
||||||
return nil, fmt.Errorf("JsonRpcEthereum requires a valid RPC URL")
|
|
||||||
}
|
|
||||||
return &JsonRpcEthereum{
|
|
||||||
rpcURL: rpcURL,
|
|
||||||
client: &http.Client{
|
|
||||||
Timeout: time.Duration(rpcTimeoutMS) * time.Millisecond,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type rpcRequest struct {
|
|
||||||
JSONRPC string `json:"jsonrpc"`
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Method string `json:"method"`
|
|
||||||
Params []interface{} `json:"params"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type rpcResponse struct {
|
|
||||||
JSONRPC string `json:"jsonrpc"`
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Result json.RawMessage `json:"result"`
|
|
||||||
Error *rpcError `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type rpcError struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
|
|
||||||
if params == nil {
|
|
||||||
params = []interface{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(rpcRequest{
|
|
||||||
JSONRPC: "2.0",
|
|
||||||
ID: time.Now().UnixMilli(),
|
|
||||||
Method: method,
|
|
||||||
Params: params,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("marshal RPC request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create RPC request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := j.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("RPC call failed [%s]: %w", method, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
|
|
||||||
}
|
|
||||||
|
|
||||||
var rpcResp rpcResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode RPC response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rpcResp.Error != nil {
|
|
||||||
return nil, fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rpcResp.Result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
|
|
||||||
result, err := j.callRPC(ctx, "eth_blockNumber")
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var hexBlock string
|
|
||||||
if err := json.Unmarshal(result, &hexBlock); err != nil {
|
|
||||||
return 0, fmt.Errorf("unmarshal block number: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return hexToInt(hexBlock)
|
|
||||||
}
|
|
||||||
|
|
||||||
type rpcBlock struct {
|
|
||||||
Timestamp string `json:"timestamp"`
|
|
||||||
Transactions []rpcTx `json:"transactions"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type rpcTx struct {
|
|
||||||
Hash string `json:"hash"`
|
|
||||||
From string `json:"from"`
|
|
||||||
To *string `json:"to"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
BlockNumber string `json:"blockNumber"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *JsonRpcEthereum) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) {
|
|
||||||
hexBlock := fmt.Sprintf("0x%x", blockNumber)
|
|
||||||
|
|
||||||
result, err := j.callRPC(ctx, "eth_getBlockByNumber", hexBlock, true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(result) == "null" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var block rpcBlock
|
|
||||||
if err := json.Unmarshal(result, &block); err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshal block: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
blockTimestamp, err := hexToInt64(block.Timestamp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse block timestamp: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
txs := make([]domain.NormalizedTx, 0, len(block.Transactions))
|
|
||||||
for _, tx := range block.Transactions {
|
|
||||||
bn, _ := hexToInt(tx.BlockNumber)
|
|
||||||
|
|
||||||
var toLower *string
|
|
||||||
if tx.To != nil {
|
|
||||||
s := strings.ToLower(*tx.To)
|
|
||||||
toLower = &s
|
|
||||||
}
|
|
||||||
|
|
||||||
txs = append(txs, domain.NormalizedTx{
|
|
||||||
Hash: tx.Hash,
|
|
||||||
From: strings.ToLower(tx.From),
|
|
||||||
To: toLower,
|
|
||||||
Value: hexToDecimalString(tx.Value),
|
|
||||||
BlockNumber: bn,
|
|
||||||
BlockTimestamp: blockTimestamp,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return txs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (string, error) {
|
|
||||||
result, err := j.callRPC(ctx, "eth_getBalance", address, "latest")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var hexBalance string
|
|
||||||
if err := json.Unmarshal(result, &hexBalance); err != nil {
|
|
||||||
return "", fmt.Errorf("unmarshal balance: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return hexToDecimalString(hexBalance), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hexToInt(hex string) (int, error) {
|
|
||||||
hex = strings.TrimPrefix(hex, "0x")
|
|
||||||
n, ok := new(big.Int).SetString(hex, 16)
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("invalid hex: %s", hex)
|
|
||||||
}
|
|
||||||
return int(n.Int64()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hexToInt64(hex string) (int64, error) {
|
|
||||||
hex = strings.TrimPrefix(hex, "0x")
|
|
||||||
n, ok := new(big.Int).SetString(hex, 16)
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("invalid hex: %s", hex)
|
|
||||||
}
|
|
||||||
return n.Int64(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hexToDecimalString(hex string) string {
|
|
||||||
if hex == "" || hex == "0x" || hex == "0x0" {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
clean := strings.TrimPrefix(hex, "0x")
|
|
||||||
n, ok := new(big.Int).SetString(clean, 16)
|
|
||||||
if !ok {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
return n.String()
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/wei"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EvaluatorService struct {
|
|
||||||
eth ethereum.EthereumObserver
|
|
||||||
alertRules *models.AlertRuleModel
|
|
||||||
alertEvents *models.AlertEventModel
|
|
||||||
addresses *models.AddressModel
|
|
||||||
notifConfigs *models.NotificationConfigModel
|
|
||||||
resendAPIKey string
|
|
||||||
emailFrom string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEvaluatorService(
|
|
||||||
eth ethereum.EthereumObserver,
|
|
||||||
alertRules *models.AlertRuleModel,
|
|
||||||
alertEvents *models.AlertEventModel,
|
|
||||||
addresses *models.AddressModel,
|
|
||||||
notifConfigs *models.NotificationConfigModel,
|
|
||||||
resendAPIKey string,
|
|
||||||
emailFrom string,
|
|
||||||
) *EvaluatorService {
|
|
||||||
return &EvaluatorService{
|
|
||||||
eth: eth,
|
|
||||||
alertRules: alertRules,
|
|
||||||
alertEvents: alertEvents,
|
|
||||||
addresses: addresses,
|
|
||||||
notifConfigs: notifConfigs,
|
|
||||||
resendAPIKey: resendAPIKey,
|
|
||||||
emailFrom: emailFrom,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *EvaluatorService) Evaluate(ctx context.Context, observations []domain.ObservedTx) (int, error) {
|
|
||||||
alertsFired := 0
|
|
||||||
|
|
||||||
for _, obs := range observations {
|
|
||||||
fired, err := s.evaluateObservation(ctx, obs)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error evaluating observation for address ID %d: %v", obs.AddressID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
alertsFired += fired
|
|
||||||
}
|
|
||||||
|
|
||||||
return alertsFired, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.ObservedTx) (int, error) {
|
|
||||||
rules, err := s.alertRules.ListByAddress(ctx, obs.AddressID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
alertsFired := 0
|
|
||||||
for _, rule := range rules {
|
|
||||||
if !rule.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
matches, err := s.ruleMatches(ctx, rule, obs)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error matching rule %d: %v", rule.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches {
|
|
||||||
if err := s.fireAlert(ctx, rule, obs); err != nil {
|
|
||||||
log.Printf("Error firing alert for rule %d: %v", rule.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
alertsFired++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return alertsFired, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
|
||||||
switch rule.Type {
|
|
||||||
case domain.AlertIncomingTx:
|
|
||||||
return obs.Direction == domain.DirectionIncoming, nil
|
|
||||||
case domain.AlertOutgoingTx:
|
|
||||||
return obs.Direction == domain.DirectionOutgoing, nil
|
|
||||||
case domain.AlertLargeTransfer:
|
|
||||||
return s.matchesLargeTransfer(rule, obs)
|
|
||||||
case domain.AlertBalanceBelow:
|
|
||||||
return s.matchesBalanceBelow(ctx, rule, obs)
|
|
||||||
default:
|
|
||||||
log.Printf("Unknown rule type: %s", rule.Type)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
|
||||||
if rule.Threshold == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
thresholdWei, err := wei.FromEth(*rule.Threshold)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return wei.GreaterThanOrEqual(obs.Value, thresholdWei)
|
|
||||||
}
|
|
||||||
|
|
||||||
// matchesBalanceBelow only triggers after outgoing transactions, since incoming
|
|
||||||
// transactions increase balance and can't cause it to drop below threshold.
|
|
||||||
func (s *EvaluatorService) matchesBalanceBelow(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
|
||||||
if rule.Threshold == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if obs.Direction != domain.DirectionOutgoing {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if addr == nil {
|
|
||||||
log.Printf("Address ID %d not found", obs.AddressID)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
balanceWei, err := s.eth.GetBalance(ctx, addr.Address)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
thresholdWei, err := wei.FromEth(*rule.Threshold)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return wei.LessThan(balanceWei, thresholdWei)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) error {
|
|
||||||
addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
addressLabel := "Unknown"
|
|
||||||
if addr != nil {
|
|
||||||
if addr.Label != nil {
|
|
||||||
addressLabel = *addr.Label
|
|
||||||
} else {
|
|
||||||
addressLabel = addr.Address
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
message := s.buildMessage(rule, obs)
|
|
||||||
txHash := &obs.Hash
|
|
||||||
|
|
||||||
_, err = s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
|
|
||||||
|
|
||||||
// Send Discord notification (non-fatal on failure)
|
|
||||||
if addr != nil {
|
|
||||||
go func() {
|
|
||||||
s.sendNotification(
|
|
||||||
ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address,
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to get notification config: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if notifConfig == nil || !notifConfig.NotificationEnabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := notifications.AlertMetadata{
|
|
||||||
TxHash: obs.Hash,
|
|
||||||
AddressLabel: addressLabel,
|
|
||||||
AlertType: string(rule.Type),
|
|
||||||
Address: address,
|
|
||||||
}
|
|
||||||
|
|
||||||
if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" {
|
|
||||||
sent, sendErr := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta)
|
|
||||||
if sendErr != nil || !sent {
|
|
||||||
log.Printf("Discord notification failed for user %s: %v", userID, sendErr)
|
|
||||||
} else {
|
|
||||||
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 != "" {
|
|
||||||
sent, sendErr := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta)
|
|
||||||
if sendErr != nil || !sent {
|
|
||||||
log.Printf("Slack notification failed for user %s: %v", userID, sendErr)
|
|
||||||
} else {
|
|
||||||
log.Printf("Slack notification sent to user %s", userID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if notifConfig.Email != nil && *notifConfig.Email != "" {
|
|
||||||
sent, sendErr := notifications.SendEmailNotification(
|
|
||||||
s.resendAPIKey, s.emailFrom, *notifConfig.Email, message, meta,
|
|
||||||
)
|
|
||||||
if sendErr != nil || !sent {
|
|
||||||
log.Printf("Email notification failed for user %s: %v", userID, sendErr)
|
|
||||||
} else {
|
|
||||||
log.Printf("Email notification sent to user %s", userID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
|
||||||
switch rule.Type {
|
|
||||||
case domain.AlertIncomingTx:
|
|
||||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
|
||||||
return fmt.Sprintf("Incoming transaction: %s received", ethStr)
|
|
||||||
case domain.AlertOutgoingTx:
|
|
||||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
|
||||||
return fmt.Sprintf("Outgoing transaction: %s sent", ethStr)
|
|
||||||
case domain.AlertLargeTransfer:
|
|
||||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
|
||||||
threshold := float64(0)
|
|
||||||
if rule.Threshold != nil {
|
|
||||||
threshold = *rule.Threshold
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Large transfer detected: %s (threshold: %g ETH)", ethStr, threshold)
|
|
||||||
case domain.AlertBalanceBelow:
|
|
||||||
threshold := float64(0)
|
|
||||||
if rule.Threshold != nil {
|
|
||||||
threshold = *rule.Threshold
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Balance dropped below threshold of %g ETH", threshold)
|
|
||||||
default:
|
|
||||||
return "Alert triggered"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,7 @@ tidy:
|
|||||||
|
|
||||||
# Database setup
|
# Database setup
|
||||||
db-setup:
|
db-setup:
|
||||||
psql -d koin_ping_dev -f infra/schema.sql
|
psql -d koin_ping -f infra/schema.sql
|
||||||
|
|
||||||
vet:
|
vet:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
32
backend/README.md
Normal file
32
backend/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
Backend startup quickstarat:
|
||||||
|
-----------------------------> BEST
|
||||||
|
## 1. brew services start postgresql@15
|
||||||
|
|
||||||
|
OR
|
||||||
|
|
||||||
|
/opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start
|
||||||
|
|
||||||
|
## 2. ALLIN ONE:
|
||||||
|
Make dev-all — Runs both the API and poller concurrently.
|
||||||
|
-----------------------------> BEST
|
||||||
|
|
||||||
|
- OR -
|
||||||
|
|
||||||
|
|
||||||
|
## 3. Option 1: Single command (both API + poller)
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
|
||||||
|
|
||||||
|
## 4. Option 2: Two separate terminals
|
||||||
|
Terminal 1 (API server):
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
|
||||||
|
|
||||||
|
make run — Builds and runs the API server.
|
||||||
|
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
|
||||||
|
|
||||||
|
## 5. Terminal 2 (Poller):
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend
|
||||||
|
go run ./cmd/poller
|
||||||
|
|
||||||
|
make poller — Builds and runs the poller.
|
||||||
|
make poller-dev — Runs the poller with auto-reload.
|
||||||
|
|
||||||
176
backend/cmd/api/main.go
Normal file
176
backend/cmd/api/main.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
// Package main is the entry point for the API server.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/handlers"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// max duration to read a request.
|
||||||
|
serverReadTimeoutSeconds = 5
|
||||||
|
// maxiduration to write a response.
|
||||||
|
serverWriteTimeoutSeconds = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:funlen
|
||||||
|
func main() {
|
||||||
|
_ = godotenv.Load() // .env is optional; env vars can also be set externally
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := database.Connect(cfg.DSN())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
|
||||||
|
log.Fatalf("Failed to initialize Firebase: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
userModel := models.NewUserModel(pool)
|
||||||
|
addressModel := models.NewAddressModel(pool)
|
||||||
|
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||||
|
alertEventModel := models.NewAlertEventModel(pool)
|
||||||
|
checkpointModel := models.NewCheckpointModel(pool)
|
||||||
|
notifConfigModel := models.NewNotificationConfigModel(pool)
|
||||||
|
|
||||||
|
emailDigestSvc := services.NewEmailDigestService(
|
||||||
|
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
addressHandler := handlers.NewAddressHandler(addressModel, userModel)
|
||||||
|
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel, userModel)
|
||||||
|
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
||||||
|
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
|
||||||
|
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||||
|
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||||
|
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
|
||||||
|
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
|
||||||
|
|
||||||
|
authenticate := middleware.Authenticate(userModel)
|
||||||
|
requireSub := middleware.RequireSubscription(userModel)
|
||||||
|
|
||||||
|
// authAndSub chains authentication + subscription check for protected routes.
|
||||||
|
authAndSub := func(h http.Handler) http.Handler {
|
||||||
|
return authenticate(requireSub(h))
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
b := cfg.APIBasePath // e.g. "/v1"
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
||||||
|
mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
|
||||||
|
|
||||||
|
// Stripe webhook (public — called by Stripe, not authenticated)
|
||||||
|
mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook)
|
||||||
|
|
||||||
|
// Onboarding checkout (public — account doesn't exist yet)
|
||||||
|
mux.HandleFunc("POST "+b+"/stripe/create-onboarding-checkout", stripeHandler.CreateOnboardingCheckout)
|
||||||
|
|
||||||
|
// Stripe routes (auth required, NO subscription required)
|
||||||
|
mux.Handle("POST "+b+"/stripe/create-checkout-session",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
|
||||||
|
mux.Handle("GET "+b+"/stripe/subscription-status",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
|
||||||
|
mux.Handle("POST "+b+"/stripe/verify-checkout",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
|
||||||
|
mux.Handle("POST "+b+"/stripe/create-portal-session",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.CreatePortalSession)))
|
||||||
|
mux.Handle("POST "+b+"/stripe/activate-free",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.ActivateFreeTier)))
|
||||||
|
|
||||||
|
// Account route (auth required, NO subscription required)
|
||||||
|
mux.Handle("GET "+b+"/user/account",
|
||||||
|
authenticate(http.HandlerFunc(accountHandler.GetAccount)))
|
||||||
|
|
||||||
|
// Authenticated + subscribed routes — addresses
|
||||||
|
mux.Handle("POST "+b+"/addresses",
|
||||||
|
authAndSub(http.HandlerFunc(addressHandler.Create)))
|
||||||
|
mux.Handle("GET "+b+"/addresses",
|
||||||
|
authAndSub(http.HandlerFunc(addressHandler.List)))
|
||||||
|
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
||||||
|
authAndSub(http.HandlerFunc(addressHandler.Remove)))
|
||||||
|
mux.Handle("PATCH "+b+"/addresses/{addressId}",
|
||||||
|
authAndSub(http.HandlerFunc(addressHandler.UpdateLabel)))
|
||||||
|
|
||||||
|
// Authenticated + subscribed routes — alert rules
|
||||||
|
mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
|
||||||
|
authAndSub(http.HandlerFunc(alertRuleHandler.Create)))
|
||||||
|
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
||||||
|
authAndSub(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
||||||
|
mux.Handle("PATCH "+b+"/alerts/{alertId}",
|
||||||
|
authAndSub(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
||||||
|
mux.Handle("DELETE "+b+"/alerts/{alertId}",
|
||||||
|
authAndSub(http.HandlerFunc(alertRuleHandler.Remove)))
|
||||||
|
|
||||||
|
// Authenticated + subscribed routes — alert events
|
||||||
|
mux.Handle("GET "+b+"/alert-events",
|
||||||
|
authAndSub(http.HandlerFunc(alertEventHandler.List)))
|
||||||
|
|
||||||
|
// Authenticated + subscribed routes — notification config
|
||||||
|
mux.Handle("GET "+b+"/notification-config",
|
||||||
|
authAndSub(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
||||||
|
mux.Handle("PUT "+b+"/notification-config",
|
||||||
|
authAndSub(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||||
|
mux.Handle("DELETE "+b+"/notification-config",
|
||||||
|
authAndSub(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
||||||
|
mux.Handle("POST "+b+"/notification-config/test",
|
||||||
|
authAndSub(http.HandlerFunc(notifConfigHandler.TestChannels)))
|
||||||
|
|
||||||
|
// Authenticated + subscribed routes — email digest
|
||||||
|
mux.Handle("POST "+b+"/email/setup",
|
||||||
|
authAndSub(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
||||||
|
mux.Handle("POST "+b+"/email/digest",
|
||||||
|
authAndSub(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
||||||
|
|
||||||
|
handler := corsMiddleware(mux)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||||
|
log.Printf("Server running on port %d", cfg.Port)
|
||||||
|
log.Printf("API base path: %s", cfg.APIBasePath)
|
||||||
|
log.Printf("Environment: %s", cfg.NodeEnv)
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: handler,
|
||||||
|
ReadTimeout: serverReadTimeoutSeconds * time.Second,
|
||||||
|
WriteTimeout: serverWriteTimeoutSeconds * time.Second,
|
||||||
|
}
|
||||||
|
if err := server.ListenAndServe(); err != nil {
|
||||||
|
log.Fatalf("Server failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func corsMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -51,6 +51,7 @@ func main() {
|
|||||||
|
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
|
userModel := models.NewUserModel(pool)
|
||||||
addressModel := models.NewAddressModel(pool)
|
addressModel := models.NewAddressModel(pool)
|
||||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||||
alertEventModel := models.NewAlertEventModel(pool)
|
alertEventModel := models.NewAlertEventModel(pool)
|
||||||
@@ -59,9 +60,9 @@ func main() {
|
|||||||
|
|
||||||
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
||||||
evaluator := services.NewEvaluatorService(
|
evaluator := services.NewEvaluatorService(
|
||||||
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
|
eth, alertRuleModel, alertEventModel, addressModel, userModel, notifConfigModel,
|
||||||
cfg.ResendAPIKey, cfg.EmailFrom,
|
|
||||||
)
|
)
|
||||||
|
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -78,12 +79,14 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond
|
interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond
|
||||||
|
digestInterval := time.Duration(cfg.DigestIntervalHours) * time.Hour
|
||||||
|
|
||||||
log.Println(strings.Repeat("=", separatorWidth))
|
log.Println(strings.Repeat("=", separatorWidth))
|
||||||
log.Println("Koin Ping Observer Poller Starting")
|
log.Println("Koin Ping Observer Poller Starting")
|
||||||
log.Println(strings.Repeat("=", separatorWidth))
|
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/msPerSecond)
|
log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/msPerSecond)
|
||||||
|
log.Printf("Digest Interval: %dh", cfg.DigestIntervalHours)
|
||||||
log.Println(strings.Repeat("=", separatorWidth))
|
log.Println(strings.Repeat("=", separatorWidth))
|
||||||
|
|
||||||
runCycle(ctx, observer, evaluator)
|
runCycle(ctx, observer, evaluator)
|
||||||
@@ -91,6 +94,9 @@ func main() {
|
|||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
digestTicker := time.NewTicker(digestInterval)
|
||||||
|
defer digestTicker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -99,6 +105,13 @@ func main() {
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
runCycle(ctx, observer, evaluator)
|
runCycle(ctx, observer, evaluator)
|
||||||
|
case <-digestTicker.C:
|
||||||
|
sent, digestErr := digestSvc.SendDigestsForAllUsers(ctx)
|
||||||
|
if digestErr != nil {
|
||||||
|
log.Printf("Email digest failed: %v", digestErr)
|
||||||
|
} else {
|
||||||
|
log.Printf("Sent %d email digests", sent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +138,8 @@ func runCycle(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
evaluator.WaitForNotifications()
|
||||||
|
|
||||||
duration := time.Since(startTime)
|
duration := time.Since(startTime)
|
||||||
log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s",
|
log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s",
|
||||||
time.Now().UTC().Format(time.RFC3339),
|
time.Now().UTC().Format(time.RFC3339),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module github.com/kjannette/koin-ping/backend-go
|
module github.com/kjannette/koin-ping/backend
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ require (
|
|||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
github.com/resend/resend-go/v3 v3.1.1 // indirect
|
github.com/resend/resend-go/v3 v3.1.1 // indirect
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||||
|
github.com/stripe/stripe-go/v82 v82.5.1 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||||
@@ -101,6 +101,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
|
||||||
|
github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
3
backend/infra/migrations/004_alert_event_dedup.sql
Normal file
3
backend/infra/migrations/004_alert_event_dedup.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_events_dedup
|
||||||
|
ON alert_events (alert_rule_id, tx_hash)
|
||||||
|
WHERE tx_hash IS NOT NULL;
|
||||||
13
backend/infra/migrations/005_create_users_table.sql
Normal file
13
backend/infra/migrations/005_create_users_table.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration 005: Create users table with UUID primary key
|
||||||
|
-- This establishes a local user record for each Firebase-authenticated user.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_firebase_uid ON users(firebase_uid);
|
||||||
73
backend/infra/migrations/006_migrate_user_ids_to_uuid.sql
Normal file
73
backend/infra/migrations/006_migrate_user_ids_to_uuid.sql
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
-- Migration 006: Migrate user_id columns from Firebase UID strings to user UUIDs
|
||||||
|
--
|
||||||
|
-- Prerequisites: migration 005 (users table) must be applied first.
|
||||||
|
-- This migration backfills the users table from existing data, then swaps the
|
||||||
|
-- VARCHAR user_id columns for UUID foreign keys referencing users(id).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. Backfill users table from existing Firebase UIDs in addresses
|
||||||
|
INSERT INTO users (firebase_uid, email)
|
||||||
|
SELECT DISTINCT user_id, ''
|
||||||
|
FROM addresses
|
||||||
|
WHERE user_id IS NOT NULL
|
||||||
|
ON CONFLICT (firebase_uid) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. Backfill from notification configs (catches users with configs but no addresses)
|
||||||
|
INSERT INTO users (firebase_uid, email)
|
||||||
|
SELECT DISTINCT user_id, ''
|
||||||
|
FROM user_notification_configs
|
||||||
|
WHERE user_id IS NOT NULL
|
||||||
|
ON CONFLICT (firebase_uid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. Migrate addresses.user_id from VARCHAR to UUID
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE addresses ADD COLUMN user_uuid UUID;
|
||||||
|
|
||||||
|
UPDATE addresses a
|
||||||
|
SET user_uuid = u.id
|
||||||
|
FROM users u
|
||||||
|
WHERE u.firebase_uid = a.user_id;
|
||||||
|
|
||||||
|
-- Drop old constraints and column
|
||||||
|
ALTER TABLE addresses DROP CONSTRAINT IF EXISTS addresses_user_id_address_key;
|
||||||
|
DROP INDEX IF EXISTS idx_addresses_user_id;
|
||||||
|
ALTER TABLE addresses DROP COLUMN user_id;
|
||||||
|
|
||||||
|
-- Rename and constrain
|
||||||
|
ALTER TABLE addresses RENAME COLUMN user_uuid TO user_id;
|
||||||
|
ALTER TABLE addresses ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
ALTER TABLE addresses ADD CONSTRAINT fk_addresses_user
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE addresses ADD CONSTRAINT addresses_user_id_address_key
|
||||||
|
UNIQUE (user_id, address);
|
||||||
|
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. Migrate user_notification_configs.user_id from VARCHAR to UUID
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Drop the PK first (it's on user_id)
|
||||||
|
ALTER TABLE user_notification_configs DROP CONSTRAINT IF EXISTS user_notification_configs_pkey;
|
||||||
|
DROP INDEX IF EXISTS idx_notification_configs_enabled;
|
||||||
|
|
||||||
|
ALTER TABLE user_notification_configs ADD COLUMN user_uuid UUID;
|
||||||
|
|
||||||
|
UPDATE user_notification_configs nc
|
||||||
|
SET user_uuid = u.id
|
||||||
|
FROM users u
|
||||||
|
WHERE u.firebase_uid = nc.user_id;
|
||||||
|
|
||||||
|
ALTER TABLE user_notification_configs DROP COLUMN user_id;
|
||||||
|
ALTER TABLE user_notification_configs RENAME COLUMN user_uuid TO user_id;
|
||||||
|
ALTER TABLE user_notification_configs ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
ALTER TABLE user_notification_configs ADD CONSTRAINT user_notification_configs_pkey
|
||||||
|
PRIMARY KEY (user_id);
|
||||||
|
ALTER TABLE user_notification_configs ADD CONSTRAINT fk_notification_configs_user
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
CREATE INDEX idx_notification_configs_enabled
|
||||||
|
ON user_notification_configs(notification_enabled);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
8
backend/infra/migrations/007_add_stripe_to_users.sql
Normal file
8
backend/infra/migrations/007_add_stripe_to_users.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Migration 007: Add Stripe subscription fields to users table
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(50) DEFAULT 'none';
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_created_at TIMESTAMP;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer_id ON users(stripe_customer_id);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE alert_rules ADD COLUMN minimum DECIMAL(20, 6);
|
||||||
|
ALTER TABLE alert_rules ADD COLUMN maximum DECIMAL(20, 6);
|
||||||
|
|
||||||
|
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0);
|
||||||
|
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0);
|
||||||
|
ALTER TABLE alert_rules ADD CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum);
|
||||||
8
backend/infra/migrations/009_add_subscription_tier.sql
Normal file
8
backend/infra/migrations/009_add_subscription_tier.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_tier VARCHAR(20) DEFAULT 'free';
|
||||||
|
|
||||||
|
-- Existing active/trialing subscribers were on the single paid plan,
|
||||||
|
-- which is now the "premium" tier. Backfill them so they aren't downgraded.
|
||||||
|
UPDATE users
|
||||||
|
SET subscription_tier = 'premium'
|
||||||
|
WHERE subscription_status IN ('active', 'trialing')
|
||||||
|
AND stripe_subscription_id IS NOT NULL;
|
||||||
@@ -4,15 +4,29 @@ DROP TABLE IF EXISTS alert_rules CASCADE;
|
|||||||
DROP TABLE IF EXISTS address_checkpoints CASCADE;
|
DROP TABLE IF EXISTS address_checkpoints CASCADE;
|
||||||
DROP TABLE IF EXISTS user_notification_configs CASCADE;
|
DROP TABLE IF EXISTS user_notification_configs CASCADE;
|
||||||
DROP TABLE IF EXISTS addresses CASCADE;
|
DROP TABLE IF EXISTS addresses CASCADE;
|
||||||
|
DROP TABLE IF EXISTS users CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
stripe_customer_id VARCHAR(255),
|
||||||
|
stripe_subscription_id VARCHAR(255),
|
||||||
|
subscription_status VARCHAR(50) DEFAULT 'none',
|
||||||
|
subscription_tier VARCHAR(20) DEFAULT 'free',
|
||||||
|
subscription_created_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE addresses (
|
CREATE TABLE addresses (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id VARCHAR(128) NOT NULL, -- Firebase user ID (multi-user support)
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars)
|
address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars)
|
||||||
label VARCHAR(255), -- Optional human-readable label
|
label VARCHAR(255), -- Optional human-readable label
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
-- Unique users can track the same address independently
|
|
||||||
UNIQUE(user_id, address)
|
UNIQUE(user_id, address)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -22,6 +36,8 @@ CREATE TABLE alert_rules (
|
|||||||
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
|
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
|
||||||
type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'
|
type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'
|
||||||
threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it)
|
threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it)
|
||||||
|
minimum DECIMAL(20, 6), -- Optional min amount filter for incoming/outgoing alerts
|
||||||
|
maximum DECIMAL(20, 6), -- Optional max amount filter for incoming/outgoing alerts
|
||||||
enabled BOOLEAN DEFAULT TRUE,
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
@@ -31,7 +47,11 @@ CREATE TABLE alert_rules (
|
|||||||
|
|
||||||
CONSTRAINT positive_threshold CHECK (
|
CONSTRAINT positive_threshold CHECK (
|
||||||
threshold IS NULL OR threshold > 0
|
threshold IS NULL OR threshold > 0
|
||||||
)
|
),
|
||||||
|
|
||||||
|
CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0),
|
||||||
|
CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0),
|
||||||
|
CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +71,7 @@ CREATE TABLE address_checkpoints (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE user_notification_configs (
|
CREATE TABLE user_notification_configs (
|
||||||
user_id VARCHAR(128) PRIMARY KEY,
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
||||||
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
|
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
|
||||||
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
|
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
|
||||||
@@ -63,6 +83,8 @@ CREATE TABLE user_notification_configs (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Create indexes for common queries
|
-- Create indexes for common queries
|
||||||
|
CREATE INDEX idx_users_firebase_uid ON users(firebase_uid);
|
||||||
|
CREATE INDEX idx_users_stripe_customer_id ON users(stripe_customer_id);
|
||||||
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
||||||
CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id);
|
CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id);
|
||||||
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);
|
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);
|
||||||
@@ -13,6 +13,7 @@ const (
|
|||||||
defaultDBPort = 5432
|
defaultDBPort = 5432
|
||||||
defaultPollIntervalMS = 60000
|
defaultPollIntervalMS = 60000
|
||||||
minPollIntervalMS = 1000
|
minPollIntervalMS = 1000
|
||||||
|
defaultDigestIntervalHours = 24
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -28,8 +29,15 @@ type Config struct {
|
|||||||
EthRPCURL string
|
EthRPCURL string
|
||||||
PollIntervalMS int
|
PollIntervalMS int
|
||||||
NodeEnv string
|
NodeEnv string
|
||||||
ResendAPIKey string
|
ResendAPIKey string
|
||||||
EmailFrom string
|
EmailFrom string
|
||||||
|
DigestIntervalHours int
|
||||||
|
StripeSecretKey string
|
||||||
|
StripeWebhookSecret string
|
||||||
|
StripePriceIDPremium string
|
||||||
|
StripePriceIDPro string
|
||||||
|
StripePublishableKey string
|
||||||
|
FrontendURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and returns a Config.
|
// Load reads configuration from environment variables and returns a Config.
|
||||||
@@ -47,8 +55,15 @@ func Load() (*Config, error) {
|
|||||||
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
||||||
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
||||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||||
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||||
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||||
|
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
||||||
|
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||||
|
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||||
|
StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
|
||||||
|
StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
|
||||||
|
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
||||||
|
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.PollIntervalMS < minPollIntervalMS {
|
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||||
@@ -79,6 +94,19 @@ func (c *Config) DSN() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TierForPriceID maps a Stripe price ID back to the corresponding
|
||||||
|
// subscription tier. Returns empty string if the price is unrecognised.
|
||||||
|
func (c *Config) TierForPriceID(priceID string) string {
|
||||||
|
switch priceID {
|
||||||
|
case c.StripePriceIDPremium:
|
||||||
|
return "premium"
|
||||||
|
case c.StripePriceIDPro:
|
||||||
|
return "pro"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
func getEnv(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
28
backend/internal/config/config_test.go
Normal file
28
backend/internal/config/config_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTierForPriceID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
StripePriceIDPremium: "price_premium_123",
|
||||||
|
StripePriceIDPro: "price_pro_456",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
priceID string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"price_premium_123", "premium"},
|
||||||
|
{"price_pro_456", "pro"},
|
||||||
|
{"price_unknown", ""},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := cfg.TierForPriceID(tt.priceID); got != tt.want {
|
||||||
|
t.Errorf("TierForPriceID(%q) = %q, want %q", tt.priceID, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package database manages PostgreSQL connection pools.
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,21 +10,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// maxConnIdleSeconds is the maximum idle time for a connection.
|
|
||||||
maxConnIdleSeconds = 30
|
maxConnIdleSeconds = 30
|
||||||
// maxConnLifetimeMinutes is the maximum lifetime for a connection.
|
|
||||||
maxConnLifetimeMinutes = 5
|
maxConnLifetimeMinutes = 5
|
||||||
// connectTimeoutSeconds is the timeout for initial connection.
|
|
||||||
connectTimeoutSeconds = 10
|
connectTimeoutSeconds = 10
|
||||||
// maxConns is the maximum number of connections in the pool.
|
|
||||||
maxConns = 20
|
maxConns = 20
|
||||||
// minConns is the minimum number of connections in the pool.
|
|
||||||
minConns = 2
|
minConns = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
var pool *pgxpool.Pool //nolint:gochecknoglobals
|
var pool *pgxpool.Pool //nolint:gochecknoglobals
|
||||||
|
|
||||||
// Connect establishes a PostgreSQL connection pool using the given DSN.
|
// establishPostgreSQL connection pool using the given DSN.
|
||||||
func Connect(dsn string) (*pgxpool.Pool, error) {
|
func Connect(dsn string) (*pgxpool.Pool, error) {
|
||||||
cfg, err := pgxpool.ParseConfig(dsn)
|
cfg, err := pgxpool.ParseConfig(dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,12 +56,10 @@ func Connect(dsn string) (*pgxpool.Pool, error) {
|
|||||||
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,9 +1,80 @@
|
|||||||
// 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 SubscriptionTier string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TierFree SubscriptionTier = "free"
|
||||||
|
TierPremium SubscriptionTier = "premium"
|
||||||
|
TierPro SubscriptionTier = "pro"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsValidTier(t string) bool {
|
||||||
|
switch SubscriptionTier(t) {
|
||||||
|
case TierFree, TierPremium, TierPro:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlimitedLimit = -1
|
||||||
|
|
||||||
|
type TierLimits struct {
|
||||||
|
MaxAddresses int `json:"max_addresses"`
|
||||||
|
MaxAlertTypes int `json:"max_alert_types"`
|
||||||
|
AllowedChannels []string `json:"allowed_channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTierLimits(tier SubscriptionTier) TierLimits {
|
||||||
|
switch tier {
|
||||||
|
case TierPremium:
|
||||||
|
return TierLimits{
|
||||||
|
MaxAddresses: 3,
|
||||||
|
MaxAlertTypes: 2,
|
||||||
|
AllowedChannels: []string{"email", "discord", "telegram"},
|
||||||
|
}
|
||||||
|
case TierPro:
|
||||||
|
return TierLimits{
|
||||||
|
MaxAddresses: unlimitedLimit,
|
||||||
|
MaxAlertTypes: unlimitedLimit,
|
||||||
|
AllowedChannels: []string{"email", "discord", "telegram", "slack"},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return TierLimits{
|
||||||
|
MaxAddresses: 1,
|
||||||
|
MaxAlertTypes: 1,
|
||||||
|
AllowedChannels: []string{"email"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l TierLimits) IsUnlimitedAddresses() bool { return l.MaxAddresses == unlimitedLimit }
|
||||||
|
func (l TierLimits) IsUnlimitedAlertTypes() bool { return l.MaxAlertTypes == unlimitedLimit }
|
||||||
|
|
||||||
|
func (l TierLimits) ChannelAllowed(channel string) bool {
|
||||||
|
for _, c := range l.AllowedChannels {
|
||||||
|
if c == channel {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FirebaseUID string `json:"-"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName *string `json:"display_name"` //nolint:tagliatelle
|
||||||
|
StripeCustomerID *string `json:"-"`
|
||||||
|
StripeSubscriptionID *string `json:"-"`
|
||||||
|
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
|
||||||
|
SubscriptionTier SubscriptionTier `json:"subscription_tier"` //nolint:tagliatelle
|
||||||
|
SubscriptionCreatedAt *time.Time `json:"subscription_created_at,omitempty"` //nolint:tagliatelle
|
||||||
|
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||||
|
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
|
||||||
|
}
|
||||||
|
|
||||||
type Address struct {
|
type Address struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||||
@@ -12,13 +83,10 @@ type Address struct {
|
|||||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
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) }
|
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"
|
||||||
@@ -26,7 +94,6 @@ const (
|
|||||||
AlertBalanceBelow AlertType = "balance_below"
|
AlertBalanceBelow AlertType = "balance_below"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidAlertTypes lists all alert types accepted by the API.
|
|
||||||
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
|
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
|
||||||
AlertIncomingTx,
|
AlertIncomingTx,
|
||||||
AlertOutgoingTx,
|
AlertOutgoingTx,
|
||||||
@@ -40,7 +107,7 @@ var ThresholdRequiredTypes = []AlertType{ //nolint:gochecknoglobals
|
|||||||
AlertBalanceBelow,
|
AlertBalanceBelow,
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValidAlertType returns true if the given string matches a known AlertType.
|
// 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 {
|
||||||
@@ -51,7 +118,6 @@ func IsValidAlertType(t string) bool {
|
|||||||
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 {
|
||||||
@@ -62,17 +128,17 @@ func IsThresholdRequired(t AlertType) bool {
|
|||||||
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"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
Type AlertType `json:"type"`
|
Type AlertType `json:"type"`
|
||||||
Threshold *float64 `json:"threshold"`
|
Threshold *float64 `json:"threshold"`
|
||||||
|
Minimum *float64 `json:"minimum"`
|
||||||
|
Maximum *float64 `json:"maximum"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
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"` //nolint:tagliatelle
|
AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
|
||||||
@@ -82,14 +148,12 @@ type AlertEvent struct {
|
|||||||
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"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
|
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
|
||||||
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
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"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
@@ -98,7 +162,7 @@ type CheckpointDetail struct {
|
|||||||
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationConfig holds a user's notification preferences.
|
// holds a user's notification preferences.
|
||||||
type NotificationConfig struct {
|
type NotificationConfig struct {
|
||||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||||
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
||||||
@@ -119,21 +183,27 @@ type NormalizedTx struct {
|
|||||||
Value string `json:"value"` // Wei as string for precision
|
Value string `json:"value"` // Wei as string for precision
|
||||||
BlockNumber int `json:"block_number"` //nolint:tagliatelle
|
BlockNumber int `json:"block_number"` //nolint:tagliatelle
|
||||||
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
|
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
|
||||||
|
|
||||||
|
// ERC-20 token transfer fields (nil for native ETH transfers)
|
||||||
|
TokenContract *string `json:"token_contract,omitempty"` //nolint:tagliatelle
|
||||||
|
TokenSymbol *string `json:"token_symbol,omitempty"` //nolint:tagliatelle
|
||||||
|
TokenDecimals *int `json:"token_decimals,omitempty"` //nolint:tagliatelle
|
||||||
|
TokenValue *string `json:"token_value,omitempty"` //nolint:tagliatelle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tx NormalizedTx) IsTokenTransfer() bool {
|
||||||
|
return tx.TokenContract != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) }
|
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"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
104
backend/internal/domain/types_test.go
Normal file
104
backend/internal/domain/types_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsValidTier(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
valid := []string{"free", "premium", "pro"}
|
||||||
|
for _, tier := range valid {
|
||||||
|
if !IsValidTier(tier) {
|
||||||
|
t.Errorf("expected %q to be valid", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{"", "basic", "enterprise", "FREE", "Pro"}
|
||||||
|
for _, tier := range invalid {
|
||||||
|
if IsValidTier(tier) {
|
||||||
|
t.Errorf("expected %q to be invalid", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Free(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierFree)
|
||||||
|
if limits.MaxAddresses != 1 {
|
||||||
|
t.Errorf("free MaxAddresses = %d, want 1", limits.MaxAddresses)
|
||||||
|
}
|
||||||
|
if limits.MaxAlertTypes != 1 {
|
||||||
|
t.Errorf("free MaxAlertTypes = %d, want 1", limits.MaxAlertTypes)
|
||||||
|
}
|
||||||
|
if len(limits.AllowedChannels) != 1 || limits.AllowedChannels[0] != "email" {
|
||||||
|
t.Errorf("free AllowedChannels = %v, want [email]", limits.AllowedChannels)
|
||||||
|
}
|
||||||
|
if limits.IsUnlimitedAddresses() {
|
||||||
|
t.Error("free should not have unlimited addresses")
|
||||||
|
}
|
||||||
|
if limits.IsUnlimitedAlertTypes() {
|
||||||
|
t.Error("free should not have unlimited alert types")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Premium(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierPremium)
|
||||||
|
if limits.MaxAddresses != 3 {
|
||||||
|
t.Errorf("premium MaxAddresses = %d, want 3", limits.MaxAddresses)
|
||||||
|
}
|
||||||
|
if limits.MaxAlertTypes != 2 {
|
||||||
|
t.Errorf("premium MaxAlertTypes = %d, want 2", limits.MaxAlertTypes)
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("email") {
|
||||||
|
t.Error("premium should allow email")
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("discord") {
|
||||||
|
t.Error("premium should allow discord")
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("telegram") {
|
||||||
|
t.Error("premium should allow telegram")
|
||||||
|
}
|
||||||
|
if limits.ChannelAllowed("slack") {
|
||||||
|
t.Error("premium should NOT allow slack")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Pro(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierPro)
|
||||||
|
if !limits.IsUnlimitedAddresses() {
|
||||||
|
t.Error("pro should have unlimited addresses")
|
||||||
|
}
|
||||||
|
if !limits.IsUnlimitedAlertTypes() {
|
||||||
|
t.Error("pro should have unlimited alert types")
|
||||||
|
}
|
||||||
|
for _, ch := range []string{"email", "discord", "telegram", "slack"} {
|
||||||
|
if !limits.ChannelAllowed(ch) {
|
||||||
|
t.Errorf("pro should allow %s", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Unknown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(SubscriptionTier("unknown"))
|
||||||
|
if limits.MaxAddresses != 1 {
|
||||||
|
t.Errorf("unknown tier should default to free limits, got MaxAddresses=%d", limits.MaxAddresses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelAllowed_NotInList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierFree)
|
||||||
|
if limits.ChannelAllowed("discord") {
|
||||||
|
t.Error("free should not allow discord")
|
||||||
|
}
|
||||||
|
if limits.ChannelAllowed("nonexistent") {
|
||||||
|
t.Error("nonexistent channel should not be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package firebase provides Firebase authentication integration.
|
|
||||||
package firebase
|
package firebase
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,7 +16,6 @@ var ( //nolint:gochecknoglobals
|
|||||||
errInit error //nolint:gochecknoglobals
|
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()
|
||||||
@@ -48,7 +46,6 @@ func Init(projectID string) error {
|
|||||||
return errInit
|
return errInit
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth returns the initialized Firebase auth client.
|
|
||||||
func Auth() *auth.Client {
|
func Auth() *auth.Client {
|
||||||
return authClient
|
return authClient
|
||||||
}
|
}
|
||||||
88
backend/internal/handlers/account.go
Normal file
88
backend/internal/handlers/account.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountHandler struct {
|
||||||
|
users *models.UserModel
|
||||||
|
addresses *models.AddressModel
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAccountHandler(users *models.UserModel, addresses *models.AddressModel, cfg *config.Config) *AccountHandler {
|
||||||
|
return &AccountHandler{users: users, addresses: addresses, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountResponse struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
SubscriptionStatus string `json:"subscription_status"`
|
||||||
|
SubscriptionTier string `json:"subscription_tier"`
|
||||||
|
SubscriptionPlan string `json:"subscription_plan"`
|
||||||
|
TierLimits domain.TierLimits `json:"tier_limits"`
|
||||||
|
AddressCount int `json:"address_count"`
|
||||||
|
MemberSince *string `json:"member_since,omitempty"`
|
||||||
|
NextBillingDate *string `json:"next_billing_date,omitempty"`
|
||||||
|
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||||
|
PeriodEndDate *string `json:"period_end_date,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var tierPlanLabels = map[domain.SubscriptionTier]string{ //nolint:gochecknoglobals
|
||||||
|
domain.TierFree: "Free Trial",
|
||||||
|
domain.TierPremium: "Premium / $1.99 mo",
|
||||||
|
domain.TierPro: "Pro / $11.99 mo",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
email := middleware.GetUserEmail(r.Context())
|
||||||
|
|
||||||
|
user, err := h.users.GetByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Account: failed to get user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addrCount, err := h.addresses.CountByUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Account: failed to count addresses for %s: %v", userID, err)
|
||||||
|
addrCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
planLabel := tierPlanLabels[user.SubscriptionTier]
|
||||||
|
if planLabel == "" {
|
||||||
|
planLabel = "Free Trial"
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := accountResponse{
|
||||||
|
UserID: user.ID,
|
||||||
|
Email: email,
|
||||||
|
UserName: email,
|
||||||
|
SubscriptionStatus: user.SubscriptionStatus,
|
||||||
|
SubscriptionTier: string(user.SubscriptionTier),
|
||||||
|
SubscriptionPlan: planLabel,
|
||||||
|
TierLimits: domain.GetTierLimits(user.SubscriptionTier),
|
||||||
|
AddressCount: addrCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.SubscriptionCreatedAt != nil {
|
||||||
|
t := user.SubscriptionCreatedAt.Format(time.DateOnly)
|
||||||
|
resp.MemberSince = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.NextBillingDate = nil
|
||||||
|
resp.CancelAtPeriodEnd = false
|
||||||
|
resp.PeriodEndDate = nil
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -3,29 +3,28 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
users *models.UserModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAddressHandler creates a new AddressHandler.
|
func NewAddressHandler(addresses *models.AddressModel, users *models.UserModel) *AddressHandler {
|
||||||
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
|
return &AddressHandler{addresses: addresses, users: users}
|
||||||
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())
|
||||||
|
|
||||||
@@ -52,6 +51,28 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, err := h.users.GetByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Failed to get user %s for tier check: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||||
|
if !limits.IsUnlimitedAddresses() {
|
||||||
|
count, err := h.addresses.CountByUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to count addresses for user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count >= limits.MaxAddresses {
|
||||||
|
writeError(w, http.StatusForbidden, "TIER_LIMIT_REACHED",
|
||||||
|
fmt.Sprintf("Your %s plan allows %d address(es). Upgrade to track more.", user.SubscriptionTier, limits.MaxAddresses))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("User %s creating address: %s", userID, body.Address)
|
log.Printf("User %s creating address: %s", userID, body.Address)
|
||||||
|
|
||||||
addr, err := h.addresses.Create(r.Context(), userID, body.Address, body.Label)
|
addr, err := h.addresses.Create(r.Context(), userID, body.Address, body.Label)
|
||||||
@@ -92,6 +113,44 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, addresses)
|
writeJSON(w, http.StatusOK, addresses)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handles PATCH requests to update an address label.
|
||||||
|
func (h *AddressHandler) UpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Label *string `json:"label"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("User %s updating label for address ID: %d", userID, addressID)
|
||||||
|
|
||||||
|
addr, err := h.addresses.UpdateLabel(r.Context(), addressID, userID, body.Label)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating address label: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update address")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if addr == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, addr)
|
||||||
|
}
|
||||||
|
|
||||||
// Remove handles DELETE requests to remove a tracked address.
|
// 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())
|
||||||
@@ -4,11 +4,10 @@ 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/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertEventHandler handles HTTP requests for alert event history.
|
// AlertEventHandler handles HTTP requests for alert event history.
|
||||||
@@ -51,45 +50,9 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
log.Printf("Found %d alert events for user", len(events))
|
log.Printf("Found %d alert events for user", len(events))
|
||||||
|
|
||||||
// MVP scaffolding: return mock data if DB is empty
|
if events == nil {
|
||||||
if len(events) == 0 {
|
events = []domain.AlertEvent{}
|
||||||
events = mockEvents(limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, events)
|
writeJSON(w, http.StatusOK, events)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mockEvents(limit int) []domain.AlertEvent {
|
|
||||||
label1 := "Treasury Wallet"
|
|
||||||
label2 := "Cold Storage"
|
|
||||||
|
|
||||||
mocks := []domain.AlertEvent{
|
|
||||||
{
|
|
||||||
ID: 1,
|
|
||||||
AlertRuleID: 1,
|
|
||||||
Message: "Incoming transaction detected: 5.5 ETH received",
|
|
||||||
AddressLabel: &label1,
|
|
||||||
Timestamp: time.Now().Add(-2 * time.Hour),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 2, //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
|
|
||||||
}
|
|
||||||
@@ -9,26 +9,23 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errThresholdFormat is returned when the threshold JSON cannot be decoded.
|
|
||||||
var errThresholdFormat = errors.New("unsupported threshold format")
|
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
|
||||||
|
users *models.UserModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAlertRuleHandler creates a new AlertRuleHandler.
|
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel, users *models.UserModel) *AlertRuleHandler {
|
||||||
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
|
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses, users: users}
|
||||||
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"))
|
||||||
@@ -41,6 +38,8 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Threshold json.RawMessage `json:"threshold"`
|
Threshold json.RawMessage `json:"threshold"`
|
||||||
|
Minimum json.RawMessage `json:"minimum"`
|
||||||
|
Maximum json.RawMessage `json:"maximum"`
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -57,6 +56,40 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
minimum, err := parseThreshold(body.Minimum)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to parse minimum: %v", err)
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maximum, err := parseThreshold(body.Maximum)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to parse maximum: %v", err)
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if minimum != nil && *minimum < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if maximum != nil && *maximum < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if minimum != nil && maximum != nil && *minimum > *maximum {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID)
|
log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID)
|
||||||
|
|
||||||
if body.Type == "" {
|
if body.Type == "" {
|
||||||
@@ -100,7 +133,29 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold)
|
user, userErr := h.users.GetByID(r.Context(), userID)
|
||||||
|
if userErr != nil || user == nil {
|
||||||
|
log.Printf("Failed to get user %s for tier check: %v", userID, userErr)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||||
|
if !limits.IsUnlimitedAlertTypes() {
|
||||||
|
typeCount, countErr := h.alertRules.CountDistinctTypesByAddress(r.Context(), addressID)
|
||||||
|
if countErr != nil {
|
||||||
|
log.Printf("Failed to count alert types for address %d: %v", addressID, countErr)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if typeCount >= limits.MaxAlertTypes {
|
||||||
|
writeError(w, http.StatusForbidden, "TIER_LIMIT_REACHED",
|
||||||
|
fmt.Sprintf("Your %s plan allows %d alert type(s) per address. Upgrade for more.", user.SubscriptionTier, limits.MaxAlertTypes))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold, minimum, maximum)
|
||||||
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")
|
||||||
@@ -186,7 +241,7 @@ 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.
|
// UpdateStatus handles PATCH requests to enable/disable an alert rule and/or update min/max thresholds.
|
||||||
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"))
|
||||||
@@ -197,7 +252,10 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
Minimum json.RawMessage `json:"minimum"`
|
||||||
|
Maximum json.RawMessage `json:"maximum"`
|
||||||
|
UpdateMinMax bool `json:"update_min_max"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -208,8 +266,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
log.Printf("User %s updating alert ID: %d", userID, alertID)
|
log.Printf("User %s updating alert ID: %d", userID, alertID)
|
||||||
|
|
||||||
if body.Enabled == nil {
|
if body.Enabled == nil && !body.UpdateMinMax {
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value")
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "must provide enabled (boolean) or update_min_max with minimum/maximum values")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -228,15 +286,64 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, err := h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
|
var updated *domain.AlertRule
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error updating alert: %v", err)
|
|
||||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
|
||||||
|
|
||||||
return
|
if body.UpdateMinMax {
|
||||||
|
minimum, parseErr := parseThreshold(body.Minimum)
|
||||||
|
if parseErr != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maximum, parseErr := parseThreshold(body.Maximum)
|
||||||
|
if parseErr != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if minimum != nil && *minimum < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if maximum != nil && *maximum < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if minimum != nil && maximum != nil && *minimum > *maximum {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err = h.alertRules.UpdateThresholds(r.Context(), alertID, minimum, maximum)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating alert thresholds: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Alert %d thresholds updated: min=%v, max=%v", alertID, minimum, maximum)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Enabled != nil {
|
||||||
|
updated, err = h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating alert: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
|
|
||||||
writeJSON(w, http.StatusOK, updated)
|
writeJSON(w, http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EmailDigestHandler struct {
|
type EmailDigestHandler struct {
|
||||||
@@ -7,22 +7,23 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||||
)
|
)
|
||||||
|
|
||||||
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||||
|
|
||||||
type NotificationConfigHandler struct {
|
type NotificationConfigHandler struct {
|
||||||
configs *models.NotificationConfigModel
|
configs *models.NotificationConfigModel
|
||||||
|
users *models.UserModel
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, cfg *config.Config) *NotificationConfigHandler {
|
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, users *models.UserModel, cfg *config.Config) *NotificationConfigHandler {
|
||||||
return &NotificationConfigHandler{configs: configs, cfg: cfg}
|
return &NotificationConfigHandler{configs: configs, users: users, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -95,6 +96,26 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, userErr := h.users.GetByID(r.Context(), userID)
|
||||||
|
if userErr != nil || user == nil {
|
||||||
|
log.Printf("Failed to get user %s for tier check: %v", userID, userErr)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||||
|
|
||||||
|
if !limits.ChannelAllowed("discord") {
|
||||||
|
body.DiscordWebhookURL = nil
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("telegram") {
|
||||||
|
body.TelegramBotToken = nil
|
||||||
|
body.TelegramChatID = nil
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("slack") {
|
||||||
|
body.SlackWebhookURL = nil
|
||||||
|
}
|
||||||
|
|
||||||
enabled := true
|
enabled := true
|
||||||
if body.NotificationEnabled != nil {
|
if body.NotificationEnabled != nil {
|
||||||
enabled = *body.NotificationEnabled
|
enabled = *body.NotificationEnabled
|
||||||
71
backend/internal/handlers/status.go
Normal file
71
backend/internal/handlers/status.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusHandler handles the system status endpoint.
|
||||||
|
type StatusHandler struct {
|
||||||
|
checkpoints *models.CheckpointModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStatusHandler creates a new StatusHandler.
|
||||||
|
func NewStatusHandler(checkpoints *models.CheckpointModel) *StatusHandler {
|
||||||
|
return &StatusHandler{checkpoints: checkpoints}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns real-time system status derived from checkpoint data.
|
||||||
|
func (h *StatusHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
block, checkedAt, err := h.checkpoints.GetLatestBlock(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error querying latest block: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get system status")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
latestBlock := 0
|
||||||
|
lag := 0
|
||||||
|
status := "starting"
|
||||||
|
|
||||||
|
if checkedAt != nil {
|
||||||
|
lag = int(time.Since(*checkedAt).Seconds())
|
||||||
|
if lag > 600 { //nolint:mnd
|
||||||
|
status = "idle"
|
||||||
|
} else {
|
||||||
|
status = "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if block != nil {
|
||||||
|
latestBlock = *block
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"latestBlock": latestBlock,
|
||||||
|
"lag": lag,
|
||||||
|
"lastCheckedAt": checkedAtStr(checkedAt),
|
||||||
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkedAtStr(t *time.Time) string {
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"service": "koin-ping-backend",
|
||||||
|
})
|
||||||
|
}
|
||||||
429
backend/internal/handlers/stripe.go
Normal file
429
backend/internal/handlers/stripe.go
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/stripe/stripe-go/v82"
|
||||||
|
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
|
||||||
|
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
|
||||||
|
"github.com/stripe/stripe-go/v82/webhook"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const webhookMaxBodyBytes = 65536
|
||||||
|
|
||||||
|
type StripeHandler struct {
|
||||||
|
users *models.UserModel
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler {
|
||||||
|
stripe.Key = cfg.StripeSecretKey
|
||||||
|
return &StripeHandler{users: users, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier) (string, error) {
|
||||||
|
switch tier {
|
||||||
|
case domain.TierPremium:
|
||||||
|
return h.cfg.StripePriceIDPremium, nil
|
||||||
|
case domain.TierPro:
|
||||||
|
return h.cfg.StripePriceIDPro, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("no Stripe price for tier %q", tier) //nolint:err113
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCheckoutSession creates a Stripe Checkout session for the selected tier.
|
||||||
|
func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Tier == "" {
|
||||||
|
body.Tier = "premium"
|
||||||
|
}
|
||||||
|
|
||||||
|
tier := domain.SubscriptionTier(body.Tier)
|
||||||
|
if tier != domain.TierPremium && tier != domain.TierPro {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
priceID, err := h.priceIDForTier(tier)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.users.GetByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Failed to get user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.CheckoutSessionParams{
|
||||||
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||||
|
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||||
|
{
|
||||||
|
Price: stripe.String(priceID),
|
||||||
|
Quantity: stripe.Int64(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
|
||||||
|
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
|
||||||
|
ClientReferenceID: stripe.String(userID),
|
||||||
|
CustomerEmail: stripe.String(user.Email),
|
||||||
|
}
|
||||||
|
|
||||||
|
params.AddMetadata("tier", string(tier))
|
||||||
|
|
||||||
|
if user.StripeCustomerID != nil && *user.StripeCustomerID != "" {
|
||||||
|
params.Customer = user.StripeCustomerID
|
||||||
|
params.CustomerEmail = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := checkoutsession.New(params)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create Stripe checkout session: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubscriptionStatus returns the current user's subscription state.
|
||||||
|
func (h *StripeHandler) GetSubscriptionStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
user, err := h.users.GetByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Failed to get user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"subscription_status": user.SubscriptionStatus,
|
||||||
|
"subscription_tier": user.SubscriptionTier,
|
||||||
|
"subscription_created_at": user.SubscriptionCreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyCheckoutSession retrieves a completed checkout session from Stripe,
|
||||||
|
// confirms payment, and activates the user's subscription in the database.
|
||||||
|
func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.SessionID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Missing session_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := checkoutsession.Get(body.SessionID, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to retrieve checkout session %s: %v", body.SessionID, err)
|
||||||
|
writeError(w, http.StatusBadRequest, "STRIPE_ERROR", "Invalid checkout session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.ClientReferenceID != "" && s.ClientReferenceID != userID {
|
||||||
|
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid {
|
||||||
|
writeError(w, http.StatusBadRequest, "PAYMENT_INCOMPLETE", "Payment has not been completed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tier := domain.TierPremium
|
||||||
|
if t, ok := s.Metadata["tier"]; ok && domain.IsValidTier(t) {
|
||||||
|
tier = domain.SubscriptionTier(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID := ""
|
||||||
|
if s.Customer != nil {
|
||||||
|
customerID = s.Customer.ID
|
||||||
|
}
|
||||||
|
subscriptionID := ""
|
||||||
|
if s.Subscription != nil {
|
||||||
|
subscriptionID = s.Subscription.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if customerID != "" {
|
||||||
|
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
|
||||||
|
log.Printf("VerifyCheckout: failed to save customer ID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if subscriptionID != "" && customerID != "" {
|
||||||
|
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
|
||||||
|
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Checkout verified for user %s, customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"subscription_status": "active",
|
||||||
|
"subscription_tier": string(tier),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateFreeTier sets the user to the free tier without Stripe involvement.
|
||||||
|
func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
if err := h.users.ActivateFreeTier(r.Context(), userID); err != nil {
|
||||||
|
log.Printf("ActivateFreeTier: failed for user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to activate free tier")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Free tier activated for user %s", userID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"subscription_status": "active",
|
||||||
|
"subscription_tier": "free",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOnboardingCheckout creates a Stripe Checkout session for a user who
|
||||||
|
// has not yet created an account. This is a public endpoint (no auth required).
|
||||||
|
// The Firebase account is created on the frontend only after payment succeeds.
|
||||||
|
func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Email == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Tier == "" {
|
||||||
|
body.Tier = "premium"
|
||||||
|
}
|
||||||
|
|
||||||
|
tier := domain.SubscriptionTier(body.Tier)
|
||||||
|
if tier != domain.TierPremium && tier != domain.TierPro {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
priceID, err := h.priceIDForTier(tier)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.CheckoutSessionParams{
|
||||||
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||||
|
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||||
|
{
|
||||||
|
Price: stripe.String(priceID),
|
||||||
|
Quantity: stripe.Int64(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
|
||||||
|
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
|
||||||
|
CustomerEmail: stripe.String(body.Email),
|
||||||
|
}
|
||||||
|
params.AddMetadata("tier", string(tier))
|
||||||
|
params.AddMetadata("onboarding", "true")
|
||||||
|
|
||||||
|
s, err := checkoutsession.New(params)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create onboarding checkout session: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePortalSession creates a Stripe Billing Portal session so the user can
|
||||||
|
// manage their subscription (cancel, update payment method, view invoices).
|
||||||
|
func (h *StripeHandler) CreatePortalSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
user, err := h.users.GetByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Portal: failed to get user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.StripeCustomerID == nil || *user.StripeCustomerID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "NO_CUSTOMER", "No Stripe customer on file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.BillingPortalSessionParams{
|
||||||
|
Customer: user.StripeCustomerID,
|
||||||
|
ReturnURL: stripe.String(h.cfg.FrontendURL + "/account"),
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := portalsession.New(params)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Portal: failed to create portal session: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create portal session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleWebhook processes incoming Stripe webhook events.
|
||||||
|
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error reading webhook body: %v", err)
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := r.Header.Get("Stripe-Signature")
|
||||||
|
event, err := webhook.ConstructEvent(payload, sig, h.cfg.StripeWebhookSecret)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Webhook signature verification failed: %v", err)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event.Type {
|
||||||
|
case "checkout.session.completed":
|
||||||
|
h.handleCheckoutCompleted(r, event)
|
||||||
|
case "customer.subscription.updated":
|
||||||
|
h.handleSubscriptionUpdated(r, event)
|
||||||
|
case "customer.subscription.deleted":
|
||||||
|
h.handleSubscriptionDeleted(r, event)
|
||||||
|
default:
|
||||||
|
log.Printf("Unhandled Stripe event type: %s", event.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Event) {
|
||||||
|
var session stripe.CheckoutSession
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
|
||||||
|
log.Printf("Error parsing checkout session: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := session.ClientReferenceID
|
||||||
|
if userID == "" {
|
||||||
|
log.Println("Checkout session missing client_reference_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tier := domain.TierPremium
|
||||||
|
if t, ok := session.Metadata["tier"]; ok && domain.IsValidTier(t) {
|
||||||
|
tier = domain.SubscriptionTier(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID := ""
|
||||||
|
if session.Customer != nil {
|
||||||
|
customerID = session.Customer.ID
|
||||||
|
}
|
||||||
|
subscriptionID := ""
|
||||||
|
if session.Subscription != nil {
|
||||||
|
subscriptionID = session.Subscription.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if customerID != "" {
|
||||||
|
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
|
||||||
|
log.Printf("Failed to save Stripe customer ID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if subscriptionID != "" && customerID != "" {
|
||||||
|
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
|
||||||
|
log.Printf("Failed to activate subscription: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Checkout completed for user %s, customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.Event) {
|
||||||
|
var sub stripe.Subscription
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||||
|
log.Printf("Error parsing subscription update: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID := ""
|
||||||
|
if sub.Customer != nil {
|
||||||
|
customerID = sub.Customer.ID
|
||||||
|
}
|
||||||
|
if customerID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := string(sub.Status)
|
||||||
|
|
||||||
|
// Detect tier from the subscription's current price so that
|
||||||
|
// upgrades/downgrades via the Stripe portal are reflected.
|
||||||
|
tier := domain.TierPremium
|
||||||
|
if sub.Items != nil {
|
||||||
|
for _, item := range sub.Items.Data {
|
||||||
|
if item.Price != nil {
|
||||||
|
if t := h.cfg.TierForPriceID(item.Price.ID); t != "" {
|
||||||
|
tier = domain.SubscriptionTier(t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil {
|
||||||
|
log.Printf("Failed to update subscription: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {
|
||||||
|
var sub stripe.Subscription
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||||
|
log.Printf("Error parsing subscription deletion: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID := ""
|
||||||
|
if sub.Customer != nil {
|
||||||
|
customerID = sub.Customer.ID
|
||||||
|
}
|
||||||
|
if customerID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.users.UpdateSubscriptionStatus(r.Context(), customerID, "canceled"); err != nil {
|
||||||
|
log.Printf("Failed to mark subscription canceled: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Subscription canceled for customer %s", customerID)
|
||||||
|
}
|
||||||
161
backend/internal/middleware/auth.go
Normal file
161
backend/internal/middleware/auth.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// Package middleware provides HTTP middleware for authentication and context injection.
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
fbauth "github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserIDKey contextKey = "user_id"
|
||||||
|
UserEmailKey contextKey = "user_email"
|
||||||
|
UserTierKey contextKey = "user_tier"
|
||||||
|
)
|
||||||
|
|
||||||
|
type errorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate verifies the Firebase ID token and auto-provisions a local user
|
||||||
|
// record. The local user UUID (not the Firebase UID) is placed into context so
|
||||||
|
// all downstream handlers use it as the canonical user identifier.
|
||||||
|
func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
log.Println("No Authorization header or invalid format")
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
|
Error: "UNAUTHORIZED",
|
||||||
|
Message: "No authentication token provided",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if token == "" {
|
||||||
|
log.Println("Empty token")
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
|
Error: "UNAUTHORIZED",
|
||||||
|
Message: "Invalid token format",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Verifying Firebase token...")
|
||||||
|
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Token verification failed: %v", err)
|
||||||
|
|
||||||
|
errMsg := err.Error()
|
||||||
|
if strings.Contains(errMsg, "expired") {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
|
Error: "TOKEN_EXPIRED",
|
||||||
|
Message: "Authentication token has expired",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
|
Error: "UNAUTHORIZED",
|
||||||
|
Message: "Failed to verify authentication token",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
firebaseUID := decoded.UID
|
||||||
|
email, _ := decoded.Claims["email"].(string)
|
||||||
|
|
||||||
|
user, err := userModel.FindOrCreateByFirebaseUID(r.Context(), firebaseUID, email)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to provision local user for Firebase UID %s: %v", firebaseUID, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||||
|
Error: "INTERNAL_ERROR",
|
||||||
|
Message: "Failed to initialize user account",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Token verified! User UUID: %s, Email: %s", user.ID, email)
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
|
||||||
|
ctx = context.WithValue(ctx, UserEmailKey, email)
|
||||||
|
ctx = context.WithValue(ctx, UserTierKey, string(user.SubscriptionTier))
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireSubscription blocks requests from users without an active subscription.
|
||||||
|
// Must be applied after Authenticate.
|
||||||
|
func RequireSubscription(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := GetUserID(r.Context())
|
||||||
|
if userID == "" {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
|
Error: "UNAUTHORIZED",
|
||||||
|
Message: "Authentication required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := userModel.GetByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("RequireSubscription: failed to load user %s: %v", userID, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||||
|
Error: "INTERNAL_ERROR",
|
||||||
|
Message: "Failed to verify subscription",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.SubscriptionStatus != "active" && user.SubscriptionStatus != "trialing" {
|
||||||
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||||
|
Error: "SUBSCRIPTION_REQUIRED",
|
||||||
|
Message: "An active subscription is required to use this feature",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserID(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(UserIDKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserEmail(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(UserEmailKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserTier(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(UserTierKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "free"
|
||||||
|
}
|
||||||
60
backend/internal/middleware/auth_test.go
Normal file
60
backend/internal/middleware/auth_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetUserID_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if id := GetUserID(ctx); id != "" {
|
||||||
|
t.Errorf("expected empty user ID, got %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserID_Set(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), UserIDKey, "abc-123")
|
||||||
|
if id := GetUserID(ctx); id != "abc-123" {
|
||||||
|
t.Errorf("expected abc-123, got %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserEmail_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if email := GetUserEmail(ctx); email != "" {
|
||||||
|
t.Errorf("expected empty email, got %q", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserEmail_Set(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), UserEmailKey, "test@example.com")
|
||||||
|
if email := GetUserEmail(ctx); email != "test@example.com" {
|
||||||
|
t.Errorf("expected test@example.com, got %q", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserTier_Default(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if tier := GetUserTier(ctx); tier != "free" {
|
||||||
|
t.Errorf("expected default tier 'free', got %q", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserTier_Set(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), UserTierKey, "pro")
|
||||||
|
if tier := GetUserTier(ctx); tier != "pro" {
|
||||||
|
t.Errorf("expected 'pro', got %q", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"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/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AddressModel struct {
|
type AddressModel struct {
|
||||||
@@ -55,12 +55,14 @@ func (m *AddressModel) ListByUser(ctx context.Context, userID string) ([]domain.
|
|||||||
return addresses, rows.Err()
|
return addresses, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAll returns all addresses system-wide (used by the poller).
|
// ListAll returns addresses for users with an active subscription (used by the poller).
|
||||||
func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) {
|
func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT id, user_id, address, label, created_at
|
`SELECT a.id, a.user_id, a.address, a.label, a.created_at
|
||||||
FROM addresses
|
FROM addresses a
|
||||||
ORDER BY created_at DESC`,
|
JOIN users u ON u.id = a.user_id
|
||||||
|
WHERE u.subscription_status IN ('active', 'trialing')
|
||||||
|
ORDER BY a.created_at DESC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -107,6 +109,33 @@ func (m *AddressModel) FindByID(ctx context.Context, id int, userID *string) (*d
|
|||||||
return &a, nil
|
return &a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateLabel updates the label for an address owned by userID.
|
||||||
|
// Returns nil, nil if no row matched (address not found or not owned by user).
|
||||||
|
func (m *AddressModel) UpdateLabel(ctx context.Context, id int, userID string, label *string) (*domain.Address, error) {
|
||||||
|
var a domain.Address
|
||||||
|
err := m.pool.QueryRow(ctx,
|
||||||
|
`UPDATE addresses SET label = $3 WHERE id = $1 AND user_id = $2
|
||||||
|
RETURNING id, user_id, address, label, created_at`,
|
||||||
|
id, userID, label,
|
||||||
|
).Scan(&a.ID, &a.UserID, &a.Address, &a.Label, &a.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AddressModel) CountByUser(ctx context.Context, userID string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := m.pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM addresses WHERE user_id = $1`,
|
||||||
|
userID,
|
||||||
|
).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) {
|
func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) {
|
||||||
tag, err := m.pool.Exec(ctx,
|
tag, err := m.pool.Exec(ctx,
|
||||||
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`,
|
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`,
|
||||||
@@ -2,9 +2,11 @@ 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/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertEventModel struct {
|
type AlertEventModel struct {
|
||||||
@@ -71,10 +73,15 @@ func (m *AlertEventModel) Create(ctx context.Context, alertRuleID int, message s
|
|||||||
err := m.pool.QueryRow(ctx,
|
err := m.pool.QueryRow(ctx,
|
||||||
`INSERT INTO alert_events (alert_rule_id, message, address_label, tx_hash)
|
`INSERT INTO alert_events (alert_rule_id, message, address_label, tx_hash)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
RETURNING id, alert_rule_id, message, address_label, tx_hash, timestamp`,
|
RETURNING id, alert_rule_id, message, address_label, tx_hash, timestamp`,
|
||||||
alertRuleID, message, addressLabel, txHash,
|
alertRuleID, message, addressLabel, txHash,
|
||||||
).Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp)
|
).Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// Duplicate silently skipped by ON CONFLICT DO NOTHING
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &e, nil
|
return &e, nil
|
||||||
142
backend/internal/models/alert_rule.go
Normal file
142
backend/internal/models/alert_rule.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertRuleModel struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel {
|
||||||
|
return &AlertRuleModel{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold, minimum, maximum *float64) (*domain.AlertRule, error) {
|
||||||
|
var r domain.AlertRule
|
||||||
|
err := m.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO alert_rules (address_id, type, threshold, minimum, maximum, enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, TRUE)
|
||||||
|
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||||
|
addressID, alertType.String(), threshold, minimum, maximum,
|
||||||
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
|
||||||
|
rows, err := m.pool.Query(ctx,
|
||||||
|
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
|
||||||
|
FROM alert_rules
|
||||||
|
WHERE address_id = $1
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
addressID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var rules []domain.AlertRule
|
||||||
|
for rows.Next() {
|
||||||
|
var r domain.AlertRule
|
||||||
|
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules = append(rules, r)
|
||||||
|
}
|
||||||
|
return rules, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (*domain.AlertRule, error) {
|
||||||
|
var r domain.AlertRule
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if userID != nil {
|
||||||
|
err = m.pool.QueryRow(ctx,
|
||||||
|
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.minimum, ar.maximum, ar.enabled, ar.created_at
|
||||||
|
FROM alert_rules ar
|
||||||
|
JOIN addresses a ON a.id = ar.address_id
|
||||||
|
WHERE ar.id = $1 AND a.user_id = $2`,
|
||||||
|
id, *userID,
|
||||||
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
|
} else {
|
||||||
|
err = m.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
|
||||||
|
FROM alert_rules
|
||||||
|
WHERE id = $1`,
|
||||||
|
id,
|
||||||
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool) (*domain.AlertRule, error) {
|
||||||
|
var r domain.AlertRule
|
||||||
|
err := m.pool.QueryRow(ctx,
|
||||||
|
`UPDATE alert_rules
|
||||||
|
SET enabled = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||||
|
id, enabled,
|
||||||
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AlertRuleModel) UpdateThresholds(ctx context.Context, id int, minimum, maximum *float64) (*domain.AlertRule, error) {
|
||||||
|
var r domain.AlertRule
|
||||||
|
err := m.pool.QueryRow(ctx,
|
||||||
|
`UPDATE alert_rules
|
||||||
|
SET minimum = $2, maximum = $3
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||||
|
id, minimum, maximum,
|
||||||
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AlertRuleModel) CountDistinctTypesByAddress(ctx context.Context, addressID int) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := m.pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(DISTINCT type) FROM alert_rules WHERE address_id = $1`,
|
||||||
|
addressID,
|
||||||
|
).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
|
||||||
|
tag, err := m.pool.Exec(ctx,
|
||||||
|
`DELETE FROM alert_rules WHERE id = $1`,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, nil
|
||||||
|
}
|
||||||
@@ -3,10 +3,11 @@ package models
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"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/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CheckpointModel struct {
|
type CheckpointModel struct {
|
||||||
@@ -17,6 +18,20 @@ func NewCheckpointModel(pool *pgxpool.Pool) *CheckpointModel {
|
|||||||
return &CheckpointModel{pool: pool}
|
return &CheckpointModel{pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLatestBlock returns the highest last_checked_block and its timestamp across all addresses.
|
||||||
|
// Returns nil, nil, nil when no checkpoints exist yet.
|
||||||
|
func (m *CheckpointModel) GetLatestBlock(ctx context.Context) (*int, *time.Time, error) {
|
||||||
|
var block *int
|
||||||
|
var checkedAt *time.Time
|
||||||
|
err := m.pool.QueryRow(ctx,
|
||||||
|
`SELECT MAX(last_checked_block), MAX(last_checked_at) FROM address_checkpoints`,
|
||||||
|
).Scan(&block, &checkedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return block, checkedAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetLastCheckedBlock returns the last checked block for an address, or -1 if never checked.
|
// GetLastCheckedBlock returns the last checked block for an address, or -1 if never checked.
|
||||||
func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int) (int, bool, error) {
|
func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int) (int, bool, error) {
|
||||||
var block int
|
var block int
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"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/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NotificationConfigModel struct {
|
type NotificationConfigModel struct {
|
||||||
@@ -77,10 +77,12 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
|
|||||||
|
|
||||||
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
`SELECT nc.user_id, nc.discord_webhook_url, nc.telegram_chat_id, nc.telegram_bot_token,
|
||||||
email, slack_webhook_url
|
nc.email, nc.slack_webhook_url
|
||||||
FROM user_notification_configs
|
FROM user_notification_configs nc
|
||||||
WHERE notification_enabled = TRUE`,
|
JOIN users u ON u.id = nc.user_id
|
||||||
|
WHERE nc.notification_enabled = TRUE
|
||||||
|
AND u.subscription_status IN ('active', 'trialing')`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
111
backend/internal/models/user.go
Normal file
111
backend/internal/models/user.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserModel struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserModel(pool *pgxpool.Pool) *UserModel {
|
||||||
|
return &UserModel{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userColumns = `id, firebase_uid, email, display_name,
|
||||||
|
stripe_customer_id, stripe_subscription_id, subscription_status,
|
||||||
|
subscription_tier, subscription_created_at, created_at, updated_at`
|
||||||
|
|
||||||
|
func scanUser(row pgx.Row) (*domain.User, error) {
|
||||||
|
var u domain.User
|
||||||
|
err := row.Scan(
|
||||||
|
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
|
||||||
|
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
|
||||||
|
&u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil //nolint:nilnil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
|
||||||
|
// creating one if it doesn't exist yet. On conflict (returning user) the
|
||||||
|
// updated_at timestamp is refreshed.
|
||||||
|
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
|
||||||
|
row := m.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO users (firebase_uid, email)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
|
||||||
|
RETURNING `+userColumns,
|
||||||
|
firebaseUID, email,
|
||||||
|
)
|
||||||
|
return scanUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||||
|
row := m.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+userColumns+` FROM users WHERE id = $1`, id,
|
||||||
|
)
|
||||||
|
return scanUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
|
||||||
|
_, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
|
userID, stripeCustomerID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string, tier domain.SubscriptionTier) error {
|
||||||
|
_, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE users
|
||||||
|
SET stripe_subscription_id = $2,
|
||||||
|
subscription_status = $3,
|
||||||
|
subscription_tier = $4,
|
||||||
|
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE stripe_customer_id = $1`,
|
||||||
|
stripeCustomerID, subscriptionID, status, string(tier),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) UpdateSubscriptionTier(ctx context.Context, userID string, tier domain.SubscriptionTier) error {
|
||||||
|
_, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE users SET subscription_tier = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
|
userID, string(tier),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) ActivateFreeTier(ctx context.Context, userID string) error {
|
||||||
|
_, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE users
|
||||||
|
SET subscription_status = 'active',
|
||||||
|
subscription_tier = 'free',
|
||||||
|
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) UpdateSubscriptionStatus(ctx context.Context, stripeCustomerID, status string) error {
|
||||||
|
_, err := m.pool.Exec(ctx,
|
||||||
|
`UPDATE users SET subscription_status = $2, updated_at = NOW()
|
||||||
|
WHERE stripe_customer_id = $1`,
|
||||||
|
stripeCustomerID, status,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package notifications
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -20,16 +21,17 @@ const (
|
|||||||
colorBlue = 0x0099ff
|
colorBlue = 0x0099ff
|
||||||
)
|
)
|
||||||
|
|
||||||
// discordHTTPClient is a shared HTTP client with a timeout for Discord requests.
|
|
||||||
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertMetadata struct {
|
// sends alert notifications via a Discord webhook.
|
||||||
TxHash string
|
type DiscordNotifier struct {
|
||||||
AddressLabel string
|
WebhookURL string
|
||||||
AlertType string
|
}
|
||||||
Address string
|
func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||||
|
_, err := SendDiscordNotification(d.WebhookURL, message, meta)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
type discordEmbed struct {
|
type discordEmbed struct {
|
||||||
@@ -99,8 +101,12 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
|
||||||
log.Printf("Discord webhook failed: HTTP %d", resp.StatusCode)
|
log.Printf("Discord webhook failed: HTTP %d", resp.StatusCode)
|
||||||
return false, fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
|
if isPermanentStatusCode(resp.StatusCode) {
|
||||||
|
return false, &PermanentError{Err: err}
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -2,6 +2,7 @@ package notifications
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -9,6 +10,17 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type EmailNotifier struct {
|
||||||
|
APIKey string
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||||
|
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const emailHTTPTimeoutSeconds = 10
|
const emailHTTPTimeoutSeconds = 10
|
||||||
|
|
||||||
var emailHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
var emailHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
@@ -85,8 +97,12 @@ func SendEmailNotification(apiKey, fromAddress, toAddress, message string, meta
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
||||||
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
|
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
|
||||||
return false, fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
if isPermanentStatusCode(resp.StatusCode) {
|
||||||
|
return false, &PermanentError{Err: err}
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
38
backend/internal/notifications/notifier.go
Normal file
38
backend/internal/notifications/notifier.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertMetadata holds context about the alert being sent.
|
||||||
|
type AlertMetadata struct {
|
||||||
|
TxHash string
|
||||||
|
AddressLabel string
|
||||||
|
AlertType string
|
||||||
|
Address string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notifier interface {
|
||||||
|
Send(ctx context.Context, message string, meta AlertMetadata) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PermanentError wraps errors that should not be retried (e.g. 401, 403, 404).
|
||||||
|
type PermanentError struct{ Err error }
|
||||||
|
|
||||||
|
func (e *PermanentError) Error() string { return e.Err.Error() }
|
||||||
|
func (e *PermanentError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
func IsPermanent(err error) bool {
|
||||||
|
var p *PermanentError
|
||||||
|
return errors.As(err, &p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPermanentStatusCode(code int) bool {
|
||||||
|
return code == http.StatusUnauthorized ||
|
||||||
|
code == http.StatusForbidden ||
|
||||||
|
code == http.StatusNotFound ||
|
||||||
|
code == http.StatusMethodNotAllowed ||
|
||||||
|
code == http.StatusGone
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package notifications
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -9,6 +10,17 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SlackNotifier sends alert notifications via a Slack webhook.
|
||||||
|
type SlackNotifier struct {
|
||||||
|
WebhookURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send implements Notifier for Slack.
|
||||||
|
func (s *SlackNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||||
|
_, err := SendSlackNotification(s.WebhookURL, message, meta)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const slackHTTPTimeoutSeconds = 10
|
const slackHTTPTimeoutSeconds = 10
|
||||||
|
|
||||||
var slackHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
var slackHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
@@ -75,8 +87,12 @@ func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
||||||
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
||||||
return false, fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
if isPermanentStatusCode(resp.StatusCode) {
|
||||||
|
return false, &PermanentError{Err: err}
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -2,6 +2,7 @@ package notifications
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -9,6 +10,18 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TelegramNotifier sends alert notifications via Telegram.
|
||||||
|
type TelegramNotifier struct {
|
||||||
|
BotToken string
|
||||||
|
ChatID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send implements Notifier for Telegram.
|
||||||
|
func (t *TelegramNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||||
|
_, err := SendTelegramNotification(t.BotToken, t.ChatID, message, meta)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const telegramHTTPTimeoutSeconds = 10
|
const telegramHTTPTimeoutSeconds = 10
|
||||||
|
|
||||||
var telegramHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
var telegramHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
@@ -50,8 +63,12 @@ func SendTelegramNotification(botToken, chatID, message string, meta AlertMetada
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
||||||
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
|
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
|
||||||
return false, fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
if isPermanentStatusCode(resp.StatusCode) {
|
||||||
|
return false, &PermanentError{Err: err}
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
421
backend/internal/protocols/ethereum/jsonrpc.go
Normal file
421
backend/internal/protocols/ethereum/jsonrpc.go
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
package ethereum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
rpcTimeoutMS = 30000
|
||||||
|
rpcMaxRetries = 3
|
||||||
|
rpcRetryBaseMS = 2000
|
||||||
|
rpcMinIntervalMS = 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
type JsonRpcEthereum struct {
|
||||||
|
rpcURL string
|
||||||
|
client *http.Client
|
||||||
|
lastCallAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
|
||||||
|
if rpcURL == "" {
|
||||||
|
return nil, fmt.Errorf("JsonRpcEthereum requires a valid RPC URL")
|
||||||
|
}
|
||||||
|
return &JsonRpcEthereum{
|
||||||
|
rpcURL: rpcURL,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: time.Duration(rpcTimeoutMS) * time.Millisecond,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params []interface{} `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error *rpcError `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
|
||||||
|
j.throttle(ctx)
|
||||||
|
|
||||||
|
if params == nil {
|
||||||
|
params = []interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(rpcRequest{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: time.Now().UnixMilli(),
|
||||||
|
Method: method,
|
||||||
|
Params: params,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal RPC request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, callErr := j.callWithRetry(ctx, method, body)
|
||||||
|
j.lastCallAt = time.Now()
|
||||||
|
|
||||||
|
return result, callErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JsonRpcEthereum) throttle(ctx context.Context) {
|
||||||
|
if j.lastCallAt.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
minInterval := time.Duration(rpcMinIntervalMS) * time.Millisecond
|
||||||
|
elapsed := time.Since(j.lastCallAt)
|
||||||
|
if elapsed >= minInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(minInterval - elapsed):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
|
||||||
|
// It retries on network errors, HTTP 429, and HTTP 5xx. It does NOT retry on RPC-level
|
||||||
|
// errors or other 4xx responses (those are permanent failures).
|
||||||
|
func (j *JsonRpcEthereum) callWithRetry(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := range rpcMaxRetries {
|
||||||
|
if attempt > 0 {
|
||||||
|
wait := time.Duration(rpcRetryBaseMS*(1<<(attempt-1))) * time.Millisecond
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
log.Printf("Retrying RPC call [%s] (attempt %d/%d)", method, attempt+1, rpcMaxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := j.doRPCCall(ctx, method, body)
|
||||||
|
if err == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
|
||||||
|
// Permanent errors: do not retry
|
||||||
|
if isPermanentRPCError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Transient RPC error [%s] (attempt %d/%d): %v", method, attempt+1, rpcMaxRetries, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JsonRpcEthereum) doRPCCall(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create RPC request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := j.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("RPC call failed [%s]: %w", method, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 429 and 5xx are transient; other non-200 are permanent.
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
err := fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { //nolint:mnd
|
||||||
|
return nil, err // transient — will be retried
|
||||||
|
}
|
||||||
|
return nil, &permanentRPCError{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rpcResp rpcResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode RPC response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rpcResp.Error != nil {
|
||||||
|
// RPC-level errors are permanent (bad params, unsupported method, etc.)
|
||||||
|
return nil, &permanentRPCError{
|
||||||
|
fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rpcResp.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type permanentRPCError struct{ cause error }
|
||||||
|
|
||||||
|
func (e *permanentRPCError) Error() string { return e.cause.Error() }
|
||||||
|
func (e *permanentRPCError) Unwrap() error { return e.cause }
|
||||||
|
|
||||||
|
func isPermanentRPCError(err error) bool {
|
||||||
|
var p *permanentRPCError
|
||||||
|
return errors.As(err, &p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
|
||||||
|
result, err := j.callRPC(ctx, "eth_blockNumber")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var hexBlock string
|
||||||
|
if err := json.Unmarshal(result, &hexBlock); err != nil {
|
||||||
|
return 0, fmt.Errorf("unmarshal block number: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hexToInt(hexBlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcBlock struct {
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Transactions []rpcTx `json:"transactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcTx struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To *string `json:"to"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
BlockNumber string `json:"blockNumber"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JsonRpcEthereum) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) {
|
||||||
|
hexBlock := fmt.Sprintf("0x%x", blockNumber)
|
||||||
|
|
||||||
|
result, err := j.callRPC(ctx, "eth_getBlockByNumber", hexBlock, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(result) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var block rpcBlock
|
||||||
|
if err := json.Unmarshal(result, &block); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal block: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blockTimestamp, err := hexToInt64(block.Timestamp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse block timestamp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
txs := make([]domain.NormalizedTx, 0, len(block.Transactions))
|
||||||
|
for _, tx := range block.Transactions {
|
||||||
|
bn, _ := hexToInt(tx.BlockNumber)
|
||||||
|
|
||||||
|
var toLower *string
|
||||||
|
if tx.To != nil {
|
||||||
|
s := strings.ToLower(*tx.To)
|
||||||
|
toLower = &s
|
||||||
|
}
|
||||||
|
|
||||||
|
txs = append(txs, domain.NormalizedTx{
|
||||||
|
Hash: tx.Hash,
|
||||||
|
From: strings.ToLower(tx.From),
|
||||||
|
To: toLower,
|
||||||
|
Value: hexToDecimalString(tx.Value),
|
||||||
|
BlockNumber: bn,
|
||||||
|
BlockTimestamp: blockTimestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return txs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (string, error) {
|
||||||
|
result, err := j.callRPC(ctx, "eth_getBalance", address, "latest")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var hexBalance string
|
||||||
|
if err := json.Unmarshal(result, &hexBalance); err != nil {
|
||||||
|
return "", fmt.Errorf("unmarshal balance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hexToDecimalString(hexBalance), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const erc20TransferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||||
|
|
||||||
|
const minTransferTopics = 3
|
||||||
|
|
||||||
|
type rpcLog struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Topics []string `json:"topics"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
BlockNumber string `json:"blockNumber"`
|
||||||
|
TxHash string `json:"transactionHash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenTransfers fetches ERC-20 Transfer events for a given address
|
||||||
|
// across a block range, covering both incoming and outgoing transfers.
|
||||||
|
func (j *JsonRpcEthereum) GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error) {
|
||||||
|
paddedAddr := padAddress(address)
|
||||||
|
hexFrom := fmt.Sprintf("0x%x", fromBlock)
|
||||||
|
hexTo := fmt.Sprintf("0x%x", toBlock)
|
||||||
|
|
||||||
|
incomingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, "", paddedAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch incoming token transfers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outgoingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, paddedAddr, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch outgoing token transfers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var txs []domain.NormalizedTx
|
||||||
|
|
||||||
|
for _, entry := range append(incomingLogs, outgoingLogs...) {
|
||||||
|
key := entry.TxHash + "|" + entry.Address + "|" + entry.Data
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
|
||||||
|
tx, parseErr := parseTransferLog(entry)
|
||||||
|
if parseErr != nil {
|
||||||
|
log.Printf("Skipping unparseable transfer log in tx %s: %v", entry.TxHash, parseErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
txs = append(txs, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return txs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JsonRpcEthereum) getTransferLogs(ctx context.Context, fromBlock, toBlock, fromAddr, toAddr string) ([]rpcLog, error) {
|
||||||
|
topics := make([]interface{}, minTransferTopics)
|
||||||
|
topics[0] = erc20TransferTopic
|
||||||
|
|
||||||
|
if fromAddr != "" {
|
||||||
|
topics[1] = fromAddr
|
||||||
|
}
|
||||||
|
if toAddr != "" {
|
||||||
|
topics[2] = toAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := map[string]interface{}{
|
||||||
|
"fromBlock": fromBlock,
|
||||||
|
"toBlock": toBlock,
|
||||||
|
"topics": topics,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := j.callRPC(ctx, "eth_getLogs", filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs []rpcLog
|
||||||
|
if err := json.Unmarshal(result, &logs); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTransferLog(entry rpcLog) (domain.NormalizedTx, error) {
|
||||||
|
if len(entry.Topics) < minTransferTopics {
|
||||||
|
return domain.NormalizedTx{}, fmt.Errorf("transfer log has %d topics, expected >= 3", len(entry.Topics))
|
||||||
|
}
|
||||||
|
|
||||||
|
from := topicToAddress(entry.Topics[1])
|
||||||
|
to := topicToAddress(entry.Topics[2])
|
||||||
|
tokenValue := hexToDecimalString(entry.Data)
|
||||||
|
blockNumber, _ := hexToInt(entry.BlockNumber)
|
||||||
|
|
||||||
|
contractAddr := strings.ToLower(entry.Address)
|
||||||
|
tokenInfo, known := LookupToken(contractAddr)
|
||||||
|
|
||||||
|
tx := domain.NormalizedTx{
|
||||||
|
Hash: entry.TxHash,
|
||||||
|
From: from,
|
||||||
|
To: &to,
|
||||||
|
Value: "0",
|
||||||
|
BlockNumber: blockNumber,
|
||||||
|
TokenContract: &contractAddr,
|
||||||
|
TokenValue: &tokenValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
if known {
|
||||||
|
tx.TokenSymbol = &tokenInfo.Symbol
|
||||||
|
tx.TokenDecimals = &tokenInfo.Decimals
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func padAddress(addr string) string {
|
||||||
|
clean := strings.TrimPrefix(strings.ToLower(addr), "0x")
|
||||||
|
const addressHexLen = 64
|
||||||
|
return "0x" + strings.Repeat("0", addressHexLen-len(clean)) + clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func topicToAddress(topic string) string {
|
||||||
|
clean := strings.TrimPrefix(topic, "0x")
|
||||||
|
const ethAddrLen = 40
|
||||||
|
if len(clean) > ethAddrLen {
|
||||||
|
clean = clean[len(clean)-ethAddrLen:]
|
||||||
|
}
|
||||||
|
return "0x" + strings.ToLower(clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexToInt(hex string) (int, error) {
|
||||||
|
hex = strings.TrimPrefix(hex, "0x")
|
||||||
|
n, ok := new(big.Int).SetString(hex, 16)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("invalid hex: %s", hex)
|
||||||
|
}
|
||||||
|
return int(n.Int64()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexToInt64(hex string) (int64, error) {
|
||||||
|
hex = strings.TrimPrefix(hex, "0x")
|
||||||
|
n, ok := new(big.Int).SetString(hex, 16)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("invalid hex: %s", hex)
|
||||||
|
}
|
||||||
|
return n.Int64(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexToDecimalString(hex string) string {
|
||||||
|
if hex == "" || hex == "0x" || hex == "0x0" {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
clean := strings.TrimPrefix(hex, "0x")
|
||||||
|
n, ok := new(big.Int).SetString(clean, 16)
|
||||||
|
if !ok {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
return n.String()
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package ethereum
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EthereumObserver defines the interface for blockchain interaction.
|
// EthereumObserver defines the interface for blockchain interaction.
|
||||||
@@ -12,4 +12,5 @@ type EthereumObserver interface {
|
|||||||
GetLatestBlockNumber(ctx context.Context) (int, error)
|
GetLatestBlockNumber(ctx context.Context) (int, error)
|
||||||
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
|
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
|
||||||
GetBalance(ctx context.Context, address string) (string, error)
|
GetBalance(ctx context.Context, address string) (string, error)
|
||||||
|
GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error)
|
||||||
}
|
}
|
||||||
34
backend/internal/protocols/ethereum/tokens.go
Normal file
34
backend/internal/protocols/ethereum/tokens.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package ethereum
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// TokenInfo holds metadata for a known ERC-20 token contract.
|
||||||
|
type TokenInfo struct {
|
||||||
|
Symbol string
|
||||||
|
Decimals int
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gochecknoglobals
|
||||||
|
var wellKnownTokens = map[string]TokenInfo{
|
||||||
|
"0xdac17f958d2ee523a2206206994597c13d831ec7": {Symbol: "USDT", Decimals: 6},
|
||||||
|
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": {Symbol: "USDC", Decimals: 6},
|
||||||
|
"0x6b175474e89094c44da98b954eedeac495271d0f": {Symbol: "DAI", Decimals: 18},
|
||||||
|
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": {Symbol: "WETH", Decimals: 18},
|
||||||
|
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": {Symbol: "WBTC", Decimals: 8},
|
||||||
|
"0x514910771af9ca656af840dff83e8264ecf986ca": {Symbol: "LINK", Decimals: 18},
|
||||||
|
"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": {Symbol: "UNI", Decimals: 18},
|
||||||
|
"0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9": {Symbol: "AAVE", Decimals: 18},
|
||||||
|
"0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce": {Symbol: "SHIB", Decimals: 18},
|
||||||
|
"0x6982508145454ce325ddbe47a25d4ec3d2311933": {Symbol: "PEPE", Decimals: 18},
|
||||||
|
"0xb8c77482e45f1f44de1745f52c74426c631bdd52": {Symbol: "BNB", Decimals: 18},
|
||||||
|
"0x4fabb145d64652a948d72533023f6e7a623c7c53": {Symbol: "BUSD", Decimals: 18},
|
||||||
|
"0x75231f58b43240c9718dd58b4967c5114342a86c": {Symbol: "OKB", Decimals: 18},
|
||||||
|
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1": {Symbol: "TON", Decimals: 9},
|
||||||
|
"0x4d224452801aced8b2f0aebe155379bb5d594381": {Symbol: "APE", Decimals: 18},
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupToken returns metadata for a known token contract, if found.
|
||||||
|
func LookupToken(contractAddress string) (TokenInfo, bool) {
|
||||||
|
info, ok := wellKnownTokens[strings.ToLower(contractAddress)]
|
||||||
|
return info, ok
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -20,7 +20,7 @@ const (
|
|||||||
|
|
||||||
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
|
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
|
||||||
|
|
||||||
// EmailDigestService handles email setup and digest sending via Resend.
|
// handles email setup and digest sending via Resend.
|
||||||
type EmailDigestService struct {
|
type EmailDigestService struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
fromAddress string
|
fromAddress string
|
||||||
@@ -66,7 +66,6 @@ func (s *EmailDigestService) SetupEmail(toAddress string) error {
|
|||||||
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
|
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 {
|
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
|
||||||
if !s.Configured() {
|
if !s.Configured() {
|
||||||
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
||||||
@@ -131,8 +130,6 @@ func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress s
|
|||||||
return s.send(toAddress, subject, html)
|
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) {
|
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
|
||||||
if !s.Configured() {
|
if !s.Configured() {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
406
backend/internal/services/evaluator.go
Normal file
406
backend/internal/services/evaluator.go
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/semaphore"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/wei"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
notificationTimeout = 30 * time.Second
|
||||||
|
notificationMaxRetries = 3
|
||||||
|
notificationRetryBase = time.Second
|
||||||
|
maxConcurrentNotifications = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
type EvaluatorService struct {
|
||||||
|
eth ethereum.EthereumObserver
|
||||||
|
alertRules *models.AlertRuleModel
|
||||||
|
alertEvents *models.AlertEventModel
|
||||||
|
addresses *models.AddressModel
|
||||||
|
users *models.UserModel
|
||||||
|
notifConfigs *models.NotificationConfigModel
|
||||||
|
notifSem *semaphore.Weighted
|
||||||
|
notifWg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEvaluatorService(
|
||||||
|
eth ethereum.EthereumObserver,
|
||||||
|
alertRules *models.AlertRuleModel,
|
||||||
|
alertEvents *models.AlertEventModel,
|
||||||
|
addresses *models.AddressModel,
|
||||||
|
users *models.UserModel,
|
||||||
|
notifConfigs *models.NotificationConfigModel,
|
||||||
|
) *EvaluatorService {
|
||||||
|
return &EvaluatorService{
|
||||||
|
eth: eth,
|
||||||
|
alertRules: alertRules,
|
||||||
|
alertEvents: alertEvents,
|
||||||
|
addresses: addresses,
|
||||||
|
users: users,
|
||||||
|
notifConfigs: notifConfigs,
|
||||||
|
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EvaluatorService) Evaluate(ctx context.Context, observations []domain.ObservedTx) (int, error) {
|
||||||
|
alertsFired := 0
|
||||||
|
|
||||||
|
for _, obs := range observations {
|
||||||
|
fired, err := s.evaluateObservation(ctx, obs)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error evaluating observation for address ID %d: %v", obs.AddressID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alertsFired += fired
|
||||||
|
}
|
||||||
|
|
||||||
|
return alertsFired, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.ObservedTx) (int, error) {
|
||||||
|
rules, err := s.alertRules.ListByAddress(ctx, obs.AddressID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
alertsFired := 0
|
||||||
|
for _, rule := range rules {
|
||||||
|
if !rule.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, err := s.ruleMatches(ctx, rule, obs)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error matching rule %d: %v", rule.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches {
|
||||||
|
if err := s.fireAlert(ctx, rule, obs); err != nil {
|
||||||
|
log.Printf("Error firing alert for rule %d: %v", rule.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alertsFired++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return alertsFired, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||||
|
switch rule.Type {
|
||||||
|
case domain.AlertIncomingTx:
|
||||||
|
return s.matchesDirectionalTx(rule, obs, domain.DirectionIncoming)
|
||||||
|
case domain.AlertOutgoingTx:
|
||||||
|
return s.matchesDirectionalTx(rule, obs, domain.DirectionOutgoing)
|
||||||
|
case domain.AlertLargeTransfer:
|
||||||
|
return s.matchesLargeTransfer(rule, obs)
|
||||||
|
case domain.AlertBalanceBelow:
|
||||||
|
return s.matchesBalanceBelow(ctx, rule, obs)
|
||||||
|
default:
|
||||||
|
log.Printf("Unknown rule type: %s", rule.Type)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EvaluatorService) matchesDirectionalTx(rule domain.AlertRule, obs domain.ObservedTx, expected domain.Direction) (bool, error) {
|
||||||
|
if obs.Direction != expected {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.Minimum != nil {
|
||||||
|
minWei, err := wei.FromEth(*rule.Minimum)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
aboveMin, err := wei.GreaterThanOrEqual(obs.Value, minWei)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !aboveMin {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.Maximum != nil {
|
||||||
|
maxWei, err := wei.FromEth(*rule.Maximum)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
belowMax, err := wei.LessThanOrEqual(obs.Value, maxWei)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !belowMax {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||||
|
if rule.Threshold == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
thresholdWei, err := wei.FromEth(*rule.Threshold)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return wei.GreaterThanOrEqual(obs.Value, thresholdWei)
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesBalanceBelow only triggers after outgoing transactions, since incoming
|
||||||
|
// transactions increase balance and can't cause it to drop below threshold.
|
||||||
|
func (s *EvaluatorService) matchesBalanceBelow(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||||
|
if rule.Threshold == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if obs.Direction != domain.DirectionOutgoing {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if addr == nil {
|
||||||
|
log.Printf("Address ID %d not found", obs.AddressID)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
balanceWei, err := s.eth.GetBalance(ctx, addr.Address)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
thresholdWei, err := wei.FromEth(*rule.Threshold)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return wei.LessThan(balanceWei, thresholdWei)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) error {
|
||||||
|
addr, err := s.addresses.FindByID(ctx, obs.AddressID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
addressLabel := "Unknown"
|
||||||
|
if addr != nil {
|
||||||
|
if addr.Label != nil {
|
||||||
|
addressLabel = *addr.Label
|
||||||
|
} else {
|
||||||
|
addressLabel = addr.Address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message := s.buildMessage(rule, obs)
|
||||||
|
txHash := &obs.Hash
|
||||||
|
|
||||||
|
event, err := s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if event == nil {
|
||||||
|
log.Printf("[ALERT DEDUP] Rule %d (%s) - duplicate event skipped for TX: %s", rule.ID, rule.Type, obs.Hash)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
|
||||||
|
|
||||||
|
if addr != nil {
|
||||||
|
userID := addr.UserID
|
||||||
|
address := addr.Address
|
||||||
|
if err := s.notifSem.Acquire(ctx, 1); err != nil {
|
||||||
|
log.Printf("Failed to acquire notification semaphore for rule %d: %v", rule.ID, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.notifWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.notifSem.Release(1)
|
||||||
|
defer s.notifWg.Done()
|
||||||
|
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
|
||||||
|
defer cancel()
|
||||||
|
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForNotifications blocks until all in-flight notification goroutines finish.
|
||||||
|
func (s *EvaluatorService) WaitForNotifications() {
|
||||||
|
s.notifWg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig, limits domain.TierLimits) []notifications.Notifier {
|
||||||
|
var notifiers []notifications.Notifier
|
||||||
|
|
||||||
|
if limits.ChannelAllowed("discord") &&
|
||||||
|
cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||||
|
notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
||||||
|
}
|
||||||
|
|
||||||
|
if limits.ChannelAllowed("telegram") &&
|
||||||
|
cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||||
|
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||||
|
notifiers = append(notifiers, ¬ifications.TelegramNotifier{
|
||||||
|
BotToken: *cfg.TelegramBotToken,
|
||||||
|
ChatID: *cfg.TelegramChatID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if limits.ChannelAllowed("slack") &&
|
||||||
|
cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
|
||||||
|
notifiers = append(notifiers, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email is delivered exclusively via the daily digest service, never real-time.
|
||||||
|
|
||||||
|
return notifiers
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendWithRetry(ctx context.Context, n notifications.Notifier, message string, meta notifications.AlertMetadata) error {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := range notificationMaxRetries {
|
||||||
|
if attempt > 0 {
|
||||||
|
wait := notificationRetryBase * time.Duration(1<<(attempt-1))
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := n.Send(ctx, message, meta); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
|
||||||
|
if notifications.IsPermanent(err) {
|
||||||
|
log.Printf("Permanent notification failure, skipping retries: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
|
||||||
|
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to get notification config: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if notifConfig == nil || !notifConfig.NotificationEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.users.GetByID(ctx, userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Failed to load user %s for tier check in notification: %v", userID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||||
|
|
||||||
|
meta := notifications.AlertMetadata{
|
||||||
|
TxHash: obs.Hash,
|
||||||
|
AddressLabel: addressLabel,
|
||||||
|
AlertType: string(rule.Type),
|
||||||
|
Address: address,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range s.buildNotifiers(notifConfig, limits) {
|
||||||
|
if err := sendWithRetry(ctx, n, message, meta); err != nil {
|
||||||
|
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Notification sent to user %s via %T", userID, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||||
|
if obs.IsTokenTransfer() {
|
||||||
|
return s.buildTokenMessage(rule, obs)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rule.Type {
|
||||||
|
case domain.AlertIncomingTx:
|
||||||
|
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||||
|
return fmt.Sprintf("Incoming transaction: %s received", ethStr)
|
||||||
|
case domain.AlertOutgoingTx:
|
||||||
|
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||||
|
return fmt.Sprintf("Outgoing transaction: %s sent", ethStr)
|
||||||
|
case domain.AlertLargeTransfer:
|
||||||
|
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||||
|
threshold := float64(0)
|
||||||
|
if rule.Threshold != nil {
|
||||||
|
threshold = *rule.Threshold
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Large transfer detected: %s (threshold: %g ETH)", ethStr, threshold)
|
||||||
|
case domain.AlertBalanceBelow:
|
||||||
|
threshold := float64(0)
|
||||||
|
if rule.Threshold != nil {
|
||||||
|
threshold = *rule.Threshold
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Balance dropped below threshold of %g ETH", threshold)
|
||||||
|
default:
|
||||||
|
return "Alert triggered"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTokenDecimals = 18
|
||||||
|
|
||||||
|
func (s *EvaluatorService) buildTokenMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||||
|
symbol := "tokens"
|
||||||
|
if obs.TokenSymbol != nil {
|
||||||
|
symbol = *obs.TokenSymbol
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := "unknown"
|
||||||
|
if obs.TokenValue != nil {
|
||||||
|
decimals := defaultTokenDecimals
|
||||||
|
if obs.TokenDecimals != nil {
|
||||||
|
decimals = *obs.TokenDecimals
|
||||||
|
}
|
||||||
|
amount = wei.FormatTokenAmount(*obs.TokenValue, decimals)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rule.Type {
|
||||||
|
case domain.AlertIncomingTx:
|
||||||
|
return fmt.Sprintf("Incoming transfer: %s %s received", amount, symbol)
|
||||||
|
case domain.AlertOutgoingTx:
|
||||||
|
return fmt.Sprintf("Outgoing transfer: %s %s sent", amount, symbol)
|
||||||
|
case domain.AlertLargeTransfer:
|
||||||
|
threshold := float64(0)
|
||||||
|
if rule.Threshold != nil {
|
||||||
|
threshold = *rule.Threshold
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Large token transfer: %s %s (threshold: %g)", amount, symbol, threshold)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Token transfer: %s %s", amount, symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxBlocksPerRun = 100
|
const maxBlocksPerRun = 100
|
||||||
@@ -80,6 +80,15 @@ func (s *ObserverService) observeAddress(ctx context.Context, addr domain.Addres
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tokenTxs, err := s.eth.GetTokenTransfers(ctx, startBlock, endBlock, addr.Address)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error fetching token transfers for %s: %v", addr.Address, err)
|
||||||
|
} else {
|
||||||
|
for _, tx := range tokenTxs {
|
||||||
|
observations = append(observations, createObservedTx(tx, addr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
|
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,14 @@ func LessThan(weiA, weiB string) (bool, error) {
|
|||||||
return cmp < 0, nil
|
return cmp < 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LessThanOrEqual(weiA, weiB string) (bool, error) {
|
||||||
|
cmp, err := Compare(weiA, weiB)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return cmp <= 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FormatAsEth formats a Wei string as "X.XXXX ETH".
|
// FormatAsEth formats a Wei string as "X.XXXX ETH".
|
||||||
func FormatAsEth(weiString string, decimals int) (string, error) {
|
func FormatAsEth(weiString string, decimals int) (string, error) {
|
||||||
eth, err := ToEth(weiString)
|
eth, err := ToEth(weiString)
|
||||||
@@ -98,3 +106,54 @@ func FormatAsEth(weiString string, decimals int) (string, error) {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%.*f ETH", decimals, eth), nil
|
return fmt.Sprintf("%.*f ETH", decimals, eth), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatTokenAmount formats a raw token amount using the token's decimal places.
|
||||||
|
// For example, 1000000 USDT (6 decimals) becomes "1".
|
||||||
|
func FormatTokenAmount(rawValue string, tokenDecimals int) string {
|
||||||
|
if rawValue == "" || rawValue == "0" {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
n, ok := new(big.Int).SetString(rawValue, 10)
|
||||||
|
if !ok {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(tokenDecimals)), nil) //nolint:mnd
|
||||||
|
whole := new(big.Int).Div(n, divisor)
|
||||||
|
remainder := new(big.Int).Mod(n, divisor)
|
||||||
|
|
||||||
|
if remainder.Sign() == 0 {
|
||||||
|
return addThousandsSeparators(whole.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
fracStr := fmt.Sprintf("%0*s", tokenDecimals, remainder.String())
|
||||||
|
fracStr = strings.TrimRight(fracStr, "0")
|
||||||
|
const maxDisplayDecimals = 4
|
||||||
|
if len(fracStr) > maxDisplayDecimals {
|
||||||
|
fracStr = fracStr[:maxDisplayDecimals]
|
||||||
|
}
|
||||||
|
|
||||||
|
return addThousandsSeparators(whole.String()) + "." + fracStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func addThousandsSeparators(s string) string {
|
||||||
|
if len(s) <= 3 { //nolint:mnd
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
offset := len(s) % 3 //nolint:mnd
|
||||||
|
if offset > 0 {
|
||||||
|
result.WriteString(s[:offset])
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := offset; i < len(s); i += 3 { //nolint:mnd
|
||||||
|
if result.Len() > 0 {
|
||||||
|
result.WriteByte(',')
|
||||||
|
}
|
||||||
|
result.WriteString(s[i : i+3]) //nolint:mnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
BIN
frontend/public/koin_exp_2.mp4
Normal file
BIN
frontend/public/koin_exp_2.mp4
Normal file
Binary file not shown.
BIN
frontend/public/koin_spin.mp4
Normal file
BIN
frontend/public/koin_spin.mp4
Normal file
Binary file not shown.
BIN
frontend/public/ping.png
Normal file
BIN
frontend/public/ping.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -1,35 +1,50 @@
|
|||||||
import { Routes, Route, 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 Navbar from "./components/Navbar";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/login/Login";
|
||||||
import Signup from "./pages/Signup";
|
import Signup from "./pages/Signup";
|
||||||
import Addresses from "./pages/Addresses";
|
import Subscribe from "./pages/subscribe/Subscribe";
|
||||||
import Alerts from "./pages/Alerts";
|
import Addresses from "./pages/addresses/Addresses";
|
||||||
import AlertHistory from "./pages/AlertHistory";
|
import Alerts from "./pages/alerts/Alerts";
|
||||||
|
import AlertHistory from "./pages/alertHistory/AlertHistory";
|
||||||
|
import Account from "./pages/user_account/Account";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser, isSubscribed } = useAuth();
|
||||||
|
|
||||||
if (!currentUser) {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route path="/signup" element={<Signup />} />
|
|
||||||
<Route path="*" element={<Navigate to="/login" />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<Routes>
|
||||||
<Navbar />
|
<Route path="/login" element={<Login />} />
|
||||||
<Routes>
|
<Route path="/signup" element={<Signup />} />
|
||||||
<Route path="/" element={<Addresses />} />
|
<Route path="/subscribe" element={<Subscribe />} />
|
||||||
<Route path="/addresses" element={<Addresses />} />
|
<Route path="*" element={<Navigate to="/login" />} />
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
</Routes>
|
||||||
<Route path="/history" element={<AlertHistory />} />
|
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSubscribed) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/subscribe" element={<Subscribe />} />
|
||||||
|
<Route path="/account" element={<><Navbar /><Account /></>} />
|
||||||
|
<Route path="*" element={<Navigate to="/subscribe" />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Navbar />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Addresses />} />
|
||||||
|
<Route path="/addresses" element={<Addresses />} />
|
||||||
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
|
<Route path="/alertevents" element={<AlertHistory />} />
|
||||||
|
<Route path="/account" element={<Account />} />
|
||||||
|
<Route path="/subscribe" element={<Subscribe />} />
|
||||||
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
25
frontend/src/api/account.js
Normal file
25
frontend/src/api/account.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { getAuthHeaders } from "./authHeaders";
|
||||||
|
import { API_BASE } from "./config";
|
||||||
|
|
||||||
|
export async function getAccount() {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/user/account`, { headers });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to load account");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPortalSession() {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/stripe/create-portal-session`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to create portal session");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -80,6 +80,41 @@ export async function getAddresses() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an address (e.g. change its label)
|
||||||
|
* @param {number} addressId - Address ID to update
|
||||||
|
* @param {Object} data - Fields to update (e.g. { label: "New Label" })
|
||||||
|
* @returns {Promise<Object>} Updated address
|
||||||
|
*/
|
||||||
|
export async function updateAddress(addressId, data) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetch(`${API_BASE}/addresses/${addressId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = "Failed to update address";
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("fetch")) {
|
||||||
|
throw new Error("Cannot connect to server. Is the backend running?");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a tracked address
|
* Delete a tracked address
|
||||||
* @param {number} addressId - Address ID to delete
|
* @param {number} addressId - Address ID to delete
|
||||||
|
|||||||
@@ -129,6 +129,48 @@ export async function updateAlertStatus(alertId, enabled) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the min/max thresholds on an alert rule
|
||||||
|
* @param {number} alertId - Alert rule ID
|
||||||
|
* @param {number|null} minimum - Minimum amount (null to clear)
|
||||||
|
* @param {number|null} maximum - Maximum amount (null to clear)
|
||||||
|
* @returns {Promise<Object>} Updated alert rule
|
||||||
|
*/
|
||||||
|
export async function updateAlertThresholds(alertId, minimum, maximum) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
update_min_max: true,
|
||||||
|
minimum: minimum ?? null,
|
||||||
|
maximum: maximum ?? null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = "Failed to update alert thresholds";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an alert rule
|
* Delete an alert rule
|
||||||
* @param {number} alertId - Alert rule ID
|
* @param {number} alertId - Alert rule ID
|
||||||
|
|||||||
68
frontend/src/api/stripe.jsx
Normal file
68
frontend/src/api/stripe.jsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { getAuthHeaders } from "./authHeaders";
|
||||||
|
import { API_BASE } from "./config";
|
||||||
|
|
||||||
|
export async function createCheckoutSession(tier = "premium") {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tier }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to create checkout session");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOnboardingCheckout(email, tier = "premium") {
|
||||||
|
const res = await fetch(`${API_BASE}/stripe/create-onboarding-checkout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, tier }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to create checkout session");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateFreeTier() {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/stripe/activate-free`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to activate free tier");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCheckoutSession(sessionId) {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/stripe/verify-checkout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: sessionId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to verify checkout session");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubscriptionStatus() {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await fetch(`${API_BASE}/stripe/subscription-status`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || "Failed to get subscription status");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ export default function AddressForm({ onSubmit }) {
|
|||||||
placeholder="Treasury, Cold Wallet, etc."
|
placeholder="Treasury, Cold Wallet, etc."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" disabled={!canSubmit}>
|
<Button type="submit" disabled={!canSubmit} className="mt-md">
|
||||||
Add Address
|
Add Address
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
7
frontend/src/components/AlertForm.css
Normal file
7
frontend/src/components/AlertForm.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.alert-form__options {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-form__radio {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Input from "./Input";
|
import Input from "./Input";
|
||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
|
import "./AlertForm.css";
|
||||||
|
|
||||||
const ALERT_TYPES = [
|
const ALERT_TYPES = [
|
||||||
{ value: "incoming_tx", label: "Incoming transaction" },
|
{ value: "incoming_tx", label: "Incoming transaction" },
|
||||||
@@ -36,9 +37,9 @@ export default function AlertForm({ onSubmit }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
<div className="alert-form__options">
|
||||||
{ALERT_TYPES.map((opt) => (
|
{ALERT_TYPES.map((opt) => (
|
||||||
<label key={opt.value} style={{ display: "block" }}>
|
<label key={opt.value} className="alert-form__radio">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="alertType"
|
name="alertType"
|
||||||
|
|||||||
14
frontend/src/components/Button.css
Normal file
14
frontend/src/components/Button.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
|
import "./Button.css";
|
||||||
|
|
||||||
export default function Button({
|
export default function Button({
|
||||||
children,
|
children,
|
||||||
onClick,
|
onClick,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
type = "button",
|
type = "button",
|
||||||
|
variant = "primary",
|
||||||
|
className = "",
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type={type}
|
type={type}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={{
|
className={`btn btn--${variant} ${className}`}
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
fontSize: "1rem",
|
|
||||||
cursor: disabled ? "not-allowed" : "pointer",
|
|
||||||
opacity: disabled ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
21
frontend/src/components/Input.css
Normal file
21
frontend/src/components/Input.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
.input__label {
|
||||||
|
display: block;
|
||||||
|
margin: 0.8rem 0rem 0.4rem 0rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input__control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input__control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import "./Input.css";
|
||||||
|
|
||||||
export default function Input({
|
export default function Input({
|
||||||
label,
|
label,
|
||||||
type = "text",
|
type = "text",
|
||||||
@@ -7,12 +9,12 @@ export default function Input({
|
|||||||
step,
|
step,
|
||||||
min,
|
min,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
className = "",
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label style={{ display: "block", marginBottom: "1rem" }}>
|
<label className={`form-field ${className}`}>
|
||||||
<div style={{ marginBottom: "0.25rem", fontSize: "0.9rem" }}>
|
<div className="input__label">{label}</div>
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -20,12 +22,9 @@ export default function Input({
|
|||||||
step={step}
|
step={step}
|
||||||
min={min}
|
min={min}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
style={{
|
className="input__control"
|
||||||
width: "100%",
|
|
||||||
padding: "0.5rem",
|
|
||||||
fontSize: "1rem",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
167
frontend/src/components/Navbar.css
Normal file
167
frontend/src/components/Navbar.css
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Left Group: Brand + Links ───────────────────────────── */
|
||||||
|
|
||||||
|
.navbar__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__brand-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--color-brand);
|
||||||
|
margin-right: 2rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__links {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__link {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.425rem;
|
||||||
|
font-weight: 200;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
background-color: transparent;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__link--active {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Right Group: Email + Logout + Hamburger ─────────────── */
|
||||||
|
|
||||||
|
.navbar__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user {
|
||||||
|
font-size: 1.275rem;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link:hover {
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link--mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__logout {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-size: 1.275rem;
|
||||||
|
font-weight: 400;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hamburger Icon (hidden on desktop) ──────────────────── */
|
||||||
|
|
||||||
|
.navbar__hamburger {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__hamburger-bar {
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 3px;
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive: Tablet & below (≤ 768px) ────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.navbar {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__brand-group {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__brand {
|
||||||
|
margin-right: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link--mobile {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link--desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__logout {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__hamburger {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive: Mobile (≤ 480px) ────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.navbar {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__brand {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link--mobile {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,85 +1,71 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import NavPanel from "./navPanel/NavPanel";
|
||||||
|
import "./Navbar.css";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: "/addresses", label: "Addresses" },
|
{ to: "/addresses", label: "Addresses" },
|
||||||
{ to: "/alerts", label: "Configure Alerts" },
|
{ to: "/alerts", label: "Configure Alerts" },
|
||||||
{ to: "/history", label: "Alert History" },
|
{ to: "/alertevents", label: "Alert Event History" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { currentUser, logout } = useAuth();
|
const { currentUser, logout } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
|
||||||
|
|
||||||
if (!currentUser) return null;
|
if (!currentUser) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav style={{
|
<>
|
||||||
display: "flex",
|
<nav className="navbar">
|
||||||
justifyContent: "space-between",
|
<div className="navbar__left">
|
||||||
alignItems: "center",
|
<div className="navbar__brand-group">
|
||||||
padding: "1rem 2rem",
|
<span className="navbar__brand">Koin Ping</span>
|
||||||
marginBottom: "1.5rem",
|
<Link to="/account" className="navbar__user-link navbar__user-link--mobile">
|
||||||
backgroundColor: "#1a1a1a",
|
{currentUser.email}
|
||||||
borderBottom: "1px solid #333",
|
</Link>
|
||||||
}}>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
<div className="navbar__links">
|
||||||
<span style={{
|
{navLinks.map(({ to, label }) => {
|
||||||
fontWeight: 700,
|
const isActive = location.pathname === to;
|
||||||
fontSize: "1.1rem",
|
return (
|
||||||
color: "#fff",
|
<Link
|
||||||
marginRight: "2rem",
|
key={to}
|
||||||
letterSpacing: "0.5px",
|
to={to}
|
||||||
}}>
|
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
|
||||||
Koin Ping
|
>
|
||||||
</span>
|
{label}
|
||||||
|
</Link>
|
||||||
<div style={{ display: "flex", gap: "0.25rem" }}>
|
);
|
||||||
{navLinks.map(({ to, label }) => {
|
})}
|
||||||
const isActive = location.pathname === to;
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "1.25rem" }}>
|
<div className="navbar__right">
|
||||||
<span style={{ fontSize: "1.275rem", color: "#777" }}>
|
<Link to="/account" className="navbar__user navbar__user-link navbar__user-link--desktop">
|
||||||
{currentUser.email}
|
{currentUser.email}
|
||||||
</span>
|
</Link>
|
||||||
<button
|
<button onClick={logout} className="navbar__logout">
|
||||||
onClick={logout}
|
Logout
|
||||||
style={{
|
</button>
|
||||||
padding: "0.4rem 1rem",
|
<button
|
||||||
fontSize: "1.275rem",
|
className="navbar__hamburger"
|
||||||
fontWeight: 400,
|
onClick={() => setIsNavPanelOpen(true)}
|
||||||
backgroundColor: "transparent",
|
aria-label="Open navigation menu"
|
||||||
color: "#999",
|
>
|
||||||
border: "1px solid #444",
|
<span className="navbar__hamburger-bar" />
|
||||||
borderRadius: "6px",
|
<span className="navbar__hamburger-bar" />
|
||||||
cursor: "pointer",
|
<span className="navbar__hamburger-bar" />
|
||||||
transition: "all 0.15s ease",
|
</button>
|
||||||
}}
|
</div>
|
||||||
>
|
</nav>
|
||||||
Logout
|
|
||||||
</button>
|
<NavPanel
|
||||||
</div>
|
isOpen={isNavPanelOpen}
|
||||||
</nav>
|
onClose={() => setIsNavPanelOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
120
frontend/src/components/TierPicker.css
Normal file
120
frontend/src/components/TierPicker.css
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
.tier-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem 1.25rem;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__card--highlighted {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__card--selected {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__name {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__price {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__amount {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__period {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__features {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
text-align: left;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__feature {
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__feature--disabled {
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__check {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__dash {
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__btn:hover {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__btn--selected {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tier-picker {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
frontend/src/components/TierPicker.jsx
Normal file
96
frontend/src/components/TierPicker.jsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import "./TierPicker.css";
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{
|
||||||
|
id: "free",
|
||||||
|
name: "Trial Monitoring",
|
||||||
|
price: "$0",
|
||||||
|
period: "",
|
||||||
|
features: [
|
||||||
|
"Monitor 1 blockchain address 24/7",
|
||||||
|
"Configure alerts to fire on trigger events",
|
||||||
|
"1 transaction alert type per trigger (email digest)",
|
||||||
|
],
|
||||||
|
disabledFeatures: [
|
||||||
|
"Real-time Discord alerts",
|
||||||
|
"Real-time Telegram alerts",
|
||||||
|
"Real-time Slack alerts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "premium",
|
||||||
|
name: "Premium Monitoring",
|
||||||
|
price: "$1.99",
|
||||||
|
period: "/month",
|
||||||
|
features: [
|
||||||
|
"Monitor 3 blockchain addresses",
|
||||||
|
"Configure alerts to fire on trigger events",
|
||||||
|
"Daily email digest alert",
|
||||||
|
"Real-time Discord alerts",
|
||||||
|
"Real-time Telegram alerts",
|
||||||
|
"2 transaction alert types per monitored address",
|
||||||
|
],
|
||||||
|
disabledFeatures: [
|
||||||
|
"Real-time Slack alerts",
|
||||||
|
],
|
||||||
|
highlighted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: "Professional Monitoring",
|
||||||
|
price: "$11.99",
|
||||||
|
period: "/month",
|
||||||
|
features: [
|
||||||
|
"Monitor unlimited blockchain addresses",
|
||||||
|
"Configure alerts to fire on trigger events",
|
||||||
|
"Daily email digest alert",
|
||||||
|
"Real-time Discord alerts",
|
||||||
|
"Real-time Telegram alerts",
|
||||||
|
"Real-time Slack alerts",
|
||||||
|
"Unlimited transaction alert types per monitored address",
|
||||||
|
],
|
||||||
|
disabledFeatures: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TierPicker({ onSelect, selectedTier }) {
|
||||||
|
return (
|
||||||
|
<div className="tier-picker">
|
||||||
|
{TIERS.map((tier) => (
|
||||||
|
<div
|
||||||
|
key={tier.id}
|
||||||
|
className={`tier-picker__card${tier.highlighted ? " tier-picker__card--highlighted" : ""}${selectedTier === tier.id ? " tier-picker__card--selected" : ""}`}
|
||||||
|
>
|
||||||
|
{tier.highlighted && (
|
||||||
|
<div className="tier-picker__badge">Most Popular</div>
|
||||||
|
)}
|
||||||
|
<h3 className="tier-picker__name">{tier.name}</h3>
|
||||||
|
<div className="tier-picker__price">
|
||||||
|
<span className="tier-picker__amount">{tier.price}</span>
|
||||||
|
{tier.period && (
|
||||||
|
<span className="tier-picker__period">{tier.period}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="tier-picker__features">
|
||||||
|
{tier.features.map((f) => (
|
||||||
|
<li key={f} className="tier-picker__feature">
|
||||||
|
<span className="tier-picker__check">✓</span> {f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{tier.disabledFeatures.map((f) => (
|
||||||
|
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
|
||||||
|
<span className="tier-picker__dash">—</span> {f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
|
||||||
|
onClick={() => onSelect(tier.id)}
|
||||||
|
>
|
||||||
|
{selectedTier === tier.id ? "Selected" : "Select"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/components/UpgradeBanner.css
Normal file
32
frontend/src/components/UpgradeBanner.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
.upgrade-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: #3a2e00;
|
||||||
|
border: 1px solid #aa7700;
|
||||||
|
color: #ffcc44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-banner__message {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-banner__link {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #aa7700;
|
||||||
|
color: #ffcc44;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-banner__link:hover {
|
||||||
|
background-color: #4a3800;
|
||||||
|
}
|
||||||
18
frontend/src/components/UpgradeBanner.jsx
Normal file
18
frontend/src/components/UpgradeBanner.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import "./UpgradeBanner.css";
|
||||||
|
|
||||||
|
export default function UpgradeBanner({ message, linkTo = "/subscribe?upgrade=true" }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="upgrade-banner">
|
||||||
|
<span className="upgrade-banner__message">{message}</span>
|
||||||
|
<button
|
||||||
|
className="upgrade-banner__link"
|
||||||
|
onClick={() => navigate(linkTo)}
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
frontend/src/components/navPanel/NavPanel.css
Normal file
78
frontend/src/components/navPanel/NavPanel.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/* ── Overlay Backdrop ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav-panel__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.45);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel__overlay--visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Slide-out Panel ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 260px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
z-index: 950;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nav Links ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav-panel__nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel__link {
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 200;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel__link--active {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border-left-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Logout Button ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav-panel__logout {
|
||||||
|
margin: 1rem 1.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 400;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
52
frontend/src/components/navPanel/NavPanel.jsx
Normal file
52
frontend/src/components/navPanel/NavPanel.jsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import "./NavPanel.css";
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ to: "/addresses", label: "Addresses" },
|
||||||
|
{ to: "/alerts", label: "Configure Alerts" },
|
||||||
|
{ to: "/alertevents", label: "Alert Event History" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NavPanel({ isOpen, onClose }) {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const handleNavClick = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
onClose();
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`nav-panel__overlay${isOpen ? " nav-panel__overlay--visible" : ""}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<aside className={`nav-panel${isOpen ? " nav-panel--open" : ""}`}>
|
||||||
|
<nav className="nav-panel__nav">
|
||||||
|
{navLinks.map(({ to, label }) => {
|
||||||
|
const isActive = location.pathname === to;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={`nav-panel__link${isActive ? " nav-panel__link--active" : ""}`}
|
||||||
|
onClick={handleNavClick}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<button onClick={handleLogout} className="nav-panel__logout">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* AuthContext - Firebase Authentication State Management
|
* AuthContext - Firebase Authentication State Management
|
||||||
*
|
*
|
||||||
* Provides authentication state and methods throughout the app
|
* Provides authentication state, tier info, and methods throughout the app
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
createUserWithEmailAndPassword,
|
createUserWithEmailAndPassword,
|
||||||
signInWithEmailAndPassword,
|
signInWithEmailAndPassword,
|
||||||
@@ -12,9 +12,16 @@ import {
|
|||||||
onAuthStateChanged,
|
onAuthStateChanged,
|
||||||
} from "firebase/auth";
|
} from "firebase/auth";
|
||||||
import { auth } from "../firebase/config";
|
import { auth } from "../firebase/config";
|
||||||
|
import { getAccount } from "../api/account";
|
||||||
|
|
||||||
const AuthContext = createContext();
|
const AuthContext = createContext();
|
||||||
|
|
||||||
|
const DEFAULT_TIER_LIMITS = {
|
||||||
|
max_addresses: 1,
|
||||||
|
max_alert_types: 1,
|
||||||
|
allowed_channels: ["email"],
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to access auth context
|
* Hook to access auth context
|
||||||
* @returns {Object} Auth context value
|
* @returns {Object} Auth context value
|
||||||
@@ -28,16 +35,30 @@ export function useAuth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AuthProvider - Wraps app and provides auth state
|
* AuthProvider - Wraps app and provides auth state + tier info
|
||||||
*/
|
*/
|
||||||
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);
|
||||||
|
|
||||||
/**
|
const [userTier, setUserTier] = useState("free");
|
||||||
* Sign up with email and password
|
const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS);
|
||||||
*/
|
const [addressCount, setAddressCount] = useState(0);
|
||||||
|
const [subscriptionStatus, setSubscriptionStatus] = useState("none");
|
||||||
|
|
||||||
|
const refreshAccount = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await getAccount();
|
||||||
|
setUserTier(data.subscription_tier || "free");
|
||||||
|
setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS);
|
||||||
|
setAddressCount(data.address_count || 0);
|
||||||
|
setSubscriptionStatus(data.subscription_status || "none");
|
||||||
|
} catch {
|
||||||
|
// account fetch can fail during onboarding before subscription is active
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function signup(email, password) {
|
async function signup(email, password) {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -53,9 +74,6 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Log in with email and password
|
|
||||||
*/
|
|
||||||
async function login(email, password) {
|
async function login(email, password) {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -71,12 +89,13 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Log out current user
|
|
||||||
*/
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setUserTier("free");
|
||||||
|
setTierLimits(DEFAULT_TIER_LIMITS);
|
||||||
|
setAddressCount(0);
|
||||||
|
setSubscriptionStatus("none");
|
||||||
await signOut(auth);
|
await signOut(auth);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -84,18 +103,19 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
||||||
|
if (user) {
|
||||||
|
refreshAccount();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup subscription
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, []);
|
}, [refreshAccount]);
|
||||||
|
|
||||||
|
const isSubscribed =
|
||||||
|
subscriptionStatus === "active" || subscriptionStatus === "trialing";
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
currentUser,
|
currentUser,
|
||||||
@@ -104,6 +124,12 @@ export function AuthProvider({ children }) {
|
|||||||
logout,
|
logout,
|
||||||
error,
|
error,
|
||||||
loading,
|
loading,
|
||||||
|
userTier,
|
||||||
|
tierLimits,
|
||||||
|
addressCount,
|
||||||
|
subscriptionStatus,
|
||||||
|
isSubscribed,
|
||||||
|
refreshAccount,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,43 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-primary: #0066cc;
|
||||||
|
--color-primary-hover: #0077ee;
|
||||||
|
--color-brand: #e62525;
|
||||||
|
--color-success: #28a745;
|
||||||
|
--color-success-text: #00ff00;
|
||||||
|
--color-success-bg: #00ff0020;
|
||||||
|
--color-success-border: #00ff00;
|
||||||
|
--color-error: #ff6666;
|
||||||
|
--color-error-bg: #ff000020;
|
||||||
|
--color-error-border: #ff0000;
|
||||||
|
--color-warning: #ffcc44;
|
||||||
|
--color-warning-bg: #3a2e00;
|
||||||
|
--color-warning-border: #aa7700;
|
||||||
|
--color-danger: #cc6666;
|
||||||
|
--color-danger-border: #884444;
|
||||||
|
|
||||||
|
--color-bg: #1a1a1a;
|
||||||
|
--color-bg-elevated: #242424;
|
||||||
|
--color-bg-card: #333;
|
||||||
|
--color-bg-section: #2a2a2a;
|
||||||
|
|
||||||
|
--color-border: #444;
|
||||||
|
--color-border-light: #333;
|
||||||
|
--color-border-section: #3a3a3a;
|
||||||
|
|
||||||
|
--color-text: white;
|
||||||
|
--color-text-muted: #b3b3b3;
|
||||||
|
--color-text-dimmed: #808080;
|
||||||
|
--color-text-subtle: #999;
|
||||||
|
--color-text-label: #ccc;
|
||||||
|
|
||||||
|
--radius-sm: 3px;
|
||||||
|
--radius-md: 4px;
|
||||||
|
--radius-lg: 6px;
|
||||||
|
--radius-xl: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -21,13 +59,378 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: 'Nunito', sans-serif;
|
||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, select, textarea, button {
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
button {
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: 'Nunito', sans-serif;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Page Layouts ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page--wide {
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards & Sections ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--color-bg-section);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border-section);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form Controls ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field--last {
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control--mono {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--success {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--color-danger-border);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--sm {
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--lg {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Feedback Alerts ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert--error {
|
||||||
|
background-color: var(--color-error-bg);
|
||||||
|
border: 1px solid var(--color-error-border);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert--success {
|
||||||
|
background-color: var(--color-success-bg);
|
||||||
|
border: 1px solid var(--color-success-border);
|
||||||
|
color: var(--color-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert--warning {
|
||||||
|
background-color: var(--color-warning-bg);
|
||||||
|
border: 1px solid var(--color-warning-border);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lists ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.list-unstyled {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item--card {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typography Helpers ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-dimmed {
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-mono {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: 1.035rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-xs {
|
||||||
|
font-size: 0.978rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Flex Utilities ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex--between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex--center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex--column {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex--wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-xs {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-sm {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-md {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-lg {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Spacing Helpers ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mb-0 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-sm {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-md {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-lg {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-xl {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-0 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-md {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-white {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-white-sm {
|
||||||
|
color: white;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive Breakpoints ──────────────────────────────── */
|
||||||
|
/* Mobile: <= 480px */
|
||||||
|
/* Tablet: 481px – 768px */
|
||||||
|
/* Laptop: 769px – 1024px */
|
||||||
|
/* Desktop: >= 1025px (default styles above) */
|
||||||
|
|
||||||
|
/* Laptop & below */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.page--wide {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet & below */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
html {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--lg {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tier-locked: greyed out, non-interactive overlay for features above the user's plan */
|
||||||
|
.tier-locked {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: grayscale(40%);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
html {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--lg {
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import AddressForm from "../components/AddressForm";
|
|
||||||
import { getAddresses, createAddress } from "../api/addresses";
|
|
||||||
|
|
||||||
export default function Addresses() {
|
|
||||||
const [addresses, setAddresses] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Load addresses on mount
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchAddresses() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await getAddresses();
|
|
||||||
setAddresses(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to fetch addresses:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAddresses();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle new address submission
|
|
||||||
async function handleAddressSubmit(data) {
|
|
||||||
try {
|
|
||||||
const newAddress = await createAddress(data);
|
|
||||||
// Append new address to state
|
|
||||||
setAddresses((prev) => [...prev, newAddress]);
|
|
||||||
setError(null); // Clear any previous errors
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to create address:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
|
||||||
<h1>Tracked Addresses</h1>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "2rem" }}>
|
|
||||||
<AddressForm onSubmit={handleAddressSubmit} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2>Existing Addresses</h2>
|
|
||||||
{loading && <p>Loading addresses...</p>}
|
|
||||||
{error && <p style={{ color: "red" }}>Error: {error}</p>}
|
|
||||||
{!loading && !error && addresses.length === 0 && (
|
|
||||||
<p style={{ color: "#808080" }}>
|
|
||||||
No addresses tracked yet. Add one above to get started.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{addresses.length > 0 && (
|
|
||||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
|
||||||
{addresses.map((addr, index) => (
|
|
||||||
<li
|
|
||||||
key={addr.id || index}
|
|
||||||
style={{
|
|
||||||
padding: "1rem",
|
|
||||||
marginBottom: "0.5rem",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "4px",
|
|
||||||
backgroundColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontWeight: "bold",
|
|
||||||
marginBottom: "0.25rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{addr.label || "Unlabeled"}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "1.035rem",
|
|
||||||
color: "#b3b3b3",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{addr.address}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { getAlertEvents } from "../api/alertEvents";
|
|
||||||
|
|
||||||
export default function AlertHistory() {
|
|
||||||
const [alertEvents, setAlertEvents] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Fetch alert events on mount
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchAlertEvents() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await getAlertEvents();
|
|
||||||
setAlertEvents(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to fetch alert events:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAlertEvents();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div style={{ padding: "2rem" }}>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: "2rem", color: "red" }}>Error: {error}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
|
||||||
<h1>Recent Alerts</h1>
|
|
||||||
|
|
||||||
{alertEvents.length === 0 ? (
|
|
||||||
<p style={{ color: "#808080" }}>No alerts yet</p>
|
|
||||||
) : (
|
|
||||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
|
||||||
{alertEvents.map((event) => (
|
|
||||||
<li
|
|
||||||
key={event.id}
|
|
||||||
style={{
|
|
||||||
padding: "1rem",
|
|
||||||
marginBottom: "0.75rem",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "4px",
|
|
||||||
backgroundColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: "0.5rem" }}>
|
|
||||||
{event.message}
|
|
||||||
</div>
|
|
||||||
{event.address_label && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "1.035rem",
|
|
||||||
color: "#808080",
|
|
||||||
marginBottom: "0.25rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Address: {event.address_label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<small style={{ color: "#b3b3b3" }}>
|
|
||||||
{formatTimestamp(event.timestamp)}
|
|
||||||
</small>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to format timestamp for display
|
|
||||||
function formatTimestamp(timestamp) {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
return date.toLocaleString();
|
|
||||||
}
|
|
||||||
@@ -1,842 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import AlertForm from "../components/AlertForm";
|
|
||||||
import Button from "../components/Button";
|
|
||||||
import { getAddresses } from "../api/addresses";
|
|
||||||
import {
|
|
||||||
getAlerts,
|
|
||||||
createAlert,
|
|
||||||
updateAlertStatus,
|
|
||||||
deleteAlert,
|
|
||||||
} from "../api/alerts";
|
|
||||||
import {
|
|
||||||
getNotificationConfig,
|
|
||||||
updateNotificationConfig,
|
|
||||||
testNotificationChannels,
|
|
||||||
setupEmail,
|
|
||||||
sendEmailDigest,
|
|
||||||
} from "../api/notificationConfig";
|
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
width: "100%",
|
|
||||||
padding: "0.5rem",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "white",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelStyle = {
|
|
||||||
display: "block",
|
|
||||||
marginBottom: "0.25rem",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "#ccc",
|
|
||||||
};
|
|
||||||
|
|
||||||
const helpLinkStyle = { color: "#0066cc", fontSize: "0.85rem" };
|
|
||||||
|
|
||||||
const sectionStyle = {
|
|
||||||
marginBottom: "1.5rem",
|
|
||||||
padding: "1rem",
|
|
||||||
backgroundColor: "#2a2a2a",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #3a3a3a",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Alerts() {
|
|
||||||
const [addresses, setAddresses] = useState([]);
|
|
||||||
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
|
||||||
const [alerts, setAlerts] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Notification config state
|
|
||||||
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
|
||||||
const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
|
|
||||||
const [telegramBotToken, setTelegramBotToken] = useState("");
|
|
||||||
const [telegramChatId, setTelegramChatId] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
|
|
||||||
const [notificationLoading, setNotificationLoading] = useState(false);
|
|
||||||
const [notificationError, setNotificationError] = useState(null);
|
|
||||||
const [notificationSuccess, setNotificationSuccess] = useState(null);
|
|
||||||
const [testingChannels, setTestingChannels] = useState(false);
|
|
||||||
const [settingUpEmail, setSettingUpEmail] = useState(false);
|
|
||||||
const [sendingDigest, setSendingDigest] = useState(false);
|
|
||||||
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const addressData = await getAddresses();
|
|
||||||
setAddresses(addressData);
|
|
||||||
if (addressData.length > 0) {
|
|
||||||
setSelectedAddressId(addressData[0].id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const configData = await getNotificationConfig();
|
|
||||||
setNotificationEnabled(
|
|
||||||
configData.notification_enabled !== false,
|
|
||||||
);
|
|
||||||
setDiscordWebhookUrl(configData.discord_webhook_url || "");
|
|
||||||
setTelegramBotToken(configData.telegram_bot_token || "");
|
|
||||||
setTelegramChatId(configData.telegram_chat_id || "");
|
|
||||||
setEmail(configData.email || "");
|
|
||||||
setSlackWebhookUrl(configData.slack_webhook_url || "");
|
|
||||||
|
|
||||||
const hasSaved =
|
|
||||||
!!configData.discord_webhook_url ||
|
|
||||||
!!configData.telegram_bot_token ||
|
|
||||||
!!configData.telegram_chat_id ||
|
|
||||||
!!configData.email ||
|
|
||||||
!!configData.slack_webhook_url;
|
|
||||||
setHasExistingConfig(hasSaved);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to fetch data:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedAddressId) {
|
|
||||||
setAlerts([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAlerts() {
|
|
||||||
try {
|
|
||||||
const data = await getAlerts(selectedAddressId);
|
|
||||||
setAlerts(data);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to fetch alerts:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAlerts();
|
|
||||||
}, [selectedAddressId]);
|
|
||||||
|
|
||||||
async function handleAlertSubmit(data) {
|
|
||||||
if (!selectedAddressId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newAlert = await createAlert(selectedAddressId, data);
|
|
||||||
setAlerts((prev) => [...prev, newAlert]);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to create alert:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggleAlert(alertId, currentStatus) {
|
|
||||||
try {
|
|
||||||
const updated = await updateAlertStatus(alertId, !currentStatus);
|
|
||||||
setAlerts((prev) =>
|
|
||||||
prev.map((alert) => (alert.id === alertId ? updated : alert)),
|
|
||||||
);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to update alert:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteAlert(alertId) {
|
|
||||||
try {
|
|
||||||
await deleteAlert(alertId);
|
|
||||||
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
console.error("Failed to delete alert:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveNotificationConfig() {
|
|
||||||
if (hasExistingConfig) {
|
|
||||||
const confirmed = window.confirm(
|
|
||||||
"This will overwrite your previously saved notification settings. Continue?",
|
|
||||||
);
|
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setNotificationLoading(true);
|
|
||||||
setNotificationError(null);
|
|
||||||
setNotificationSuccess(null);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
notification_enabled: notificationEnabled,
|
|
||||||
discord_webhook_url: discordWebhookUrl || null,
|
|
||||||
telegram_bot_token: telegramBotToken || null,
|
|
||||||
telegram_chat_id: telegramChatId || null,
|
|
||||||
email: email || null,
|
|
||||||
slack_webhook_url: slackWebhookUrl || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await updateNotificationConfig(config);
|
|
||||||
setHasExistingConfig(true);
|
|
||||||
setNotificationSuccess("Notification settings saved!");
|
|
||||||
setTimeout(() => setNotificationSuccess(null), 3000);
|
|
||||||
} catch (err) {
|
|
||||||
setNotificationError(err.message);
|
|
||||||
console.error("Failed to save notification config:", err);
|
|
||||||
} finally {
|
|
||||||
setNotificationLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTestChannels() {
|
|
||||||
try {
|
|
||||||
setTestingChannels(true);
|
|
||||||
setNotificationError(null);
|
|
||||||
setNotificationSuccess(null);
|
|
||||||
|
|
||||||
const data = await testNotificationChannels();
|
|
||||||
const results = data.results || [];
|
|
||||||
|
|
||||||
const failed = results.filter((r) => !r.success);
|
|
||||||
const succeeded = results.filter((r) => r.success);
|
|
||||||
|
|
||||||
if (failed.length === 0 && succeeded.length > 0) {
|
|
||||||
setNotificationSuccess(
|
|
||||||
`Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
|
|
||||||
);
|
|
||||||
} else if (failed.length > 0 && succeeded.length > 0) {
|
|
||||||
setNotificationSuccess(
|
|
||||||
`Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
|
|
||||||
);
|
|
||||||
} else if (failed.length > 0) {
|
|
||||||
setNotificationError(
|
|
||||||
`Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setNotificationSuccess(null);
|
|
||||||
setNotificationError(null);
|
|
||||||
}, 6000);
|
|
||||||
} catch (err) {
|
|
||||||
setNotificationError(err.message);
|
|
||||||
} finally {
|
|
||||||
setTestingChannels(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSetupEmail() {
|
|
||||||
try {
|
|
||||||
setSettingUpEmail(true);
|
|
||||||
setNotificationError(null);
|
|
||||||
setNotificationSuccess(null);
|
|
||||||
|
|
||||||
await handleSaveNotificationConfig();
|
|
||||||
|
|
||||||
const result = await setupEmail();
|
|
||||||
setNotificationSuccess(
|
|
||||||
result.message || "Confirmation email sent!",
|
|
||||||
);
|
|
||||||
setTimeout(() => setNotificationSuccess(null), 5000);
|
|
||||||
} catch (err) {
|
|
||||||
setNotificationError(err.message);
|
|
||||||
} finally {
|
|
||||||
setSettingUpEmail(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSendDigest() {
|
|
||||||
try {
|
|
||||||
setSendingDigest(true);
|
|
||||||
setNotificationError(null);
|
|
||||||
setNotificationSuccess(null);
|
|
||||||
|
|
||||||
const result = await sendEmailDigest();
|
|
||||||
setNotificationSuccess(result.message || "Digest email sent!");
|
|
||||||
setTimeout(() => setNotificationSuccess(null), 5000);
|
|
||||||
} catch (err) {
|
|
||||||
setNotificationError(err.message);
|
|
||||||
} finally {
|
|
||||||
setSendingDigest(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedAddress = addresses.find((a) => a.id === selectedAddressId);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div style={{ padding: "2rem" }}>Loading addresses...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addresses.length === 0) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: "2rem" }}>
|
|
||||||
<p>
|
|
||||||
No addresses tracked yet. Add an address first to create
|
|
||||||
alerts.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ maxWidth: "1400px", margin: "0 auto", padding: "2rem" }}>
|
|
||||||
<h1 style={{ marginBottom: "2rem" }}>
|
|
||||||
Alert Rules & Notifications
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 1fr",
|
|
||||||
gap: "2rem",
|
|
||||||
alignItems: "start",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* LEFT COLUMN: Alert Rules */}
|
|
||||||
<div>
|
|
||||||
<h2 style={{ marginTop: 0 }}>Alert Rules</h2>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "2rem" }}>
|
|
||||||
<label
|
|
||||||
style={{ display: "block", marginBottom: "0.5rem" }}
|
|
||||||
>
|
|
||||||
<strong>Select Address:</strong>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedAddressId || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedAddressId(Number(e.target.value))
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "0.5rem",
|
|
||||||
fontSize: "1rem",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{addresses.map((addr) => (
|
|
||||||
<option key={addr.id} value={addr.id}>
|
|
||||||
{addr.label || "Unlabeled"} - {addr.address}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedAddress && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "1rem",
|
|
||||||
marginBottom: "2rem",
|
|
||||||
backgroundColor: "#333",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid #444",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "1.035rem",
|
|
||||||
color: "#b3b3b3",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Managing alerts for:
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontWeight: "bold",
|
|
||||||
marginTop: "0.25rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedAddress.label || "Unlabeled"}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "1.035rem",
|
|
||||||
color: "#b3b3b3",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedAddress.address}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "2rem" }}>
|
|
||||||
<h3>Create New Alert</h3>
|
|
||||||
<AlertForm onSubmit={handleAlertSubmit} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3>Active Alert Rules</h3>
|
|
||||||
{error && (
|
|
||||||
<p style={{ color: "red" }}>
|
|
||||||
Error: {error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{alerts.length === 0 ? (
|
|
||||||
<p style={{ color: "#666" }}>
|
|
||||||
No alert rules defined yet. Create one
|
|
||||||
above.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul
|
|
||||||
style={{
|
|
||||||
listStyle: "none",
|
|
||||||
padding: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{alerts.map((alert) => (
|
|
||||||
<li
|
|
||||||
key={alert.id}
|
|
||||||
style={{
|
|
||||||
padding: "1rem",
|
|
||||||
marginBottom: "0.5rem",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "4px",
|
|
||||||
backgroundColor: "#333",
|
|
||||||
opacity: alert.enabled
|
|
||||||
? 1
|
|
||||||
: 0.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent:
|
|
||||||
"space-between",
|
|
||||||
alignItems:
|
|
||||||
"flex-start",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontWeight:
|
|
||||||
"bold",
|
|
||||||
marginBottom:
|
|
||||||
"0.25rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatAlertType(
|
|
||||||
alert.type,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{alert.threshold && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize:
|
|
||||||
"1.035rem",
|
|
||||||
color: "#b3b3b3",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Threshold:{" "}
|
|
||||||
{
|
|
||||||
alert.threshold
|
|
||||||
}{" "}
|
|
||||||
ETH
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize:
|
|
||||||
"0.978rem",
|
|
||||||
color: "#808080",
|
|
||||||
marginTop:
|
|
||||||
"0.25rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Status:{" "}
|
|
||||||
{alert.enabled
|
|
||||||
? "Enabled"
|
|
||||||
: "Disabled"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
handleToggleAlert(
|
|
||||||
alert.id,
|
|
||||||
alert.enabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{alert.enabled
|
|
||||||
? "Disable"
|
|
||||||
: "Enable"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
handleDeleteAlert(
|
|
||||||
alert.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RIGHT COLUMN: Notification Settings */}
|
|
||||||
<div>
|
|
||||||
<h2 style={{ marginTop: 0 }}>Notification Settings</h2>
|
|
||||||
|
|
||||||
{notificationSuccess && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "0.75rem",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
backgroundColor: "#00ff0020",
|
|
||||||
border: "1px solid #00ff00",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#00ff00",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{notificationSuccess}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{notificationError && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "0.75rem",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
backgroundColor: "#ff000020",
|
|
||||||
border: "1px solid #ff0000",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#ff6666",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{notificationError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Master toggle */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: "1.5rem",
|
|
||||||
padding: "1rem",
|
|
||||||
backgroundColor: "#333",
|
|
||||||
borderRadius: "4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={notificationEnabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNotificationEnabled(e.target.checked)
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
marginRight: "0.5rem",
|
|
||||||
width: "18px",
|
|
||||||
height: "18px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{ fontWeight: "bold" }}>
|
|
||||||
Enable Notifications
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "0.978rem",
|
|
||||||
color: "#808080",
|
|
||||||
marginTop: "0.5rem",
|
|
||||||
marginLeft: "26px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Master switch for all notification channels
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* All channel settings — hidden when master toggle is off */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: notificationEnabled ? "block" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Telegram */}
|
|
||||||
<div style={sectionStyle}>
|
|
||||||
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
|
||||||
Telegram
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "0.75rem" }}>
|
|
||||||
<label style={labelStyle}>Bot Token</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={telegramBotToken}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTelegramBotToken(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "0.5rem" }}>
|
|
||||||
<label style={labelStyle}>Chat ID</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={telegramChatId}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTelegramChatId(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="-1001234567890"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={helpLinkStyle}
|
|
||||||
>
|
|
||||||
How to create a Telegram bot & get your Chat ID
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div style={sectionStyle}>
|
|
||||||
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
|
||||||
Email
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "0.75rem" }}>
|
|
||||||
<label style={labelStyle}>Email Address</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
color: "#808080",
|
|
||||||
marginBottom: "0.75rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Alert notifications and digests will be sent to
|
|
||||||
this address
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={handleSetupEmail}
|
|
||||||
disabled={settingUpEmail || !email}
|
|
||||||
style={{
|
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
backgroundColor:
|
|
||||||
settingUpEmail || !email
|
|
||||||
? "#333"
|
|
||||||
: "#0066cc",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor:
|
|
||||||
settingUpEmail || !email
|
|
||||||
? "not-allowed"
|
|
||||||
: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{settingUpEmail
|
|
||||||
? "Setting up..."
|
|
||||||
: "Verify Email"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSendDigest}
|
|
||||||
disabled={sendingDigest || !email}
|
|
||||||
style={{
|
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
backgroundColor:
|
|
||||||
sendingDigest || !email
|
|
||||||
? "#333"
|
|
||||||
: "#6c757d",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor:
|
|
||||||
sendingDigest || !email
|
|
||||||
? "not-allowed"
|
|
||||||
: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sendingDigest
|
|
||||||
? "Sending..."
|
|
||||||
: "Send Digest Now"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Discord */}
|
|
||||||
<div style={sectionStyle}>
|
|
||||||
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
|
||||||
Discord
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "0.5rem" }}>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
Discord Webhook URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={discordWebhookUrl}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDiscordWebhookUrl(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={helpLinkStyle}
|
|
||||||
>
|
|
||||||
How to get a Discord webhook URL
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Slack */}
|
|
||||||
<div style={sectionStyle}>
|
|
||||||
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
|
||||||
Slack
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "0.5rem" }}>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
Slack Webhook URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={slackWebhookUrl}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSlackWebhookUrl(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="https://hooks.slack.com/services/..."
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://api.slack.com/messaging/webhooks"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={helpLinkStyle}
|
|
||||||
>
|
|
||||||
How to set up Slack Incoming Webhooks
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save & Test buttons at the bottom */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "0.75rem",
|
|
||||||
marginTop: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveNotificationConfig}
|
|
||||||
disabled={notificationLoading}
|
|
||||||
style={{
|
|
||||||
padding: "0.75rem 1.5rem",
|
|
||||||
fontSize: "1rem",
|
|
||||||
backgroundColor: notificationLoading
|
|
||||||
? "#333"
|
|
||||||
: "#0066cc",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: notificationLoading
|
|
||||||
? "not-allowed"
|
|
||||||
: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{notificationLoading
|
|
||||||
? "Saving..."
|
|
||||||
: "Save Settings"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleTestChannels}
|
|
||||||
disabled={testingChannels}
|
|
||||||
style={{
|
|
||||||
padding: "0.75rem 1.5rem",
|
|
||||||
fontSize: "1rem",
|
|
||||||
backgroundColor: testingChannels
|
|
||||||
? "#333"
|
|
||||||
: "#28a745",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: testingChannels
|
|
||||||
? "not-allowed"
|
|
||||||
: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{testingChannels
|
|
||||||
? "Testing..."
|
|
||||||
: "Test All Channels"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAlertType(type) {
|
|
||||||
const labels = {
|
|
||||||
incoming_tx: "Incoming transaction",
|
|
||||||
outgoing_tx: "Outgoing transaction",
|
|
||||||
large_transfer: "Large transfer",
|
|
||||||
balance_below: "Balance below threshold",
|
|
||||||
};
|
|
||||||
return labels[type] || type;
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
/**
|
|
||||||
* Login Page
|
|
||||||
*
|
|
||||||
* Allows existing users to sign in with email and password
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
|
||||||
|
|
||||||
export default function Login() {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const { login } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!email || !password) {
|
|
||||||
setError("Please fill in all fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setError("");
|
|
||||||
setLoading(true);
|
|
||||||
await login(email, password);
|
|
||||||
navigate("/addresses"); // Redirect to main app
|
|
||||||
} catch (err) {
|
|
||||||
setError("Failed to log in: " + err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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={{
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,188 +1,5 @@
|
|||||||
/**
|
import { Navigate } from "react-router-dom";
|
||||||
* Signup Page
|
|
||||||
*
|
|
||||||
* Allows new users to create an account with email and password
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
|
||||||
|
|
||||||
export default function Signup() {
|
export default function Signup() {
|
||||||
const [email, setEmail] = useState("");
|
return <Navigate to="/subscribe" replace />;
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const { signup } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!email || !password || !confirmPassword) {
|
|
||||||
setError("Please fill in all fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
setError("Passwords do not match");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 6) {
|
|
||||||
setError("Password must be at least 6 characters");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setError("");
|
|
||||||
setLoading(true);
|
|
||||||
await signup(email, password);
|
|
||||||
navigate("/addresses"); // Auto-login and redirect
|
|
||||||
} catch (err) {
|
|
||||||
// Firebase-specific error messages
|
|
||||||
if (err.code === "auth/email-already-in-use") {
|
|
||||||
setError("Email already in use. Try logging in instead.");
|
|
||||||
} else if (err.code === "auth/invalid-email") {
|
|
||||||
setError("Invalid email address");
|
|
||||||
} else if (err.code === "auth/weak-password") {
|
|
||||||
setError("Password is too weak");
|
|
||||||
} else {
|
|
||||||
setError("Failed to create account: " + err.message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxWidth: "400px",
|
|
||||||
margin: "4rem auto",
|
|
||||||
padding: "2rem",
|
|
||||||
border: "1px solid #333",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
|
|
||||||
Koin Ping - Sign Up
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "0.75rem",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
backgroundColor: "#ff000020",
|
|
||||||
border: "1px solid #ff0000",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#ff6666",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
|
||||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "0.5rem",
|
|
||||||
fontSize: "1rem",
|
|
||||||
backgroundColor: "#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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user