Compare commits

..

65 Commits

Author SHA1 Message Date
KS Jannette
615dd1dddc Chockfull 2026-03-28 11:10:25 -04:00
KS Jannette
288092e4b4 More 2026-03-28 10:31:14 -04:00
KS Jannette
69a2112df9 update readme 2026-03-28 08:27:06 -04:00
KS Jannette
4ba91c7d9b Stripe integration tweaks 2026-03-10 17:18:54 -04:00
KS Jannette
f9fa7def2b add subscription tiers 2026-03-10 15:22:48 -04:00
S Jannette
7b45c5b2ab Merge pull request #26 from kjannette/add-alert-thresholds
enhanced alert granularity with min/max thresholds
2026-03-06 23:39:15 -05:00
KS Jannette
0fb4dcd7d1 enhanced alert granularity with min/max thresholds 2026-03-06 23:33:12 -05:00
KS Jannette
d3530deb46 m 2026-03-06 03:09:44 -05:00
KS Jannette
aadcdc628e hot 3 2026-03-06 03:08:47 -05:00
KS Jannette
2d4fcefdcf hottie 2026-03-06 03:00:14 -05:00
KS Jannette
81303573fd quick style changes 2026-03-06 02:51:35 -05:00
KS Jannette
76fadf05d9 hot f 2026-03-05 16:14:51 -05:00
KS Jannette
b9e3d8ae82 hotfix 2026-03-05 14:52:35 -05:00
S Jannette
6c5ffc2340 Merge pull request #25 from kjannette/media-queries-2
mobile navigation
2026-03-05 11:04:07 -05:00
KS Jannette
e32aa4b6aa mobile navigation
Some checks are pending
check / check (push) Waiting to run
2026-03-05 11:03:42 -05:00
S Jannette
682c168250 Merge pull request #24 from kjannette/media-queries
media queries
2026-03-05 10:31:18 -05:00
KS Jannette
e5c8f91e3f media queries
Some checks are pending
check / check (push) Waiting to run
2026-03-05 10:29:37 -05:00
S Jannette
f6255ec1f1 Merge pull request #23 from kjannette/account-info-page
Account info page
2026-03-05 08:26:39 -05:00
KS Jannette
4b77e7a4d4 add user account page and associated functionaility to support it
Some checks are pending
check / check (push) Waiting to run
2026-03-05 08:25:24 -05:00
KS Jannette
8f1212813f edits 2026-03-05 07:39:46 -05:00
S Jannette
95ec1a520a Merge pull request #22 from kjannette/frontend-feature-roundup
style
2026-03-05 07:27:41 -05:00
KS Jannette
4b228d9b57 style
Some checks are pending
check / check (push) Waiting to run
2026-03-05 07:19:12 -05:00
S Jannette
08bc3e1ea2 Merge pull request #21 from kjannette/poller-refinements-3
adjust rpc call to eth block timing
2026-03-05 06:51:54 -05:00
KS Jannette
a091d39c08 adjust rpc call to eth block timing
Some checks are pending
check / check (push) Waiting to run
2026-03-05 06:48:11 -05:00
S Jannette
35699a41bd Merge pull request #20 from kjannette/loginpage-style
Loginpage style
2026-03-05 02:18:22 -05:00
KS Jannette
2e96982d3a m
Some checks are pending
check / check (push) Waiting to run
2026-03-05 02:17:11 -05:00
KS Jannette
e48de2d6b6 style tweaks 2026-03-05 02:14:55 -05:00
KS Jannette
9414af8108 adjust css 2026-03-05 02:07:51 -05:00
S Jannette
068ca2f834 Merge pull request #19 from kjannette/more-poller-refinement
Some checks are pending
check / check (push) Waiting to run
Added PermanentError type, IsPermanent() helper.
2026-03-05 00:00:49 -05:00
KS Jannette
7f10bcb7de Added PermanentError type, IsPermanent() helper. Permanent errors are logged and not retried
Some checks are pending
check / check (push) Waiting to run
2026-03-05 00:00:09 -05:00
S Jannette
f91ca33752 Merge pull request #18 from kjannette/cleanup
general cleanup - removed old comments, enforced naming conventions etc
2026-03-04 23:32:01 -05:00
KS Jannette
2c86bba235 general cleanup - removed old comments, enforced naming conventions etc
Some checks are pending
check / check (push) Waiting to run
2026-03-04 23:29:58 -05:00
S Jannette
44dad43f1d Merge pull request #17 from kjannette/poller-tweaks
updated evaluator service -
2026-03-04 23:16:56 -05:00
KS Jannette
2fe0e5b8e9 updated evaluator service - added semaphore.Weighted(5) and sync.WaitGroup etc to cap conncurrent requests; also WaitForNotifications() so caller blocks until notifications finish. jsonrpc.go -- finally, bumped rpcRetryBaseMS from 1000 to 2000 - RPC retries at 2s/4s/8s backoff rate
Some checks are pending
check / check (push) Waiting to run
2026-03-04 23:14:55 -05:00
S Jannette
8f08105246 Merge pull request #16 from kjannette/stripe-2
Stripe 2
2026-03-04 22:43:49 -05:00
KS Jannette
2bc06669e9 config
Some checks are pending
check / check (push) Waiting to run
2026-03-04 22:40:36 -05:00
KS Jannette
a2d1ff9d76 restructure pages dir 2026-03-04 22:07:05 -05:00
S Jannette
dcbf0756d2 Merge pull request #15 from kjannette/stripe
Stripe
2026-03-04 21:39:48 -05:00
KS Jannette
239ea7db85 addition Stripe payment integration work
Some checks are pending
check / check (push) Waiting to run
2026-03-04 21:38:36 -05:00
KS Jannette
9ad414313b Stripe integration 2026-03-04 15:08:37 -05:00
S Jannette
3ee1598478 Merge pull request #14 from kjannette/feat-discrete-accounts
Feat discrete accounts
2026-03-03 19:14:00 -05:00
KS Jannette
91336130b9 discrete user accounts
Some checks are pending
check / check (push) Waiting to run
2026-03-03 19:10:24 -05:00
KS Jannette
e4e859d051 style 2026-03-03 18:39:20 -05:00
S Jannette
6f6a2c6d18 Merge pull request #13 from kjannette/refactor-css
Refactor css
2026-03-02 03:43:34 -05:00
KS Jannette
63eecaaf72 m
Some checks are pending
check / check (push) Waiting to run
2026-03-02 03:42:58 -05:00
KS Jannette
8a5f5db903 refact css sytem 2026-03-02 03:36:21 -05:00
KS Jannette
c2515472b2 more css hottie 2026-03-02 00:34:34 -05:00
S Jannette
5ad51271af Merge pull request #12 from kjannette/migrate-inline-css
Migrate inline css
2026-03-02 00:28:46 -05:00
KS Jannette
b3a08e8f25 css trick
Some checks are pending
check / check (push) Waiting to run
2026-03-02 00:28:13 -05:00
KS Jannette
8716be0201 fudge 2026-03-02 00:09:24 -05:00
KS Jannette
647be874e5 m 2026-03-02 00:09:01 -05:00
KS Jannette
c12c4fe742 add image 2026-03-01 23:14:25 -05:00
S Jannette
5f5253d495 Revise README for clarity and feature description
Updated the README to clarify features and improve readability.
2026-03-01 21:48:53 -05:00
S Jannette
bbe2374703 Merge pull request #11 from kjannette/history-reconfig-possible-bugfix
History reconfig possible bugfix
2026-03-01 20:38:56 -05:00
KS Jannette
205317c920 update docs
Some checks are pending
check / check (push) Waiting to run
2026-03-01 20:35:08 -05:00
KS Jannette
25850825b4 updated types and poller 2026-03-01 20:32:21 -05:00
S Jannette
f01115de86 Merge pull request #10 from kjannette/webhook-configs
webhook configs for users, changes to system
2026-03-01 19:08:42 -05:00
KS Jannette
6735000050 webhook configs for users, changes to system
Some checks are pending
check / check (push) Waiting to run
2026-03-01 19:06:32 -05:00
KS Jannette
42a64fc043 Add multi-step onboarding wizard to replace bare signup form
Introduces a 5-step guided flow (Create Account → Add Wallet → Alert
Rules → Notifications → Done) that takes new users from zero to fully
configured alerts in one linear session. /signup now redirects to
/onboarding; already-onboarded users who revisit /onboarding are
redirected to /addresses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 12:54:55 -05:00
S Jannette
9935817fa8 Merge pull request #9 from kjannette/setupTelegramonfig
Setup telegramonfig
2026-03-01 09:49:16 -05:00
KS Jannette
8d5716bdb7 Add guide for setup of Telegram
Some checks are pending
check / check (push) Waiting to run
2026-03-01 09:48:52 -05:00
KS Jannette
aafe96d9a1 Finish config 2026-03-01 09:40:54 -05:00
S Jannette
93168209b5 Merge pull request #8 from kjannette/audit1
Audit1
2026-03-01 07:55:57 -05:00
S Jannette
6840af1a47 Merge branch 'master' into audit1 2026-03-01 07:55:49 -05:00
S Jannette
c707b82f36 Revise README with new project overview
Updated project description and added overview section.
2026-03-01 07:51:57 -05:00
108 changed files with 6052 additions and 2247 deletions

View File

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

2
.gitignore vendored
View File

@@ -21,7 +21,7 @@ node_modules/
*.key *.key
# Go build artifacts # Go build artifacts
backend-go/bin/ backend/bin/
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll

View File

@@ -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/

View File

@@ -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

View File

@@ -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

View File

@@ -2,6 +2,7 @@
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about. A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about.
# Overview # Overview
Koin Ping observes on-chain activity and notifies users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices. Koin Ping observes on-chain activity and notifies users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
## Getting Started ## Getting Started
@@ -27,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
@@ -37,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
@@ -62,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/
@@ -98,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).
@@ -105,3 +152,5 @@ MIT. See [LICENSE](LICENSE).
## Author ## Author
Steven Jannette Steven Jannette

View File

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

View File

@@ -1,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>

View File

@@ -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

View File

@@ -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 ""
}

View File

@@ -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
}

View File

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

View File

@@ -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 ./...

29
backend/README.md Normal file
View File

@@ -0,0 +1,29 @@
Backend startup quickstarat:
## 1. brew services start postgresql@15
OR
/opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start
## 2. From the backend directory, you have a few options:
## 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.
## 6. ALLIN ONE:
Make dev-all — Runs both the API and poller concurrently.

BIN
backend/api Executable file

Binary file not shown.

View File

@@ -8,13 +8,13 @@ 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/firebase" "github.com/kjannette/koin-ping/backend/internal/firebase"
"github.com/kjannette/koin-ping/backend-go/internal/handlers" "github.com/kjannette/koin-ping/backend/internal/handlers"
"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"
) )
const ( const (
@@ -44,6 +44,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)
@@ -54,12 +55,22 @@ func main() {
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel, cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
) )
addressHandler := handlers.NewAddressHandler(addressModel) addressHandler := handlers.NewAddressHandler(addressModel, userModel)
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel) alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel, userModel)
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel) alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg) notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel) emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel) 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() mux := http.NewServeMux()
b := cfg.APIBasePath // e.g. "/v1" b := cfg.APIBasePath // e.g. "/v1"
@@ -68,45 +79,67 @@ func main() {
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck) mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus) mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
// Authenticated routes — addresses // 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", mux.Handle("POST "+b+"/addresses",
middleware.Authenticate(http.HandlerFunc(addressHandler.Create))) authAndSub(http.HandlerFunc(addressHandler.Create)))
mux.Handle("GET "+b+"/addresses", mux.Handle("GET "+b+"/addresses",
middleware.Authenticate(http.HandlerFunc(addressHandler.List))) authAndSub(http.HandlerFunc(addressHandler.List)))
mux.Handle("DELETE "+b+"/addresses/{addressId}", mux.Handle("DELETE "+b+"/addresses/{addressId}",
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove))) authAndSub(http.HandlerFunc(addressHandler.Remove)))
mux.Handle("PATCH "+b+"/addresses/{addressId}", mux.Handle("PATCH "+b+"/addresses/{addressId}",
middleware.Authenticate(http.HandlerFunc(addressHandler.UpdateLabel))) authAndSub(http.HandlerFunc(addressHandler.UpdateLabel)))
// Authenticated routes for alert rules // Authenticated + subscribed routes alert rules
mux.Handle("POST "+b+"/addresses/{addressId}/alerts", mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create))) authAndSub(http.HandlerFunc(alertRuleHandler.Create)))
mux.Handle("GET "+b+"/addresses/{addressId}/alerts", mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) authAndSub(http.HandlerFunc(alertRuleHandler.ListByAddress)))
mux.Handle("PATCH "+b+"/alerts/{alertId}", mux.Handle("PATCH "+b+"/alerts/{alertId}",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) authAndSub(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
mux.Handle("DELETE "+b+"/alerts/{alertId}", mux.Handle("DELETE "+b+"/alerts/{alertId}",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove))) authAndSub(http.HandlerFunc(alertRuleHandler.Remove)))
// Authenticated routes — alert events // Authenticated + subscribed routes — alert events
mux.Handle("GET "+b+"/alert-events", mux.Handle("GET "+b+"/alert-events",
middleware.Authenticate(http.HandlerFunc(alertEventHandler.List))) authAndSub(http.HandlerFunc(alertEventHandler.List)))
// Authenticated routes — notification config // Authenticated + subscribed routes — notification config
mux.Handle("GET "+b+"/notification-config", mux.Handle("GET "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) authAndSub(http.HandlerFunc(notifConfigHandler.GetConfig)))
mux.Handle("PUT "+b+"/notification-config", mux.Handle("PUT "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) authAndSub(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
mux.Handle("DELETE "+b+"/notification-config", mux.Handle("DELETE "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) authAndSub(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
mux.Handle("POST "+b+"/notification-config/test", mux.Handle("POST "+b+"/notification-config/test",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestChannels))) authAndSub(http.HandlerFunc(notifConfigHandler.TestChannels)))
// Authenticated routes — email digest // Authenticated + subscribed routes — email digest
mux.Handle("POST "+b+"/email/setup", mux.Handle("POST "+b+"/email/setup",
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail))) authAndSub(http.HandlerFunc(emailDigestHandler.SetupEmail)))
mux.Handle("POST "+b+"/email/digest", mux.Handle("POST "+b+"/email/digest",
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SendDigest))) authAndSub(http.HandlerFunc(emailDigestHandler.SendDigest)))
handler := corsMiddleware(mux) handler := corsMiddleware(mux)

View File

@@ -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 (
@@ -60,7 +60,6 @@ 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, notifConfigModel,
cfg.ResendAPIKey, cfg.EmailFrom,
) )
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel) digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
@@ -138,6 +137,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),

View File

@@ -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

View File

@@ -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=

View 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);

View 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;

View 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);

View File

@@ -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);

View 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;

View File

@@ -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);

View File

@@ -32,6 +32,12 @@ type Config struct {
ResendAPIKey string ResendAPIKey string
EmailFrom string EmailFrom string
DigestIntervalHours int 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.
@@ -52,6 +58,12 @@ func Load() (*Config, error) {
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), 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 {

View File

@@ -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()

View File

@@ -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

View File

@@ -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
} }

View 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)
}

View File

@@ -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,7 +113,7 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, addresses) writeJSON(w, http.StatusOK, addresses)
} }
// UpdateLabel handles PATCH requests to update an address label. // handles PATCH requests to update an address label.
func (h *AddressHandler) UpdateLabel(w http.ResponseWriter, r *http.Request) { func (h *AddressHandler) UpdateLabel(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"))

View File

@@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"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.

View File

@@ -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"))
@@ -198,6 +253,9 @@ 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,7 +286,54 @@ 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 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 { if err != nil {
log.Printf("Error updating alert: %v", err) log.Printf("Error updating alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
@@ -237,6 +342,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
} }
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)
} }

View File

@@ -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 {

View File

@@ -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

View File

@@ -5,7 +5,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"
) )
// StatusHandler handles the system status endpoint. // StatusHandler handles the system status endpoint.

View File

@@ -0,0 +1,414 @@
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)
if err := h.users.UpdateSubscriptionStatus(r.Context(), customerID, status); err != nil {
log.Printf("Failed to update subscription status: %v", err)
}
log.Printf("Subscription %s updated to %s for customer %s", sub.ID, status, 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)
}

View 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"
}

View File

@@ -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 {
@@ -125,6 +125,15 @@ func (m *AddressModel) UpdateLabel(ctx context.Context, id int, userID string, l
return &a, nil 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`,

View File

@@ -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 AlertEventModel struct { type AlertEventModel struct {

View 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
}

View File

@@ -7,7 +7,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 CheckpointModel struct { type CheckpointModel struct {

View File

@@ -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 {

View 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
}

View File

@@ -21,17 +21,14 @@ 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,
} }
// DiscordNotifier sends alert notifications via a Discord webhook. // sends alert notifications via a Discord webhook.
type DiscordNotifier struct { type DiscordNotifier struct {
WebhookURL string WebhookURL string
} }
// Send implements Notifier for Discord.
func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error { func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendDiscordNotification(d.WebhookURL, message, meta) _, err := SendDiscordNotification(d.WebhookURL, message, meta)
return err return err
@@ -104,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

View File

@@ -10,14 +10,12 @@ import (
"time" "time"
) )
// EmailNotifier sends alert notifications via email (Resend).
type EmailNotifier struct { type EmailNotifier struct {
APIKey string APIKey string
From string From string
To string To string
} }
// Send implements Notifier for email.
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error { func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta) _, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
return err return err
@@ -99,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

View 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
}

View File

@@ -87,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

View File

@@ -63,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

View File

@@ -12,18 +12,20 @@ import (
"strings" "strings"
"time" "time"
"github.com/kjannette/koin-ping/backend-go/internal/domain" "github.com/kjannette/koin-ping/backend/internal/domain"
) )
const ( const (
rpcTimeoutMS = 30000 rpcTimeoutMS = 30000
rpcMaxRetries = 3 rpcMaxRetries = 3
rpcRetryBaseMS = 1000 rpcRetryBaseMS = 2000
rpcMinIntervalMS = 1000
) )
type JsonRpcEthereum struct { type JsonRpcEthereum struct {
rpcURL string rpcURL string
client *http.Client client *http.Client
lastCallAt time.Time
} }
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) { func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
@@ -58,6 +60,8 @@ type rpcError struct {
} }
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) { func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
j.throttle(ctx)
if params == nil { if params == nil {
params = []interface{}{} params = []interface{}{}
} }
@@ -72,7 +76,25 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
return nil, fmt.Errorf("marshal RPC request: %w", err) return nil, fmt.Errorf("marshal RPC request: %w", err)
} }
return j.callWithRetry(ctx, method, body) 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. // callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
@@ -242,6 +264,132 @@ func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (strin
return hexToDecimalString(hexBalance), nil return hexToDecimalString(hexBalance), nil
} }
const erc20TransferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
const minTransferTopics = 3
type rpcLog struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
BlockNumber string `json:"blockNumber"`
TxHash string `json:"transactionHash"`
}
// GetTokenTransfers fetches ERC-20 Transfer events for a given address
// across a block range, covering both incoming and outgoing transfers.
func (j *JsonRpcEthereum) GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error) {
paddedAddr := padAddress(address)
hexFrom := fmt.Sprintf("0x%x", fromBlock)
hexTo := fmt.Sprintf("0x%x", toBlock)
incomingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, "", paddedAddr)
if err != nil {
return nil, fmt.Errorf("fetch incoming token transfers: %w", err)
}
outgoingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, paddedAddr, "")
if err != nil {
return nil, fmt.Errorf("fetch outgoing token transfers: %w", err)
}
seen := make(map[string]bool)
var txs []domain.NormalizedTx
for _, entry := range append(incomingLogs, outgoingLogs...) {
key := entry.TxHash + "|" + entry.Address + "|" + entry.Data
if seen[key] {
continue
}
seen[key] = true
tx, parseErr := parseTransferLog(entry)
if parseErr != nil {
log.Printf("Skipping unparseable transfer log in tx %s: %v", entry.TxHash, parseErr)
continue
}
txs = append(txs, tx)
}
return txs, nil
}
func (j *JsonRpcEthereum) getTransferLogs(ctx context.Context, fromBlock, toBlock, fromAddr, toAddr string) ([]rpcLog, error) {
topics := make([]interface{}, minTransferTopics)
topics[0] = erc20TransferTopic
if fromAddr != "" {
topics[1] = fromAddr
}
if toAddr != "" {
topics[2] = toAddr
}
filter := map[string]interface{}{
"fromBlock": fromBlock,
"toBlock": toBlock,
"topics": topics,
}
result, err := j.callRPC(ctx, "eth_getLogs", filter)
if err != nil {
return nil, err
}
var logs []rpcLog
if err := json.Unmarshal(result, &logs); err != nil {
return nil, fmt.Errorf("unmarshal logs: %w", err)
}
return logs, nil
}
func parseTransferLog(entry rpcLog) (domain.NormalizedTx, error) {
if len(entry.Topics) < minTransferTopics {
return domain.NormalizedTx{}, fmt.Errorf("transfer log has %d topics, expected >= 3", len(entry.Topics))
}
from := topicToAddress(entry.Topics[1])
to := topicToAddress(entry.Topics[2])
tokenValue := hexToDecimalString(entry.Data)
blockNumber, _ := hexToInt(entry.BlockNumber)
contractAddr := strings.ToLower(entry.Address)
tokenInfo, known := LookupToken(contractAddr)
tx := domain.NormalizedTx{
Hash: entry.TxHash,
From: from,
To: &to,
Value: "0",
BlockNumber: blockNumber,
TokenContract: &contractAddr,
TokenValue: &tokenValue,
}
if known {
tx.TokenSymbol = &tokenInfo.Symbol
tx.TokenDecimals = &tokenInfo.Decimals
}
return tx, nil
}
func padAddress(addr string) string {
clean := strings.TrimPrefix(strings.ToLower(addr), "0x")
const addressHexLen = 64
return "0x" + strings.Repeat("0", addressHexLen-len(clean)) + clean
}
func topicToAddress(topic string) string {
clean := strings.TrimPrefix(topic, "0x")
const ethAddrLen = 40
if len(clean) > ethAddrLen {
clean = clean[len(clean)-ethAddrLen:]
}
return "0x" + strings.ToLower(clean)
}
func hexToInt(hex string) (int, error) { func hexToInt(hex string) (int, error) {
hex = strings.TrimPrefix(hex, "0x") hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16) n, ok := new(big.Int).SetString(hex, 16)

View File

@@ -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)
} }

View File

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

View File

@@ -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

View File

@@ -4,19 +4,23 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"sync"
"time" "time"
"github.com/kjannette/koin-ping/backend-go/internal/domain" "golang.org/x/sync/semaphore"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/notifications" "github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum" "github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/wei" "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 ( const (
notificationTimeout = 30 * time.Second notificationTimeout = 30 * time.Second
notificationMaxRetries = 3 notificationMaxRetries = 3
notificationRetryBase = time.Second notificationRetryBase = time.Second
maxConcurrentNotifications = 5
) )
type EvaluatorService struct { type EvaluatorService struct {
@@ -25,8 +29,8 @@ type EvaluatorService struct {
alertEvents *models.AlertEventModel alertEvents *models.AlertEventModel
addresses *models.AddressModel addresses *models.AddressModel
notifConfigs *models.NotificationConfigModel notifConfigs *models.NotificationConfigModel
resendAPIKey string notifSem *semaphore.Weighted
emailFrom string notifWg sync.WaitGroup
} }
func NewEvaluatorService( func NewEvaluatorService(
@@ -35,8 +39,6 @@ func NewEvaluatorService(
alertEvents *models.AlertEventModel, alertEvents *models.AlertEventModel,
addresses *models.AddressModel, addresses *models.AddressModel,
notifConfigs *models.NotificationConfigModel, notifConfigs *models.NotificationConfigModel,
resendAPIKey string,
emailFrom string,
) *EvaluatorService { ) *EvaluatorService {
return &EvaluatorService{ return &EvaluatorService{
eth: eth, eth: eth,
@@ -44,8 +46,7 @@ func NewEvaluatorService(
alertEvents: alertEvents, alertEvents: alertEvents,
addresses: addresses, addresses: addresses,
notifConfigs: notifConfigs, notifConfigs: notifConfigs,
resendAPIKey: resendAPIKey, notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
emailFrom: emailFrom,
} }
} }
@@ -97,9 +98,9 @@ func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.O
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) { func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
switch rule.Type { switch rule.Type {
case domain.AlertIncomingTx: case domain.AlertIncomingTx:
return obs.Direction == domain.DirectionIncoming, nil return s.matchesDirectionalTx(rule, obs, domain.DirectionIncoming)
case domain.AlertOutgoingTx: case domain.AlertOutgoingTx:
return obs.Direction == domain.DirectionOutgoing, nil return s.matchesDirectionalTx(rule, obs, domain.DirectionOutgoing)
case domain.AlertLargeTransfer: case domain.AlertLargeTransfer:
return s.matchesLargeTransfer(rule, obs) return s.matchesLargeTransfer(rule, obs)
case domain.AlertBalanceBelow: case domain.AlertBalanceBelow:
@@ -110,6 +111,42 @@ func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRul
} }
} }
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) { func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
if rule.Threshold == nil { if rule.Threshold == nil {
return false, nil return false, nil
@@ -189,7 +226,14 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
if addr != nil { if addr != nil {
userID := addr.UserID userID := addr.UserID
address := addr.Address 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() { go func() {
defer s.notifSem.Release(1)
defer s.notifWg.Done()
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout) notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
defer cancel() defer cancel()
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address) s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
@@ -199,6 +243,11 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
return nil 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) []notifications.Notifier { func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
var notifiers []notifications.Notifier var notifiers []notifications.Notifier
@@ -218,13 +267,7 @@ func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []noti
notifiers = append(notifiers, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL}) notifiers = append(notifiers, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
} }
if cfg.Email != nil && *cfg.Email != "" { // Email is delivered exclusively via the daily digest service, never real-time.
notifiers = append(notifiers, &notifications.EmailNotifier{
APIKey: s.resendAPIKey,
From: s.emailFrom,
To: *cfg.Email,
})
}
return notifiers return notifiers
} }
@@ -242,8 +285,14 @@ func sendWithRetry(ctx context.Context, n notifications.Notifier, message string
} }
if err := n.Send(ctx, message, meta); err != nil { if err := n.Send(ctx, message, meta); err != nil {
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
lastErr = err 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 continue
} }
@@ -281,6 +330,10 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
} }
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string { func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
if obs.IsTokenTransfer() {
return s.buildTokenMessage(rule, obs)
}
switch rule.Type { switch rule.Type {
case domain.AlertIncomingTx: case domain.AlertIncomingTx:
ethStr, _ := wei.FormatAsEth(obs.Value, 4) ethStr, _ := wei.FormatAsEth(obs.Value, 4)
@@ -305,3 +358,36 @@ func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.Observ
return "Alert triggered" return "Alert triggered"
} }
} }
const defaultTokenDecimals = 18
func (s *EvaluatorService) buildTokenMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
symbol := "tokens"
if obs.TokenSymbol != nil {
symbol = *obs.TokenSymbol
}
amount := "unknown"
if obs.TokenValue != nil {
decimals := defaultTokenDecimals
if obs.TokenDecimals != nil {
decimals = *obs.TokenDecimals
}
amount = wei.FormatTokenAmount(*obs.TokenValue, decimals)
}
switch rule.Type {
case domain.AlertIncomingTx:
return fmt.Sprintf("Incoming transfer: %s %s received", amount, symbol)
case domain.AlertOutgoingTx:
return fmt.Sprintf("Outgoing transfer: %s %s sent", amount, symbol)
case domain.AlertLargeTransfer:
threshold := float64(0)
if rule.Threshold != nil {
threshold = *rule.Threshold
}
return fmt.Sprintf("Large token transfer: %s %s (threshold: %g)", amount, symbol, threshold)
default:
return fmt.Sprintf("Token transfer: %s %s", amount, symbol)
}
}

View File

@@ -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
} }

View File

@@ -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
backend/poller Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
frontend/public/ping.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,11 +1,13 @@
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 } = useAuth();
@@ -15,6 +17,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} /> <Route path="/signup" element={<Signup />} />
<Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/login" />} /> <Route path="*" element={<Navigate to="/login" />} />
</Routes> </Routes>
); );
@@ -27,7 +30,9 @@ export default function App() {
<Route path="/" element={<Addresses />} /> <Route path="/" element={<Addresses />} />
<Route path="/addresses" element={<Addresses />} /> <Route path="/addresses" element={<Addresses />} />
<Route path="/alerts" element={<Alerts />} /> <Route path="/alerts" element={<Alerts />} />
<Route path="/history" element={<AlertHistory />} /> <Route path="/alertevents" element={<AlertHistory />} />
<Route path="/account" element={<Account />} />
<Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/addresses" />} /> <Route path="*" element={<Navigate to="/addresses" />} />
</Routes> </Routes>
</div> </div>

View 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();
}

View File

@@ -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

View 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();
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import Input from "./Input"; import Input from "./Input";
import Button from "./Button"; import Button from "./Button";
import "./AlertForm.css";
const ALERT_TYPES = [ const ALERT_TYPES = [
{ value: "incoming_tx", label: "Incoming transaction" }, { value: "incoming_tx", label: "Incoming transaction" },
@@ -36,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"

View File

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

View File

@@ -1,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>

View 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);
}

View File

@@ -1,3 +1,5 @@
import "./Input.css";
export default function Input({ export default function Input({
label, label,
type = "text", type = "text",
@@ -7,12 +9,12 @@ export default function Input({
step, step,
min, min,
disabled = false, disabled = false,
required = false,
className = "",
}) { }) {
return ( return (
<label style={{ display: "block", marginBottom: "1rem" }}> <label className={`form-field ${className}`}>
<div style={{ marginBottom: "0.25rem", fontSize: "0.9rem" }}> <div className="input__label">{label}</div>
{label}
</div>
<input <input
type={type} type={type}
value={value} value={value}
@@ -20,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>
); );

View 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;
}
}

View File

@@ -1,56 +1,40 @@
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={{
fontWeight: 700,
fontSize: "1.1rem",
color: "#fff",
marginRight: "2rem",
letterSpacing: "0.5px",
}}>
Koin Ping
</span>
<div style={{ display: "flex", gap: "0.25rem" }}>
{navLinks.map(({ to, label }) => { {navLinks.map(({ to, label }) => {
const isActive = location.pathname === to; const isActive = location.pathname === to;
return ( return (
<Link <Link
key={to} key={to}
to={to} to={to}
style={{ className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
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} {label}
</Link> </Link>
@@ -59,27 +43,29 @@ export default function Navbar() {
</div> </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}
style={{
padding: "0.4rem 1rem",
fontSize: "1.275rem",
fontWeight: 400,
backgroundColor: "transparent",
color: "#999",
border: "1px solid #444",
borderRadius: "6px",
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
Logout Logout
</button> </button>
<button
className="navbar__hamburger"
onClick={() => setIsNavPanelOpen(true)}
aria-label="Open navigation menu"
>
<span className="navbar__hamburger-bar" />
<span className="navbar__hamburger-bar" />
<span className="navbar__hamburger-bar" />
</button>
</div> </div>
</nav> </nav>
<NavPanel
isOpen={isNavPanelOpen}
onClose={() => setIsNavPanelOpen(false)}
/>
</>
); );
} }

View 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;
}
}

View 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">&#10003;</span> {f}
</li>
))}
{tier.disabledFeatures.map((f) => (
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
<span className="tier-picker__dash">&mdash;</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>
);
}

View 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;
}

View File

@@ -0,0 +1,18 @@
import { useNavigate } from "react-router-dom";
import "./UpgradeBanner.css";
export default function UpgradeBanner({ message, linkTo = "/account" }) {
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>
);
}

View 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;
}

View 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>
</>
);
}

View File

@@ -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,28 @@ 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 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);
} 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 +72,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 +87,12 @@ 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);
await signOut(auth); await signOut(auth);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -84,18 +100,16 @@ 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 value = { const value = {
currentUser, currentUser,
@@ -104,6 +118,10 @@ export function AuthProvider({ children }) {
logout, logout,
error, error,
loading, loading,
userTier,
tierLimits,
addressCount,
refreshAccount,
}; };
return ( return (

View File

@@ -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,370 @@ 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;
}
}
/* 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;
}
}

View File

@@ -1,200 +0,0 @@
import { useState, useEffect } from "react";
import AddressForm from "../components/AddressForm";
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../api/addresses";
export default function Addresses() {
const [addresses, setAddresses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editingId, setEditingId] = useState(null);
const [editLabel, setEditLabel] = useState("");
// 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);
setAddresses((prev) => [...prev, newAddress]);
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to create address:", err);
}
}
async function handleDelete(id, label) {
const displayName = label || "this address";
if (!window.confirm(`Remove "${displayName}"? This will also delete all associated alert rules.`)) {
return;
}
try {
await deleteAddress(id);
setAddresses((prev) => prev.filter((a) => a.id !== id));
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to delete address:", err);
}
}
function handleEditStart(addr) {
setEditingId(addr.id);
setEditLabel(addr.label ?? "");
}
async function handleEditSave(id) {
try {
const updated = await updateAddress(id, { label: editLabel || null });
setAddresses((prev) => prev.map((a) => (a.id === id ? updated : a)));
setEditingId(null);
setEditLabel("");
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to update address:", err);
}
}
function handleEditCancel() {
setEditingId(null);
setEditLabel("");
}
return (
<div 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={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ flex: 1 }}>
{editingId === addr.id ? (
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginBottom: "0.25rem" }}>
<input
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
placeholder="Label (optional)"
style={{
background: "#444",
border: "1px solid #666",
borderRadius: "3px",
color: "#fff",
padding: "0.25rem 0.5rem",
fontSize: "0.9rem",
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave(addr.id);
if (e.key === "Escape") handleEditCancel();
}}
autoFocus
/>
<button
onClick={() => handleEditSave(addr.id)}
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem" }}
>
Save
</button>
<button
onClick={handleEditCancel}
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem", background: "transparent", color: "#aaa", border: "1px solid #555" }}
>
Cancel
</button>
</div>
) : (
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.25rem" }}>
<span style={{ fontWeight: "bold" }}>
{addr.label || "Unlabeled"}
</span>
<button
onClick={() => handleEditStart(addr)}
style={{
cursor: "pointer",
background: "transparent",
border: "none",
color: "#6699cc",
fontSize: "0.8rem",
padding: "0",
textDecoration: "underline",
}}
>
Edit
</button>
</div>
)}
<div
style={{
fontFamily: "monospace",
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
{addr.address}
</div>
</div>
<button
onClick={() => handleDelete(addr.id, addr.label)}
style={{
cursor: "pointer",
background: "transparent",
border: "1px solid #884444",
color: "#cc6666",
borderRadius: "3px",
padding: "0.3rem 0.7rem",
fontSize: "0.85rem",
marginLeft: "1rem",
flexShrink: 0,
}}
>
Remove
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
} }

View File

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

View File

@@ -0,0 +1,176 @@
import { useState, useEffect } from "react";
import { useAuth } from "../../contexts/AuthContext";
import AddressForm from "../../components/AddressForm";
import UpgradeBanner from "../../components/UpgradeBanner";
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../../api/addresses";
import "./Addresses.css";
export default function Addresses() {
const { tierLimits, refreshAccount } = useAuth();
const [addresses, setAddresses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editingId, setEditingId] = useState(null);
const [editLabel, setEditLabel] = useState("");
const maxAddresses = tierLimits.max_addresses;
const isUnlimited = maxAddresses === -1;
const atLimit = !isUnlimited && addresses.length >= maxAddresses;
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();
}, []);
async function handleAddressSubmit(data) {
try {
const newAddress = await createAddress(data);
setAddresses((prev) => [...prev, newAddress]);
setError(null);
refreshAccount();
} catch (err) {
setError(err.message);
console.error("Failed to create address:", err);
}
}
async function handleDelete(id, label) {
const displayName = label || "this address";
if (!window.confirm(`Remove "${displayName}"? This will also delete all associated alert rules.`)) {
return;
}
try {
await deleteAddress(id);
setAddresses((prev) => prev.filter((a) => a.id !== id));
setError(null);
refreshAccount();
} catch (err) {
setError(err.message);
console.error("Failed to delete address:", err);
}
}
function handleEditStart(addr) {
setEditingId(addr.id);
setEditLabel(addr.label ?? "");
}
async function handleEditSave(id) {
try {
const updated = await updateAddress(id, { label: editLabel || null });
setAddresses((prev) => prev.map((a) => (a.id === id ? updated : a)));
setEditingId(null);
setEditLabel("");
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to update address:", err);
}
}
function handleEditCancel() {
setEditingId(null);
setEditLabel("");
}
return (
<div className="page">
<h1>Add Addresses to Track</h1>
{atLimit && (
<UpgradeBanner
message={`Your plan allows ${maxAddresses} address${maxAddresses !== 1 ? "es" : ""}. Upgrade to track more.`}
/>
)}
{!atLimit && (
<div className="mb-xl">
<AddressForm onSubmit={handleAddressSubmit} />
</div>
)}
<div>
<h2>Existing Tracked Addresses</h2>
{loading && <p>Loading addresses...</p>}
{error && <p className="text-error">Error: {error}</p>}
{!loading && !error && addresses.length === 0 && (
<p className="text-dimmed">
No addresses tracked yet. Add one above to get started.
</p>
)}
{addresses.length > 0 && (
<ul className="list-unstyled">
{addresses.map((addr, index) => (
<li key={addr.id || index} className="list-item--card">
<div className="flex flex--between flex--center">
<div>
{editingId === addr.id ? (
<div className="flex flex--center gap-sm mb-sm">
<input
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
placeholder="Label (optional)"
className="address__edit-input"
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave(addr.id);
if (e.key === "Escape") handleEditCancel();
}}
autoFocus
/>
<button
onClick={() => handleEditSave(addr.id)}
className="btn btn--primary btn--sm"
>
Save
</button>
<button
onClick={handleEditCancel}
className="btn btn--ghost btn--sm"
>
Cancel
</button>
</div>
) : (
<div className="flex flex--center gap-sm mb-sm">
<span className="text-bold">
{addr.label || "Unlabeled"}
</span>
<button
onClick={() => handleEditStart(addr)}
className="address__edit-link"
>
Edit
</button>
</div>
)}
<div className="text-mono text-sm text-muted">
{addr.address}
</div>
</div>
<button
onClick={() => handleDelete(addr.id, addr.label)}
className="address__remove"
>
Remove
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,67 @@
import { useState, useEffect } from "react";
import { getAlertEvents } from "../../api/alertEvents";
import "./AlertHistory.css";
export default function AlertHistory() {
const [alertEvents, setAlertEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
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 className="page">Loading...</div>;
}
if (error) {
return (
<div className="page text-error">Error: {error}</div>
);
}
return (
<div className="page">
<h1>Recent Alert Events</h1>
{alertEvents.length === 0 ? (
<p className="text-dimmed">No alerts yet</p>
) : (
<ul className="list-unstyled">
{alertEvents.map((event) => (
<li key={event.id} className="alert-history__item">
<div className="mb-sm">{event.message}</div>
{event.address_label && (
<div className="text-sm text-dimmed">
Address: {event.address_label}
</div>
)}
<small className="text-muted">
{formatTimestamp(event.timestamp)}
</small>
</li>
))}
</ul>
)}
</div>
);
}
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString();
}

View File

@@ -0,0 +1,191 @@
.alerts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: start;
}
.alerts__address-info {
padding: 1rem;
margin-bottom: 2rem;
background-color: var(--color-bg-card);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.alerts__toggle-panel {
margin-bottom: 1.5rem;
padding: 1rem;
background-color: var(--color-bg-card);
border-radius: var(--radius-md);
}
.alerts__toggle-label {
display: flex;
align-items: center;
cursor: pointer;
}
.alerts__toggle-checkbox {
margin-right: 0.5rem;
width: 18px;
height: 18px;
}
.alerts__toggle-hint {
font-size: 0.978rem;
color: var(--color-text-dimmed);
margin-top: 0.5rem;
margin-left: 26px;
}
.alerts__rule {
padding: 1rem;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-card);
}
.alerts__rule--disabled {
opacity: 0.6;
}
.alerts__accordion-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.5rem 0 0;
border-top: 1px solid var(--color-border);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-muted);
background: none;
border-left: none;
border-right: none;
border-bottom: none;
width: 100%;
text-align: left;
}
.alerts__accordion-toggle:hover {
color: var(--color-text);
}
.alerts__accordion-chevron {
display: inline-block;
transition: transform 0.2s ease;
font-size: 0.7rem;
}
.alerts__accordion-chevron--open {
transform: rotate(90deg);
}
.alerts__accordion-panel {
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
padding: 0 0.25rem;
}
.alerts__accordion-panel--open {
max-height: 200px;
opacity: 1;
padding: 0.75rem 0.25rem 0.25rem;
}
.alerts__threshold-inputs {
display: flex;
gap: 1rem;
align-items: flex-end;
}
.alerts__threshold-inputs .form-field {
flex: 1;
margin-bottom: 0;
}
.alerts__threshold-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.alerts__email-buttons {
display: flex;
gap: 0.5rem;
}
.alerts__save-test {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
/* Locked / disabled notification channel */
.alerts__channel-locked {
opacity: 0.55;
position: relative;
}
.alerts__channel-locked .upgrade-banner {
opacity: 1;
}
.alerts__channel-locked input {
pointer-events: none;
}
/* ── Alerts Page Responsive ──────────────────────────────── */
/* Laptop: tighten the gap */
@media (max-width: 1024px) {
.alerts-grid {
gap: 1.5rem;
}
}
/* Tablet & below: collapse to single column */
@media (max-width: 768px) {
.alerts-grid {
grid-template-columns: 1fr;
gap: 2.5rem;
}
}
/* Mobile: compact spacing */
@media (max-width: 480px) {
.alerts-grid {
gap: 2rem;
}
.alerts__address-info {
padding: 0.75rem;
margin-bottom: 1.25rem;
}
.alerts__rule {
padding: 0.75rem;
}
.alerts__rule .flex--between {
flex-direction: column;
gap: 0.75rem;
}
.alerts__save-test {
flex-direction: column;
}
.alerts__save-test .btn--lg {
width: 100%;
text-align: center;
}
.alerts__email-buttons {
flex-direction: column;
}
}

Some files were not shown because too many files have changed in this diff Show More