Compare commits
48 Commits
feat-discr
...
implement-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7bc65995 | ||
|
|
05453895b9 | ||
|
|
14ed0a23a6 | ||
|
|
40ed4f6afd | ||
|
|
26324150d2 | ||
|
|
b0572451d3 | ||
|
|
d9c3bd1db5 | ||
|
|
615dd1dddc | ||
|
|
288092e4b4 | ||
|
|
69a2112df9 | ||
|
|
4ba91c7d9b | ||
|
|
f9fa7def2b | ||
|
|
7b45c5b2ab | ||
|
|
0fb4dcd7d1 | ||
|
|
d3530deb46 | ||
|
|
aadcdc628e | ||
|
|
2d4fcefdcf | ||
|
|
81303573fd | ||
|
|
76fadf05d9 | ||
|
|
b9e3d8ae82 | ||
|
|
6c5ffc2340 | ||
|
|
e32aa4b6aa | ||
|
|
682c168250 | ||
|
|
e5c8f91e3f | ||
|
|
f6255ec1f1 | ||
|
|
4b77e7a4d4 | ||
|
|
8f1212813f | ||
|
|
95ec1a520a | ||
|
|
4b228d9b57 | ||
|
|
08bc3e1ea2 | ||
|
|
a091d39c08 | ||
|
|
35699a41bd | ||
|
|
2e96982d3a | ||
|
|
e48de2d6b6 | ||
|
|
9414af8108 | ||
|
|
068ca2f834 | ||
|
|
7f10bcb7de | ||
|
|
f91ca33752 | ||
|
|
2c86bba235 | ||
|
|
44dad43f1d | ||
|
|
2fe0e5b8e9 | ||
|
|
8f08105246 | ||
|
|
2bc06669e9 | ||
|
|
a2d1ff9d76 | ||
|
|
dcbf0756d2 | ||
|
|
239ea7db85 | ||
|
|
9ad414313b | ||
|
|
3ee1598478 |
@@ -1,9 +0,0 @@
|
||||
name: check
|
||||
on: [push]
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-22
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- run: docker build .
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -21,7 +21,9 @@ node_modules/
|
||||
*.key
|
||||
|
||||
# Go build artifacts
|
||||
backend-go/bin/
|
||||
backend/bin/
|
||||
backend/api
|
||||
backend/poller
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
backend-go/bin/
|
||||
backend/bin/
|
||||
*.lock
|
||||
Prompts/
|
||||
.claude/
|
||||
|
||||
3
LICENSE
3
LICENSE
@@ -2,8 +2,7 @@ MIT License
|
||||
|
||||
Copyright (c) 2026 Steven Jannette
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
|
||||
2
Makefile
2
Makefile
@@ -2,7 +2,7 @@
|
||||
test-go test-js lint-go lint-js fmt-go fmt-js fmt-check-go fmt-check-js \
|
||||
build-go build-js
|
||||
|
||||
GODIR := backend-go
|
||||
GODIR := backend
|
||||
JSDIR := frontend
|
||||
PRETTIER := $(JSDIR)/node_modules/.bin/prettier
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ make hooks
|
||||
cd frontend && npm install && cd ..
|
||||
|
||||
# Copy and fill in environment variables
|
||||
cp backend-go/.env.example backend-go/.env
|
||||
# edit backend-go/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
|
||||
cp backend/.env.example backend/.env
|
||||
# edit backend/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
|
||||
|
||||
# Run checks (requires golangci-lint)
|
||||
make check
|
||||
@@ -38,7 +38,7 @@ make check
|
||||
make run
|
||||
|
||||
# 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)
|
||||
cd frontend && npm run dev
|
||||
@@ -63,7 +63,7 @@ frontend:
|
||||
|
||||
```
|
||||
koin_ping_0.2.0/
|
||||
├── backend-go/ # Go monorepo root
|
||||
├── backend/ # Go monorepo root
|
||||
│ ├── cmd/api/ # HTTP REST API server
|
||||
│ ├── cmd/poller/ # Blockchain polling daemon
|
||||
│ └── internal/
|
||||
|
||||
188
REPO_POLICIES.md
188
REPO_POLICIES.md
@@ -1,188 +0,0 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-02-22
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
style conventions are in separate documents:
|
||||
|
||||
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||
(general, bash, Docker)
|
||||
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||
|
||||
---
|
||||
|
||||
- Cross-project documentation (such as this file) must include
|
||||
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
||||
with the authoritative source as policies evolve.
|
||||
|
||||
- **ALL external references must be pinned by cryptographic hash.** This
|
||||
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
||||
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
||||
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
||||
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
||||
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
||||
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
||||
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
||||
etc. Instead, download a specific release archive from GitHub, verify its hash
|
||||
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
||||
install scripts are arbitrary remote code execution. This is the single most
|
||||
important rule in this document. Double-check every external reference in
|
||||
every file before committing. There are zero exceptions to this rule.
|
||||
|
||||
- Every repo with software must have a root `Makefile` with these targets:
|
||||
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
||||
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
||||
`make hooks` (installs pre-commit hook). A model Makefile is at
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||
|
||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||
instead of invoking the underlying tools directly. The Makefile is the single
|
||||
source of truth for how these operations are run.
|
||||
|
||||
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
||||
the required targets above, it should have targets for every common operation:
|
||||
running a local development server (`make run`, `make dev`), re-initializing
|
||||
or migrating the database (`make db-reset`, `make migrate`), building
|
||||
artifacts (`make build`), generating code, seeding data, or anything else a
|
||||
developer would do regularly. If someone checks out the repo and types
|
||||
`make<tab>`, they should see every meaningful operation available. A new
|
||||
contributor should be able to understand the entire development workflow by
|
||||
reading the Makefile.
|
||||
|
||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||
as a build step so the build fails if the branch is not green. For non-server
|
||||
repos, the Dockerfile should bring up a development environment and run
|
||||
`make check`. For server repos, `make check` should run as an early build
|
||||
stage before the final image is assembled.
|
||||
|
||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
|
||||
a successful build implies all checks pass.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||
|
||||
- Pre-commit hook: `make check` if local testing is possible, otherwise
|
||||
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
|
||||
target to install the pre-commit hook.
|
||||
|
||||
- All repos with software must have tests that run via the platform-standard
|
||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||
tests exist yet, add the most minimal test possible — e.g. importing the
|
||||
module under test to verify it compiles/parses. There is no excuse for
|
||||
`make test` to be a no-op.
|
||||
|
||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||
Makefile.
|
||||
|
||||
- Docker builds must complete in under 5 minutes.
|
||||
|
||||
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||
directories.
|
||||
|
||||
- `main` must always pass `make check`, no exceptions.
|
||||
|
||||
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||
must be in `.gitignore`. No exceptions.
|
||||
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||
Fetch the standard `.gitignore` from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||
a new repo.
|
||||
|
||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||
|
||||
- Never force-push to `main`.
|
||||
|
||||
- Make all changes on a feature branch. You can do whatever you want on a
|
||||
feature branch.
|
||||
|
||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||
manually by the user. Fetch from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||
|
||||
- When pinning images or packages by hash, add a comment above the reference
|
||||
with the version and date (YYYY-MM-DD).
|
||||
|
||||
- Use `yarn`, not `npm`.
|
||||
|
||||
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||
|
||||
- Simple projects should be configured with environment variables.
|
||||
|
||||
- Dockerized web services listen on port 8080 by default, overridable with
|
||||
`PORT`.
|
||||
|
||||
- `README.md` is the primary documentation. Required sections:
|
||||
- **Description**: First line must include the project name, purpose,
|
||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||
- **Rationale**: Why does this exist?
|
||||
- **Design**: How is the program structured?
|
||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||
the todo list in the README so a new agent can pick up where the last one
|
||||
left off.
|
||||
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||
`LICENSE` file in the repo root and a License section in the README.
|
||||
- **Author**: [@sneak](https://sneak.berlin).
|
||||
|
||||
- First commit of a new repo should contain only `README.md`.
|
||||
|
||||
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
||||
committing.
|
||||
|
||||
- Use SemVer.
|
||||
|
||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||
the binary.
|
||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||
tracking table itself. Nothing else.
|
||||
- `001_schema.sql` — the full application schema.
|
||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||
Never edit existing migrations after release.
|
||||
|
||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||
subdirectory names:
|
||||
- `bin/` — executable scripts and tools
|
||||
- `cmd/` — Go command entrypoints
|
||||
- `configs/` — configuration templates and examples
|
||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||
- `docs/` — documentation and markdown (README.md stays in root)
|
||||
- `internal/` — Go internal packages
|
||||
- `internal/db/migrations/` — database migrations
|
||||
- `pkg/` — Go library packages
|
||||
- `share/` — systemd units, data files
|
||||
- `static/` — static assets (images, fonts, etc.)
|
||||
- `web/` — web frontend source
|
||||
|
||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||
templates. Fetch them from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
||||
|
||||
- New repos must contain at minimum:
|
||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||
- `Makefile`
|
||||
- `Dockerfile`, `.dockerignore`
|
||||
- `.gitea/workflows/check.yml`
|
||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||
- Python: `pyproject.toml`
|
||||
@@ -1,20 +0,0 @@
|
||||
# Server
|
||||
PORT=3001
|
||||
API_BASE_PATH=/v1
|
||||
NODE_ENV=development
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/koin_ping
|
||||
|
||||
# Ethereum JSON-RPC
|
||||
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR-PROJECT-ID
|
||||
|
||||
# Polling interval (ms, minimum 1000)
|
||||
POLL_INTERVAL_MS=60000
|
||||
|
||||
# Firebase
|
||||
FIREBASE_PROJECT_ID=koin-ping
|
||||
|
||||
# Email notifications (Resend — https://resend.com)
|
||||
# RESEND_API_KEY=re_xxxxxxxxxxxx
|
||||
# EMAIL_FROM=Koin Ping <alerts@yourdomain.com>
|
||||
@@ -1,21 +0,0 @@
|
||||
Start DB:
|
||||
|
||||
brew services start postgresql@15
|
||||
|
||||
|
||||
From the backend-go directory, you have a few options:
|
||||
|
||||
Option 1: Single command (both API + poller)
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gomake dev-all
|
||||
|
||||
Option 2: Two separate terminals
|
||||
Terminal 1 (API server):
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api
|
||||
Terminal 2 (Poller):
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller
|
||||
|
||||
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).
|
||||
make poller — Builds and runs the poller.
|
||||
make poller-dev — Runs the poller with auto-reload.
|
||||
make dev-all — Runs both the API and poller concurrently.
|
||||
BIN
backend-go/api
BIN
backend-go/api
Binary file not shown.
@@ -1,115 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
)
|
||||
|
||||
type AlertRuleModel struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel {
|
||||
return &AlertRuleModel{pool: pool}
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold *float64) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO alert_rules (address_id, type, threshold, enabled)
|
||||
VALUES ($1, $2, $3, TRUE)
|
||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
||||
addressID, alertType.String(), threshold,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
|
||||
rows, err := m.pool.Query(ctx,
|
||||
`SELECT id, address_id, type, threshold, enabled, created_at
|
||||
FROM alert_rules
|
||||
WHERE address_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
addressID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rules []domain.AlertRule
|
||||
for rows.Next() {
|
||||
var r domain.AlertRule
|
||||
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
var err error
|
||||
|
||||
if userID != nil {
|
||||
err = m.pool.QueryRow(ctx,
|
||||
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.enabled, ar.created_at
|
||||
FROM alert_rules ar
|
||||
JOIN addresses a ON a.id = ar.address_id
|
||||
WHERE ar.id = $1 AND a.user_id = $2`,
|
||||
id, *userID,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
} else {
|
||||
err = m.pool.QueryRow(ctx,
|
||||
`SELECT id, address_id, type, threshold, enabled, created_at
|
||||
FROM alert_rules
|
||||
WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`UPDATE alert_rules
|
||||
SET enabled = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
||||
id, enabled,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
|
||||
tag, err := m.pool.Exec(ctx,
|
||||
`DELETE FROM alert_rules WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
@@ -1,53 +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 UserModel struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewUserModel(pool *pgxpool.Pool) *UserModel {
|
||||
return &UserModel{pool: pool}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var u domain.User
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (firebase_uid, email)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
|
||||
RETURNING id, firebase_uid, email, display_name, created_at, updated_at`,
|
||||
firebaseUID, email,
|
||||
).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
var u domain.User
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`SELECT id, firebase_uid, email, display_name, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -37,7 +37,7 @@ tidy:
|
||||
|
||||
# Database setup
|
||||
db-setup:
|
||||
psql -d koin_ping_dev -f infra/schema.sql
|
||||
psql -d koin_ping -f infra/schema.sql
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
32
backend/README.md
Normal file
32
backend/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
Backend startup quickstarat:
|
||||
-----------------------------> BEST
|
||||
## 1. brew services start postgresql@15
|
||||
|
||||
OR
|
||||
|
||||
/opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start
|
||||
|
||||
## 2. ALLIN ONE:
|
||||
Make dev-all — Runs both the API and poller concurrently.
|
||||
-----------------------------> BEST
|
||||
|
||||
- OR -
|
||||
|
||||
|
||||
## 3. Option 1: Single command (both API + poller)
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
|
||||
|
||||
## 4. Option 2: Two separate terminals
|
||||
Terminal 1 (API server):
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
|
||||
|
||||
make run — Builds and runs the API server.
|
||||
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
|
||||
|
||||
## 5. Terminal 2 (Poller):
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend
|
||||
go run ./cmd/poller
|
||||
|
||||
make poller — Builds and runs the poller.
|
||||
make poller-dev — Runs the poller with auto-reload.
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||
"github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||
"github.com/kjannette/koin-ping/backend/internal/handlers"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -55,14 +55,22 @@ func main() {
|
||||
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
|
||||
)
|
||||
|
||||
addressHandler := handlers.NewAddressHandler(addressModel)
|
||||
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
|
||||
addressHandler := handlers.NewAddressHandler(addressModel, userModel)
|
||||
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel, userModel)
|
||||
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
|
||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
|
||||
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
|
||||
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
|
||||
|
||||
authenticate := middleware.Authenticate(userModel)
|
||||
requireSub := middleware.RequireSubscription(userModel)
|
||||
|
||||
// authAndSub chains authentication + subscription check for protected routes.
|
||||
authAndSub := func(h http.Handler) http.Handler {
|
||||
return authenticate(requireSub(h))
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
b := cfg.APIBasePath // e.g. "/v1"
|
||||
@@ -71,45 +79,67 @@ func main() {
|
||||
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
||||
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",
|
||||
authenticate(http.HandlerFunc(addressHandler.Create)))
|
||||
authAndSub(http.HandlerFunc(addressHandler.Create)))
|
||||
mux.Handle("GET "+b+"/addresses",
|
||||
authenticate(http.HandlerFunc(addressHandler.List)))
|
||||
authAndSub(http.HandlerFunc(addressHandler.List)))
|
||||
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
||||
authenticate(http.HandlerFunc(addressHandler.Remove)))
|
||||
authAndSub(http.HandlerFunc(addressHandler.Remove)))
|
||||
mux.Handle("PATCH "+b+"/addresses/{addressId}",
|
||||
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",
|
||||
authenticate(http.HandlerFunc(alertRuleHandler.Create)))
|
||||
authAndSub(http.HandlerFunc(alertRuleHandler.Create)))
|
||||
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
||||
authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
||||
authAndSub(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
||||
mux.Handle("PATCH "+b+"/alerts/{alertId}",
|
||||
authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
||||
authAndSub(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
||||
mux.Handle("DELETE "+b+"/alerts/{alertId}",
|
||||
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",
|
||||
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",
|
||||
authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
||||
authAndSub(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
||||
mux.Handle("PUT "+b+"/notification-config",
|
||||
authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||
authAndSub(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||
mux.Handle("DELETE "+b+"/notification-config",
|
||||
authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
||||
authAndSub(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
||||
mux.Handle("POST "+b+"/notification-config/test",
|
||||
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",
|
||||
authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
||||
authAndSub(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
||||
mux.Handle("POST "+b+"/email/digest",
|
||||
authenticate(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
||||
authAndSub(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
||||
|
||||
handler := corsMiddleware(mux)
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -51,6 +51,7 @@ func main() {
|
||||
|
||||
defer database.Close()
|
||||
|
||||
userModel := models.NewUserModel(pool)
|
||||
addressModel := models.NewAddressModel(pool)
|
||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||
alertEventModel := models.NewAlertEventModel(pool)
|
||||
@@ -59,8 +60,7 @@ func main() {
|
||||
|
||||
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
||||
evaluator := services.NewEvaluatorService(
|
||||
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
|
||||
cfg.ResendAPIKey, cfg.EmailFrom,
|
||||
eth, alertRuleModel, alertEventModel, addressModel, userModel, notifConfigModel,
|
||||
)
|
||||
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
|
||||
|
||||
@@ -138,6 +138,8 @@ func runCycle(
|
||||
return
|
||||
}
|
||||
|
||||
evaluator.WaitForNotifications()
|
||||
|
||||
duration := time.Since(startTime)
|
||||
log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s",
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/kjannette/koin-ping/backend-go
|
||||
module github.com/kjannette/koin-ping/backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
@@ -44,6 +44,7 @@ require (
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/resend/resend-go/v3 v3.1.1 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/stripe/stripe-go/v82 v82.5.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/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||
@@ -101,6 +101,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
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=
|
||||
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=
|
||||
8
backend/infra/migrations/007_add_stripe_to_users.sql
Normal file
8
backend/infra/migrations/007_add_stripe_to_users.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Migration 007: Add Stripe subscription fields to users table
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(50) DEFAULT 'none';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_created_at TIMESTAMP;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer_id ON users(stripe_customer_id);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE alert_rules ADD COLUMN minimum DECIMAL(20, 6);
|
||||
ALTER TABLE alert_rules ADD COLUMN maximum DECIMAL(20, 6);
|
||||
|
||||
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0);
|
||||
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0);
|
||||
ALTER TABLE alert_rules ADD CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum);
|
||||
8
backend/infra/migrations/009_add_subscription_tier.sql
Normal file
8
backend/infra/migrations/009_add_subscription_tier.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_tier VARCHAR(20) DEFAULT 'free';
|
||||
|
||||
-- Existing active/trialing subscribers were on the single paid plan,
|
||||
-- which is now the "premium" tier. Backfill them so they aren't downgraded.
|
||||
UPDATE users
|
||||
SET subscription_tier = 'premium'
|
||||
WHERE subscription_status IN ('active', 'trialing')
|
||||
AND stripe_subscription_id IS NOT NULL;
|
||||
@@ -11,6 +11,11 @@ CREATE TABLE users (
|
||||
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()
|
||||
);
|
||||
@@ -31,6 +36,8 @@ CREATE TABLE alert_rules (
|
||||
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
|
||||
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)
|
||||
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,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
@@ -40,7 +47,11 @@ CREATE TABLE alert_rules (
|
||||
|
||||
CONSTRAINT positive_threshold CHECK (
|
||||
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)
|
||||
);
|
||||
|
||||
|
||||
@@ -73,6 +84,7 @@ CREATE TABLE user_notification_configs (
|
||||
|
||||
-- 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_alert_rules_address_id ON alert_rules(address_id);
|
||||
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);
|
||||
@@ -32,6 +32,12 @@ type Config struct {
|
||||
ResendAPIKey string
|
||||
EmailFrom string
|
||||
DigestIntervalHours int
|
||||
StripeSecretKey string
|
||||
StripeWebhookSecret string
|
||||
StripePriceIDPremium string
|
||||
StripePriceIDPro string
|
||||
StripePublishableKey string
|
||||
FrontendURL string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and returns a Config.
|
||||
@@ -51,7 +57,13 @@ func Load() (*Config, error) {
|
||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
||||
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 {
|
||||
@@ -82,6 +94,19 @@ func (c *Config) DSN() string {
|
||||
)
|
||||
}
|
||||
|
||||
// TierForPriceID maps a Stripe price ID back to the corresponding
|
||||
// subscription tier. Returns empty string if the price is unrecognised.
|
||||
func (c *Config) TierForPriceID(priceID string) string {
|
||||
switch priceID {
|
||||
case c.StripePriceIDPremium:
|
||||
return "premium"
|
||||
case c.StripePriceIDPro:
|
||||
return "pro"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
28
backend/internal/config/config_test.go
Normal file
28
backend/internal/config/config_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTierForPriceID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &Config{
|
||||
StripePriceIDPremium: "price_premium_123",
|
||||
StripePriceIDPro: "price_pro_456",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
priceID string
|
||||
want string
|
||||
}{
|
||||
{"price_premium_123", "premium"},
|
||||
{"price_pro_456", "pro"},
|
||||
{"price_unknown", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := cfg.TierForPriceID(tt.priceID); got != tt.want {
|
||||
t.Errorf("TierForPriceID(%q) = %q, want %q", tt.priceID, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,77 @@ package domain
|
||||
|
||||
import "time"
|
||||
|
||||
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
|
||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
|
||||
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 {
|
||||
@@ -43,7 +107,7 @@ var ThresholdRequiredTypes = []AlertType{ //nolint:gochecknoglobals
|
||||
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 {
|
||||
for _, v := range ValidAlertTypes {
|
||||
if string(v) == t {
|
||||
@@ -54,7 +118,6 @@ func IsValidAlertType(t string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsThresholdRequired returns true if the given AlertType requires a threshold.
|
||||
func IsThresholdRequired(t AlertType) bool {
|
||||
for _, v := range ThresholdRequiredTypes {
|
||||
if v == t {
|
||||
@@ -70,6 +133,8 @@ type AlertRule struct {
|
||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||
Type AlertType `json:"type"`
|
||||
Threshold *float64 `json:"threshold"`
|
||||
Minimum *float64 `json:"minimum"`
|
||||
Maximum *float64 `json:"maximum"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||
}
|
||||
@@ -89,7 +154,6 @@ type AddressCheckpoint struct {
|
||||
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// CheckpointDetail combines checkpoint and address info for reporting.
|
||||
type CheckpointDetail struct {
|
||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||
Address string `json:"address"`
|
||||
@@ -98,7 +162,7 @@ type CheckpointDetail struct {
|
||||
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 {
|
||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
||||
@@ -127,14 +191,12 @@ type NormalizedTx struct {
|
||||
TokenValue *string `json:"token_value,omitempty"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// IsTokenTransfer returns true if this transaction represents an ERC-20 token transfer.
|
||||
func (tx NormalizedTx) IsTokenTransfer() bool {
|
||||
return tx.TokenContract != nil
|
||||
}
|
||||
|
||||
type Direction string
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (d Direction) String() string { return string(d) }
|
||||
|
||||
const (
|
||||
104
backend/internal/domain/types_test.go
Normal file
104
backend/internal/domain/types_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsValidTier(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
valid := []string{"free", "premium", "pro"}
|
||||
for _, tier := range valid {
|
||||
if !IsValidTier(tier) {
|
||||
t.Errorf("expected %q to be valid", tier)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{"", "basic", "enterprise", "FREE", "Pro"}
|
||||
for _, tier := range invalid {
|
||||
if IsValidTier(tier) {
|
||||
t.Errorf("expected %q to be invalid", tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTierLimits_Free(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := GetTierLimits(TierFree)
|
||||
if limits.MaxAddresses != 1 {
|
||||
t.Errorf("free MaxAddresses = %d, want 1", limits.MaxAddresses)
|
||||
}
|
||||
if limits.MaxAlertTypes != 1 {
|
||||
t.Errorf("free MaxAlertTypes = %d, want 1", limits.MaxAlertTypes)
|
||||
}
|
||||
if len(limits.AllowedChannels) != 1 || limits.AllowedChannels[0] != "email" {
|
||||
t.Errorf("free AllowedChannels = %v, want [email]", limits.AllowedChannels)
|
||||
}
|
||||
if limits.IsUnlimitedAddresses() {
|
||||
t.Error("free should not have unlimited addresses")
|
||||
}
|
||||
if limits.IsUnlimitedAlertTypes() {
|
||||
t.Error("free should not have unlimited alert types")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTierLimits_Premium(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := GetTierLimits(TierPremium)
|
||||
if limits.MaxAddresses != 3 {
|
||||
t.Errorf("premium MaxAddresses = %d, want 3", limits.MaxAddresses)
|
||||
}
|
||||
if limits.MaxAlertTypes != 2 {
|
||||
t.Errorf("premium MaxAlertTypes = %d, want 2", limits.MaxAlertTypes)
|
||||
}
|
||||
if !limits.ChannelAllowed("email") {
|
||||
t.Error("premium should allow email")
|
||||
}
|
||||
if !limits.ChannelAllowed("discord") {
|
||||
t.Error("premium should allow discord")
|
||||
}
|
||||
if !limits.ChannelAllowed("telegram") {
|
||||
t.Error("premium should allow telegram")
|
||||
}
|
||||
if limits.ChannelAllowed("slack") {
|
||||
t.Error("premium should NOT allow slack")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTierLimits_Pro(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := GetTierLimits(TierPro)
|
||||
if !limits.IsUnlimitedAddresses() {
|
||||
t.Error("pro should have unlimited addresses")
|
||||
}
|
||||
if !limits.IsUnlimitedAlertTypes() {
|
||||
t.Error("pro should have unlimited alert types")
|
||||
}
|
||||
for _, ch := range []string{"email", "discord", "telegram", "slack"} {
|
||||
if !limits.ChannelAllowed(ch) {
|
||||
t.Errorf("pro should allow %s", ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTierLimits_Unknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := GetTierLimits(SubscriptionTier("unknown"))
|
||||
if limits.MaxAddresses != 1 {
|
||||
t.Errorf("unknown tier should default to free limits, got MaxAddresses=%d", limits.MaxAddresses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelAllowed_NotInList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := GetTierLimits(TierFree)
|
||||
if limits.ChannelAllowed("discord") {
|
||||
t.Error("free should not allow discord")
|
||||
}
|
||||
if limits.ChannelAllowed("nonexistent") {
|
||||
t.Error("nonexistent channel should not be allowed")
|
||||
}
|
||||
}
|
||||
88
backend/internal/handlers/account.go
Normal file
88
backend/internal/handlers/account.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
type AccountHandler struct {
|
||||
users *models.UserModel
|
||||
addresses *models.AddressModel
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewAccountHandler(users *models.UserModel, addresses *models.AddressModel, cfg *config.Config) *AccountHandler {
|
||||
return &AccountHandler{users: users, addresses: addresses, cfg: cfg}
|
||||
}
|
||||
|
||||
type accountResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
UserName string `json:"user_name"`
|
||||
SubscriptionStatus string `json:"subscription_status"`
|
||||
SubscriptionTier string `json:"subscription_tier"`
|
||||
SubscriptionPlan string `json:"subscription_plan"`
|
||||
TierLimits domain.TierLimits `json:"tier_limits"`
|
||||
AddressCount int `json:"address_count"`
|
||||
MemberSince *string `json:"member_since,omitempty"`
|
||||
NextBillingDate *string `json:"next_billing_date,omitempty"`
|
||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||
PeriodEndDate *string `json:"period_end_date,omitempty"`
|
||||
}
|
||||
|
||||
var tierPlanLabels = map[domain.SubscriptionTier]string{ //nolint:gochecknoglobals
|
||||
domain.TierFree: "Free Trial",
|
||||
domain.TierPremium: "Premium / $1.99 mo",
|
||||
domain.TierPro: "Pro / $11.99 mo",
|
||||
}
|
||||
|
||||
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
email := middleware.GetUserEmail(r.Context())
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Account: failed to get user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
addrCount, err := h.addresses.CountByUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Printf("Account: failed to count addresses for %s: %v", userID, err)
|
||||
addrCount = 0
|
||||
}
|
||||
|
||||
planLabel := tierPlanLabels[user.SubscriptionTier]
|
||||
if planLabel == "" {
|
||||
planLabel = "Free Trial"
|
||||
}
|
||||
|
||||
resp := accountResponse{
|
||||
UserID: user.ID,
|
||||
Email: email,
|
||||
UserName: email,
|
||||
SubscriptionStatus: user.SubscriptionStatus,
|
||||
SubscriptionTier: string(user.SubscriptionTier),
|
||||
SubscriptionPlan: planLabel,
|
||||
TierLimits: domain.GetTierLimits(user.SubscriptionTier),
|
||||
AddressCount: addrCount,
|
||||
}
|
||||
|
||||
if user.SubscriptionCreatedAt != nil {
|
||||
t := user.SubscriptionCreatedAt.Format(time.DateOnly)
|
||||
resp.MemberSince = &t
|
||||
}
|
||||
|
||||
resp.NextBillingDate = nil
|
||||
resp.CancelAtPeriodEnd = false
|
||||
resp.PeriodEndDate = nil
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -3,24 +3,26 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
|
||||
|
||||
type AddressHandler struct {
|
||||
addresses *models.AddressModel
|
||||
users *models.UserModel
|
||||
}
|
||||
|
||||
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
|
||||
return &AddressHandler{addresses: addresses}
|
||||
func NewAddressHandler(addresses *models.AddressModel, users *models.UserModel) *AddressHandler {
|
||||
return &AddressHandler{addresses: addresses, users: users}
|
||||
}
|
||||
|
||||
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -49,6 +51,28 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
|
||||
addr, err := h.addresses.Create(r.Context(), userID, body.Address, body.Label)
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
// AlertEventHandler handles HTTP requests for alert event history.
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
var errThresholdFormat = errors.New("unsupported threshold format")
|
||||
@@ -19,10 +19,11 @@ var errThresholdFormat = errors.New("unsupported threshold format")
|
||||
type AlertRuleHandler struct {
|
||||
alertRules *models.AlertRuleModel
|
||||
addresses *models.AddressModel
|
||||
users *models.UserModel
|
||||
}
|
||||
|
||||
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
|
||||
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses}
|
||||
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel, users *models.UserModel) *AlertRuleHandler {
|
||||
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses, users: users}
|
||||
}
|
||||
|
||||
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -37,6 +38,8 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Type string `json:"type"`
|
||||
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 {
|
||||
log.Printf("Failed to decode alert request body: %v", err)
|
||||
@@ -53,6 +56,40 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
|
||||
if body.Type == "" {
|
||||
@@ -96,7 +133,29 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
log.Printf("Error creating alert rule: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
|
||||
@@ -182,7 +241,7 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
|
||||
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) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
alertID, ok := parseIntParam(r.PathValue("alertId"))
|
||||
@@ -193,7 +252,10 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Printf("Failed to decode update request body: %v", err)
|
||||
@@ -204,8 +266,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
log.Printf("User %s updating alert ID: %d", userID, alertID)
|
||||
|
||||
if body.Enabled == nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value")
|
||||
if body.Enabled == nil && !body.UpdateMinMax {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "must provide enabled (boolean) or update_min_max with minimum/maximum values")
|
||||
|
||||
return
|
||||
}
|
||||
@@ -224,15 +286,64 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
|
||||
if err != nil {
|
||||
log.Printf("Error updating alert: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||
var updated *domain.AlertRule
|
||||
|
||||
return
|
||||
if body.UpdateMinMax {
|
||||
minimum, parseErr := parseThreshold(body.Minimum)
|
||||
if parseErr != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
maximum, parseErr := parseThreshold(body.Maximum)
|
||||
if parseErr != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if minimum != nil && *minimum < 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if maximum != nil && *maximum < 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if minimum != nil && maximum != nil && *minimum > *maximum {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
updated, err = h.alertRules.UpdateThresholds(r.Context(), alertID, minimum, maximum)
|
||||
if err != nil {
|
||||
log.Printf("Error updating alert thresholds: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Alert %d thresholds updated: min=%v, max=%v", alertID, minimum, maximum)
|
||||
}
|
||||
|
||||
if body.Enabled != nil {
|
||||
updated, err = h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
|
||||
if err != nil {
|
||||
log.Printf("Error updating alert: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
|
||||
}
|
||||
|
||||
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||
)
|
||||
|
||||
type EmailDigestHandler struct {
|
||||
@@ -7,22 +7,23 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"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/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"
|
||||
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||
)
|
||||
|
||||
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||
|
||||
type NotificationConfigHandler struct {
|
||||
configs *models.NotificationConfigModel
|
||||
users *models.UserModel
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, cfg *config.Config) *NotificationConfigHandler {
|
||||
return &NotificationConfigHandler{configs: configs, cfg: cfg}
|
||||
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, users *models.UserModel, cfg *config.Config) *NotificationConfigHandler {
|
||||
return &NotificationConfigHandler{configs: configs, users: users, cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -95,6 +96,26 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
||||
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
|
||||
if body.NotificationEnabled != nil {
|
||||
enabled = *body.NotificationEnabled
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
// StatusHandler handles the system status endpoint.
|
||||
429
backend/internal/handlers/stripe.go
Normal file
429
backend/internal/handlers/stripe.go
Normal file
@@ -0,0 +1,429 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/stripe/stripe-go/v82"
|
||||
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
|
||||
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
|
||||
"github.com/stripe/stripe-go/v82/webhook"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
const webhookMaxBodyBytes = 65536
|
||||
|
||||
type StripeHandler struct {
|
||||
users *models.UserModel
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler {
|
||||
stripe.Key = cfg.StripeSecretKey
|
||||
return &StripeHandler{users: users, cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier) (string, error) {
|
||||
switch tier {
|
||||
case domain.TierPremium:
|
||||
return h.cfg.StripePriceIDPremium, nil
|
||||
case domain.TierPro:
|
||||
return h.cfg.StripePriceIDPro, nil
|
||||
default:
|
||||
return "", fmt.Errorf("no Stripe price for tier %q", tier) //nolint:err113
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCheckoutSession creates a Stripe Checkout session for the selected tier.
|
||||
func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
var body struct {
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if body.Tier == "" {
|
||||
body.Tier = "premium"
|
||||
}
|
||||
|
||||
tier := domain.SubscriptionTier(body.Tier)
|
||||
if tier != domain.TierPremium && tier != domain.TierPro {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'")
|
||||
return
|
||||
}
|
||||
|
||||
priceID, err := h.priceIDForTier(tier)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Failed to get user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
|
||||
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
|
||||
ClientReferenceID: stripe.String(userID),
|
||||
CustomerEmail: stripe.String(user.Email),
|
||||
}
|
||||
|
||||
params.AddMetadata("tier", string(tier))
|
||||
|
||||
if user.StripeCustomerID != nil && *user.StripeCustomerID != "" {
|
||||
params.Customer = user.StripeCustomerID
|
||||
params.CustomerEmail = nil
|
||||
}
|
||||
|
||||
s, err := checkoutsession.New(params)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Stripe checkout session: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||
}
|
||||
|
||||
// GetSubscriptionStatus returns the current user's subscription state.
|
||||
func (h *StripeHandler) GetSubscriptionStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Failed to get user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"subscription_status": user.SubscriptionStatus,
|
||||
"subscription_tier": user.SubscriptionTier,
|
||||
"subscription_created_at": user.SubscriptionCreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyCheckoutSession retrieves a completed checkout session from Stripe,
|
||||
// confirms payment, and activates the user's subscription in the database.
|
||||
func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
var body struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.SessionID == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Missing session_id")
|
||||
return
|
||||
}
|
||||
|
||||
s, err := checkoutsession.Get(body.SessionID, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to retrieve checkout session %s: %v", body.SessionID, err)
|
||||
writeError(w, http.StatusBadRequest, "STRIPE_ERROR", "Invalid checkout session")
|
||||
return
|
||||
}
|
||||
|
||||
if s.ClientReferenceID != "" && s.ClientReferenceID != userID {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
|
||||
return
|
||||
}
|
||||
|
||||
if s.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid {
|
||||
writeError(w, http.StatusBadRequest, "PAYMENT_INCOMPLETE", "Payment has not been completed")
|
||||
return
|
||||
}
|
||||
|
||||
tier := domain.TierPremium
|
||||
if t, ok := s.Metadata["tier"]; ok && domain.IsValidTier(t) {
|
||||
tier = domain.SubscriptionTier(t)
|
||||
}
|
||||
|
||||
customerID := ""
|
||||
if s.Customer != nil {
|
||||
customerID = s.Customer.ID
|
||||
}
|
||||
subscriptionID := ""
|
||||
if s.Subscription != nil {
|
||||
subscriptionID = s.Subscription.ID
|
||||
}
|
||||
|
||||
if customerID != "" {
|
||||
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
|
||||
log.Printf("VerifyCheckout: failed to save customer ID: %v", err)
|
||||
}
|
||||
}
|
||||
if subscriptionID != "" && customerID != "" {
|
||||
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
|
||||
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Checkout verified for user %s, customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier)
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"subscription_status": "active",
|
||||
"subscription_tier": string(tier),
|
||||
})
|
||||
}
|
||||
|
||||
// ActivateFreeTier sets the user to the free tier without Stripe involvement.
|
||||
func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
if err := h.users.ActivateFreeTier(r.Context(), userID); err != nil {
|
||||
log.Printf("ActivateFreeTier: failed for user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to activate free tier")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Free tier activated for user %s", userID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"subscription_status": "active",
|
||||
"subscription_tier": "free",
|
||||
})
|
||||
}
|
||||
|
||||
// CreateOnboardingCheckout creates a Stripe Checkout session for a user who
|
||||
// has not yet created an account. This is a public endpoint (no auth required).
|
||||
// The Firebase account is created on the frontend only after payment succeeds.
|
||||
func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if body.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email is required")
|
||||
return
|
||||
}
|
||||
|
||||
if body.Tier == "" {
|
||||
body.Tier = "premium"
|
||||
}
|
||||
|
||||
tier := domain.SubscriptionTier(body.Tier)
|
||||
if tier != domain.TierPremium && tier != domain.TierPro {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'")
|
||||
return
|
||||
}
|
||||
|
||||
priceID, err := h.priceIDForTier(tier)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
|
||||
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
|
||||
CustomerEmail: stripe.String(body.Email),
|
||||
}
|
||||
params.AddMetadata("tier", string(tier))
|
||||
params.AddMetadata("onboarding", "true")
|
||||
|
||||
s, err := checkoutsession.New(params)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create onboarding checkout session: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||
}
|
||||
|
||||
// CreatePortalSession creates a Stripe Billing Portal session so the user can
|
||||
// manage their subscription (cancel, update payment method, view invoices).
|
||||
func (h *StripeHandler) CreatePortalSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Portal: failed to get user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
if user.StripeCustomerID == nil || *user.StripeCustomerID == "" {
|
||||
writeError(w, http.StatusBadRequest, "NO_CUSTOMER", "No Stripe customer on file")
|
||||
return
|
||||
}
|
||||
|
||||
params := &stripe.BillingPortalSessionParams{
|
||||
Customer: user.StripeCustomerID,
|
||||
ReturnURL: stripe.String(h.cfg.FrontendURL + "/account"),
|
||||
}
|
||||
|
||||
s, err := portalsession.New(params)
|
||||
if err != nil {
|
||||
log.Printf("Portal: failed to create portal session: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create portal session")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||
}
|
||||
|
||||
// HandleWebhook processes incoming Stripe webhook events.
|
||||
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes))
|
||||
if err != nil {
|
||||
log.Printf("Error reading webhook body: %v", err)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
sig := r.Header.Get("Stripe-Signature")
|
||||
event, err := webhook.ConstructEvent(payload, sig, h.cfg.StripeWebhookSecret)
|
||||
if err != nil {
|
||||
log.Printf("Webhook signature verification failed: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "checkout.session.completed":
|
||||
h.handleCheckoutCompleted(r, event)
|
||||
case "customer.subscription.updated":
|
||||
h.handleSubscriptionUpdated(r, event)
|
||||
case "customer.subscription.deleted":
|
||||
h.handleSubscriptionDeleted(r, event)
|
||||
default:
|
||||
log.Printf("Unhandled Stripe event type: %s", event.Type)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Event) {
|
||||
var session stripe.CheckoutSession
|
||||
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
|
||||
log.Printf("Error parsing checkout session: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := session.ClientReferenceID
|
||||
if userID == "" {
|
||||
log.Println("Checkout session missing client_reference_id")
|
||||
return
|
||||
}
|
||||
|
||||
tier := domain.TierPremium
|
||||
if t, ok := session.Metadata["tier"]; ok && domain.IsValidTier(t) {
|
||||
tier = domain.SubscriptionTier(t)
|
||||
}
|
||||
|
||||
customerID := ""
|
||||
if session.Customer != nil {
|
||||
customerID = session.Customer.ID
|
||||
}
|
||||
subscriptionID := ""
|
||||
if session.Subscription != nil {
|
||||
subscriptionID = session.Subscription.ID
|
||||
}
|
||||
|
||||
if customerID != "" {
|
||||
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
|
||||
log.Printf("Failed to save Stripe customer ID: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if subscriptionID != "" && customerID != "" {
|
||||
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
|
||||
log.Printf("Failed to activate subscription: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Checkout completed for user %s, customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier)
|
||||
}
|
||||
|
||||
func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.Event) {
|
||||
var sub stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||
log.Printf("Error parsing subscription update: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
customerID := ""
|
||||
if sub.Customer != nil {
|
||||
customerID = sub.Customer.ID
|
||||
}
|
||||
if customerID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
status := string(sub.Status)
|
||||
|
||||
// Detect tier from the subscription's current price so that
|
||||
// upgrades/downgrades via the Stripe portal are reflected.
|
||||
tier := domain.TierPremium
|
||||
if sub.Items != nil {
|
||||
for _, item := range sub.Items.Data {
|
||||
if item.Price != nil {
|
||||
if t := h.cfg.TierForPriceID(item.Price.ID); t != "" {
|
||||
tier = domain.SubscriptionTier(t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil {
|
||||
log.Printf("Failed to update subscription: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID)
|
||||
}
|
||||
|
||||
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {
|
||||
var sub stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||
log.Printf("Error parsing subscription deletion: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
customerID := ""
|
||||
if sub.Customer != nil {
|
||||
customerID = sub.Customer.ID
|
||||
}
|
||||
if customerID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.users.UpdateSubscriptionStatus(r.Context(), customerID, "canceled"); err != nil {
|
||||
log.Printf("Failed to mark subscription canceled: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Subscription canceled for customer %s", customerID)
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
fbauth "github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
@@ -17,6 +17,7 @@ type contextKey string
|
||||
const (
|
||||
UserIDKey contextKey = "user_id"
|
||||
UserEmailKey contextKey = "user_email"
|
||||
UserTierKey contextKey = "user_tier"
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
@@ -94,12 +95,50 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||
|
||||
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
|
||||
@@ -113,3 +152,10 @@ func GetUserEmail(ctx context.Context) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetUserTier(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(UserTierKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return "free"
|
||||
}
|
||||
60
backend/internal/middleware/auth_test.go
Normal file
60
backend/internal/middleware/auth_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetUserID_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
if id := GetUserID(ctx); id != "" {
|
||||
t.Errorf("expected empty user ID, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserID_Set(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), UserIDKey, "abc-123")
|
||||
if id := GetUserID(ctx); id != "abc-123" {
|
||||
t.Errorf("expected abc-123, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserEmail_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
if email := GetUserEmail(ctx); email != "" {
|
||||
t.Errorf("expected empty email, got %q", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserEmail_Set(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), UserEmailKey, "test@example.com")
|
||||
if email := GetUserEmail(ctx); email != "test@example.com" {
|
||||
t.Errorf("expected test@example.com, got %q", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserTier_Default(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
if tier := GetUserTier(ctx); tier != "free" {
|
||||
t.Errorf("expected default tier 'free', got %q", tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserTier_Set(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), UserTierKey, "pro")
|
||||
if tier := GetUserTier(ctx); tier != "pro" {
|
||||
t.Errorf("expected 'pro', got %q", tier)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type AddressModel struct {
|
||||
@@ -55,12 +55,14 @@ func (m *AddressModel) ListByUser(ctx context.Context, userID string) ([]domain.
|
||||
return addresses, rows.Err()
|
||||
}
|
||||
|
||||
// ListAll returns all addresses system-wide (used by the poller).
|
||||
// ListAll returns addresses for users with an active subscription (used by the poller).
|
||||
func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) {
|
||||
rows, err := m.pool.Query(ctx,
|
||||
`SELECT id, user_id, address, label, created_at
|
||||
FROM addresses
|
||||
ORDER BY created_at DESC`,
|
||||
`SELECT a.id, a.user_id, a.address, a.label, a.created_at
|
||||
FROM addresses a
|
||||
JOIN users u ON u.id = a.user_id
|
||||
WHERE u.subscription_status IN ('active', 'trialing')
|
||||
ORDER BY a.created_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -125,6 +127,15 @@ func (m *AddressModel) UpdateLabel(ctx context.Context, id int, userID string, l
|
||||
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) {
|
||||
tag, err := m.pool.Exec(ctx,
|
||||
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`,
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"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 {
|
||||
142
backend/internal/models/alert_rule.go
Normal file
142
backend/internal/models/alert_rule.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type AlertRuleModel struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel {
|
||||
return &AlertRuleModel{pool: pool}
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold, minimum, maximum *float64) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO alert_rules (address_id, type, threshold, minimum, maximum, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, TRUE)
|
||||
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||
addressID, alertType.String(), threshold, minimum, maximum,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
|
||||
rows, err := m.pool.Query(ctx,
|
||||
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
|
||||
FROM alert_rules
|
||||
WHERE address_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
addressID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rules []domain.AlertRule
|
||||
for rows.Next() {
|
||||
var r domain.AlertRule
|
||||
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
var err error
|
||||
|
||||
if userID != nil {
|
||||
err = m.pool.QueryRow(ctx,
|
||||
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.minimum, ar.maximum, ar.enabled, ar.created_at
|
||||
FROM alert_rules ar
|
||||
JOIN addresses a ON a.id = ar.address_id
|
||||
WHERE ar.id = $1 AND a.user_id = $2`,
|
||||
id, *userID,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
} else {
|
||||
err = m.pool.QueryRow(ctx,
|
||||
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
|
||||
FROM alert_rules
|
||||
WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`UPDATE alert_rules
|
||||
SET enabled = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||
id, enabled,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) UpdateThresholds(ctx context.Context, id int, minimum, maximum *float64) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`UPDATE alert_rules
|
||||
SET minimum = $2, maximum = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||
id, minimum, maximum,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) CountDistinctTypesByAddress(ctx context.Context, addressID int) (int, error) {
|
||||
var count int
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(DISTINCT type) FROM alert_rules WHERE address_id = $1`,
|
||||
addressID,
|
||||
).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
|
||||
tag, err := m.pool.Exec(ctx,
|
||||
`DELETE FROM alert_rules WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"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 {
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"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 {
|
||||
@@ -77,10 +77,12 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
|
||||
|
||||
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
||||
rows, err := m.pool.Query(ctx,
|
||||
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||
email, slack_webhook_url
|
||||
FROM user_notification_configs
|
||||
WHERE notification_enabled = TRUE`,
|
||||
`SELECT nc.user_id, nc.discord_webhook_url, nc.telegram_chat_id, nc.telegram_bot_token,
|
||||
nc.email, nc.slack_webhook_url
|
||||
FROM user_notification_configs nc
|
||||
JOIN users u ON u.id = nc.user_id
|
||||
WHERE nc.notification_enabled = TRUE
|
||||
AND u.subscription_status IN ('active', 'trialing')`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
111
backend/internal/models/user.go
Normal file
111
backend/internal/models/user.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type UserModel struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewUserModel(pool *pgxpool.Pool) *UserModel {
|
||||
return &UserModel{pool: pool}
|
||||
}
|
||||
|
||||
const userColumns = `id, firebase_uid, email, display_name,
|
||||
stripe_customer_id, stripe_subscription_id, subscription_status,
|
||||
subscription_tier, subscription_created_at, created_at, updated_at`
|
||||
|
||||
func scanUser(row pgx.Row) (*domain.User, error) {
|
||||
var u domain.User
|
||||
err := row.Scan(
|
||||
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
|
||||
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
|
||||
&u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
|
||||
// creating one if it doesn't exist yet. On conflict (returning user) the
|
||||
// updated_at timestamp is refreshed.
|
||||
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
|
||||
row := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (firebase_uid, email)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
|
||||
RETURNING `+userColumns,
|
||||
firebaseUID, email,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
row := m.pool.QueryRow(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE id = $1`, id,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,
|
||||
userID, stripeCustomerID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string, tier domain.SubscriptionTier) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users
|
||||
SET stripe_subscription_id = $2,
|
||||
subscription_status = $3,
|
||||
subscription_tier = $4,
|
||||
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE stripe_customer_id = $1`,
|
||||
stripeCustomerID, subscriptionID, status, string(tier),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *UserModel) UpdateSubscriptionTier(ctx context.Context, userID string, tier domain.SubscriptionTier) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users SET subscription_tier = $2, updated_at = NOW() WHERE id = $1`,
|
||||
userID, string(tier),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *UserModel) ActivateFreeTier(ctx context.Context, userID string) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users
|
||||
SET subscription_status = 'active',
|
||||
subscription_tier = 'free',
|
||||
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *UserModel) UpdateSubscriptionStatus(ctx context.Context, stripeCustomerID, status string) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users SET subscription_status = $2, updated_at = NOW()
|
||||
WHERE stripe_customer_id = $1`,
|
||||
stripeCustomerID, status,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -21,17 +21,14 @@ const (
|
||||
colorBlue = 0x0099ff
|
||||
)
|
||||
|
||||
// discordHTTPClient is a shared HTTP client with a timeout for Discord requests.
|
||||
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
||||
}
|
||||
|
||||
// DiscordNotifier sends alert notifications via a Discord webhook.
|
||||
// sends alert notifications via a Discord webhook.
|
||||
type DiscordNotifier struct {
|
||||
WebhookURL string
|
||||
}
|
||||
|
||||
// Send implements Notifier for Discord.
|
||||
func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||
_, err := SendDiscordNotification(d.WebhookURL, message, meta)
|
||||
return err
|
||||
@@ -104,8 +101,12 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
|
||||
defer resp.Body.Close()
|
||||
|
||||
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)
|
||||
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
|
||||
@@ -10,14 +10,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// EmailNotifier sends alert notifications via email (Resend).
|
||||
type EmailNotifier struct {
|
||||
APIKey string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
// Send implements Notifier for email.
|
||||
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
|
||||
return err
|
||||
@@ -99,8 +97,12 @@ func SendEmailNotification(apiKey, fromAddress, toAddress, message string, meta
|
||||
defer resp.Body.Close()
|
||||
|
||||
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)
|
||||
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
|
||||
38
backend/internal/notifications/notifier.go
Normal file
38
backend/internal/notifications/notifier.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AlertMetadata holds context about the alert being sent.
|
||||
type AlertMetadata struct {
|
||||
TxHash string
|
||||
AddressLabel string
|
||||
AlertType string
|
||||
Address string
|
||||
}
|
||||
|
||||
type Notifier interface {
|
||||
Send(ctx context.Context, message string, meta AlertMetadata) error
|
||||
}
|
||||
|
||||
// PermanentError wraps errors that should not be retried (e.g. 401, 403, 404).
|
||||
type PermanentError struct{ Err error }
|
||||
|
||||
func (e *PermanentError) Error() string { return e.Err.Error() }
|
||||
func (e *PermanentError) Unwrap() error { return e.Err }
|
||||
|
||||
func IsPermanent(err error) bool {
|
||||
var p *PermanentError
|
||||
return errors.As(err, &p)
|
||||
}
|
||||
|
||||
func isPermanentStatusCode(code int) bool {
|
||||
return code == http.StatusUnauthorized ||
|
||||
code == http.StatusForbidden ||
|
||||
code == http.StatusNotFound ||
|
||||
code == http.StatusMethodNotAllowed ||
|
||||
code == http.StatusGone
|
||||
}
|
||||
@@ -87,8 +87,12 @@ func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool
|
||||
defer resp.Body.Close()
|
||||
|
||||
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)
|
||||
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
|
||||
@@ -63,8 +63,12 @@ func SendTelegramNotification(botToken, chatID, message string, meta AlertMetada
|
||||
defer resp.Body.Close()
|
||||
|
||||
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)
|
||||
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
|
||||
@@ -12,18 +12,20 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
rpcTimeoutMS = 30000
|
||||
rpcMaxRetries = 3
|
||||
rpcRetryBaseMS = 1000
|
||||
rpcTimeoutMS = 30000
|
||||
rpcMaxRetries = 3
|
||||
rpcRetryBaseMS = 2000
|
||||
rpcMinIntervalMS = 1000
|
||||
)
|
||||
|
||||
type JsonRpcEthereum struct {
|
||||
rpcURL string
|
||||
client *http.Client
|
||||
rpcURL string
|
||||
client *http.Client
|
||||
lastCallAt time.Time
|
||||
}
|
||||
|
||||
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) {
|
||||
j.throttle(ctx)
|
||||
|
||||
if params == nil {
|
||||
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 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.
|
||||
@@ -3,7 +3,7 @@ package ethereum
|
||||
import (
|
||||
"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.
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
|
||||
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 {
|
||||
apiKey string
|
||||
fromAddress string
|
||||
@@ -66,7 +66,6 @@ func (s *EmailDigestService) SetupEmail(toAddress string) error {
|
||||
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
|
||||
}
|
||||
|
||||
// SendDigest compiles recent alert events for a user and sends a digest email.
|
||||
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
|
||||
if !s.Configured() {
|
||||
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
||||
@@ -131,8 +130,6 @@ func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress s
|
||||
return s.send(toAddress, subject, html)
|
||||
}
|
||||
|
||||
// SendDigestsForAllUsers sends a digest email to every user that has
|
||||
// notifications enabled and an email configured.
|
||||
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
|
||||
if !s.Configured() {
|
||||
return 0, nil
|
||||
@@ -4,19 +4,23 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/wei"
|
||||
"golang.org/x/sync/semaphore"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend/internal/wei"
|
||||
)
|
||||
|
||||
const (
|
||||
notificationTimeout = 30 * time.Second
|
||||
notificationMaxRetries = 3
|
||||
notificationRetryBase = time.Second
|
||||
maxConcurrentNotifications = 5
|
||||
)
|
||||
|
||||
type EvaluatorService struct {
|
||||
@@ -24,9 +28,10 @@ type EvaluatorService struct {
|
||||
alertRules *models.AlertRuleModel
|
||||
alertEvents *models.AlertEventModel
|
||||
addresses *models.AddressModel
|
||||
users *models.UserModel
|
||||
notifConfigs *models.NotificationConfigModel
|
||||
resendAPIKey string
|
||||
emailFrom string
|
||||
notifSem *semaphore.Weighted
|
||||
notifWg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewEvaluatorService(
|
||||
@@ -34,18 +39,17 @@ func NewEvaluatorService(
|
||||
alertRules *models.AlertRuleModel,
|
||||
alertEvents *models.AlertEventModel,
|
||||
addresses *models.AddressModel,
|
||||
users *models.UserModel,
|
||||
notifConfigs *models.NotificationConfigModel,
|
||||
resendAPIKey string,
|
||||
emailFrom string,
|
||||
) *EvaluatorService {
|
||||
return &EvaluatorService{
|
||||
eth: eth,
|
||||
alertRules: alertRules,
|
||||
alertEvents: alertEvents,
|
||||
addresses: addresses,
|
||||
users: users,
|
||||
notifConfigs: notifConfigs,
|
||||
resendAPIKey: resendAPIKey,
|
||||
emailFrom: emailFrom,
|
||||
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,9 +101,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) {
|
||||
switch rule.Type {
|
||||
case domain.AlertIncomingTx:
|
||||
return obs.Direction == domain.DirectionIncoming, nil
|
||||
return s.matchesDirectionalTx(rule, obs, domain.DirectionIncoming)
|
||||
case domain.AlertOutgoingTx:
|
||||
return obs.Direction == domain.DirectionOutgoing, nil
|
||||
return s.matchesDirectionalTx(rule, obs, domain.DirectionOutgoing)
|
||||
case domain.AlertLargeTransfer:
|
||||
return s.matchesLargeTransfer(rule, obs)
|
||||
case domain.AlertBalanceBelow:
|
||||
@@ -110,6 +114,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) {
|
||||
if rule.Threshold == nil {
|
||||
return false, nil
|
||||
@@ -189,7 +229,14 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
||||
if addr != nil {
|
||||
userID := addr.UserID
|
||||
address := addr.Address
|
||||
if err := s.notifSem.Acquire(ctx, 1); err != nil {
|
||||
log.Printf("Failed to acquire notification semaphore for rule %d: %v", rule.ID, err)
|
||||
return nil
|
||||
}
|
||||
s.notifWg.Add(1)
|
||||
go func() {
|
||||
defer s.notifSem.Release(1)
|
||||
defer s.notifWg.Done()
|
||||
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
|
||||
defer cancel()
|
||||
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
|
||||
@@ -199,14 +246,21 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
|
||||
// WaitForNotifications blocks until all in-flight notification goroutines finish.
|
||||
func (s *EvaluatorService) WaitForNotifications() {
|
||||
s.notifWg.Wait()
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig, limits domain.TierLimits) []notifications.Notifier {
|
||||
var notifiers []notifications.Notifier
|
||||
|
||||
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||
if limits.ChannelAllowed("discord") &&
|
||||
cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||
notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
||||
}
|
||||
|
||||
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||
if limits.ChannelAllowed("telegram") &&
|
||||
cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||
notifiers = append(notifiers, ¬ifications.TelegramNotifier{
|
||||
BotToken: *cfg.TelegramBotToken,
|
||||
@@ -214,17 +268,12 @@ func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []noti
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
|
||||
if limits.ChannelAllowed("slack") &&
|
||||
cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
|
||||
notifiers = append(notifiers, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
|
||||
}
|
||||
|
||||
if cfg.Email != nil && *cfg.Email != "" {
|
||||
notifiers = append(notifiers, ¬ifications.EmailNotifier{
|
||||
APIKey: s.resendAPIKey,
|
||||
From: s.emailFrom,
|
||||
To: *cfg.Email,
|
||||
})
|
||||
}
|
||||
// Email is delivered exclusively via the daily digest service, never real-time.
|
||||
|
||||
return notifiers
|
||||
}
|
||||
@@ -242,8 +291,14 @@ func sendWithRetry(ctx context.Context, n notifications.Notifier, message string
|
||||
}
|
||||
|
||||
if err := n.Send(ctx, message, meta); err != nil {
|
||||
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, 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
|
||||
}
|
||||
|
||||
@@ -264,6 +319,13 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Failed to load user %s for tier check in notification: %v", userID, err)
|
||||
return
|
||||
}
|
||||
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||
|
||||
meta := notifications.AlertMetadata{
|
||||
TxHash: obs.Hash,
|
||||
AddressLabel: addressLabel,
|
||||
@@ -271,7 +333,7 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
||||
Address: address,
|
||||
}
|
||||
|
||||
for _, n := range s.buildNotifiers(notifConfig) {
|
||||
for _, n := range s.buildNotifiers(notifConfig, limits) {
|
||||
if err := sendWithRetry(ctx, n, message, meta); err != nil {
|
||||
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
|
||||
} else {
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||
)
|
||||
|
||||
const maxBlocksPerRun = 100
|
||||
@@ -90,6 +90,14 @@ func LessThan(weiA, weiB string) (bool, error) {
|
||||
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".
|
||||
func FormatAsEth(weiString string, decimals int) (string, error) {
|
||||
eth, err := ToEth(weiString)
|
||||
BIN
frontend/public/koin_exp_2.mp4
Normal file
BIN
frontend/public/koin_exp_2.mp4
Normal file
Binary file not shown.
BIN
frontend/public/koin_spin.mp4
Normal file
BIN
frontend/public/koin_spin.mp4
Normal file
Binary file not shown.
@@ -1,38 +1,50 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useAuth } from "./contexts/AuthContext";
|
||||
import Navbar from "./components/Navbar";
|
||||
import Login from "./pages/Login";
|
||||
import Login from "./pages/login/Login";
|
||||
import Signup from "./pages/Signup";
|
||||
import Onboarding from "./pages/Onboarding";
|
||||
import Addresses from "./pages/Addresses";
|
||||
import Alerts from "./pages/Alerts";
|
||||
import AlertHistory from "./pages/AlertHistory";
|
||||
import Subscribe from "./pages/subscribe/Subscribe";
|
||||
import Addresses from "./pages/addresses/Addresses";
|
||||
import Alerts from "./pages/alerts/Alerts";
|
||||
import AlertHistory from "./pages/alertHistory/AlertHistory";
|
||||
import Account from "./pages/user_account/Account";
|
||||
|
||||
export default function App() {
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/onboarding" element={<Onboarding />} />
|
||||
<Route path="*" element={<Navigate to="/login" />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
const { currentUser, isSubscribed } = useAuth();
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Addresses />} />
|
||||
<Route path="/addresses" element={<Addresses />} />
|
||||
<Route path="/alerts" element={<Alerts />} />
|
||||
<Route path="/alertevents" element={<AlertHistory />} />
|
||||
<Route path="/onboarding" element={<Onboarding />} />
|
||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
<Route path="*" element={<Navigate to="/login" />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSubscribed) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
<Route path="/account" element={<><Navbar /><Account /></>} />
|
||||
<Route path="*" element={<Navigate to="/subscribe" />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Addresses />} />
|
||||
<Route path="/addresses" element={<Addresses />} />
|
||||
<Route path="/alerts" element={<Alerts />} />
|
||||
<Route path="/alertevents" element={<AlertHistory />} />
|
||||
<Route path="/account" element={<Account />} />
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
25
frontend/src/api/account.js
Normal file
25
frontend/src/api/account.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getAuthHeaders } from "./authHeaders";
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
export async function getAccount() {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/user/account`, { headers });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to load account");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createPortalSession() {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/stripe/create-portal-session`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to create portal session");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -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
|
||||
* @param {number} alertId - Alert rule ID
|
||||
|
||||
68
frontend/src/api/stripe.jsx
Normal file
68
frontend/src/api/stripe.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { getAuthHeaders } from "./authHeaders";
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
export async function createCheckoutSession(tier = "premium") {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tier }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to create checkout session");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createOnboardingCheckout(email, tier = "premium") {
|
||||
const res = await fetch(`${API_BASE}/stripe/create-onboarding-checkout`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, tier }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to create checkout session");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function activateFreeTier() {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/stripe/activate-free`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to activate free tier");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function verifyCheckoutSession(sessionId) {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/stripe/verify-checkout`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to verify checkout session");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getSubscriptionStatus() {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/stripe/subscription-status`, {
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to get subscription status");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
.input__label {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
margin: 0.8rem 0rem 0.4rem 0rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
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;
|
||||
@@ -38,11 +51,33 @@
|
||||
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;
|
||||
@@ -54,3 +89,79 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
/* ── Hamburger Icon (hidden on desktop) ──────────────────── */
|
||||
|
||||
.navbar__hamburger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.navbar__hamburger-bar {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 3px;
|
||||
background-color: var(--color-brand);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Responsive: Tablet & below (≤ 768px) ────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.navbar__brand-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.navbar__brand {
|
||||
margin-right: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.navbar__links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar__user-link--mobile {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.navbar__user-link--desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar__logout {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar__hamburger {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Responsive: Mobile (≤ 480px) ────────────────────────── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.navbar {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.navbar__brand {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.navbar__user-link--mobile {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,71 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import NavPanel from "./navPanel/NavPanel";
|
||||
import "./Navbar.css";
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/addresses", label: "Addresses" },
|
||||
{ to: "/alerts", label: "Configure Alerts" },
|
||||
{ to: "/alertevents", label: "Alert Events" },
|
||||
{ to: "/alertevents", label: "Alert Event History" },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="flex flex--center gap-sm">
|
||||
<span className="navbar__brand">Koin Ping</span>
|
||||
<div className="navbar__links">
|
||||
{navLinks.map(({ to, label }) => {
|
||||
const isActive = location.pathname === to;
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<>
|
||||
<nav className="navbar">
|
||||
<div className="navbar__left">
|
||||
<div className="navbar__brand-group">
|
||||
<span className="navbar__brand">Koin Ping</span>
|
||||
<Link to="/account" className="navbar__user-link navbar__user-link--mobile">
|
||||
{currentUser.email}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="navbar__links">
|
||||
{navLinks.map(({ to, label }) => {
|
||||
const isActive = location.pathname === to;
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex--center gap-lg">
|
||||
<span className="navbar__user">{currentUser.email}</span>
|
||||
<button onClick={logout} className="navbar__logout">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="navbar__right">
|
||||
<Link to="/account" className="navbar__user navbar__user-link navbar__user-link--desktop">
|
||||
{currentUser.email}
|
||||
</Link>
|
||||
<button onClick={logout} className="navbar__logout">
|
||||
Logout
|
||||
</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>
|
||||
</nav>
|
||||
|
||||
<NavPanel
|
||||
isOpen={isNavPanelOpen}
|
||||
onClose={() => setIsNavPanelOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
120
frontend/src/components/TierPicker.css
Normal file
120
frontend/src/components/TierPicker.css
Normal file
@@ -0,0 +1,120 @@
|
||||
.tier-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tier-picker__card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem 1.25rem;
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tier-picker__card--highlighted {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
|
||||
.tier-picker__card--selected {
|
||||
border-color: var(--color-success);
|
||||
box-shadow: 0 0 0 1px var(--color-success);
|
||||
}
|
||||
|
||||
.tier-picker__badge {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 12px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tier-picker__name {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tier-picker__price {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.tier-picker__amount {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tier-picker__period {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-dimmed);
|
||||
}
|
||||
|
||||
.tier-picker__features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1.25rem;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.tier-picker__feature {
|
||||
padding: 0.3rem 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-label);
|
||||
}
|
||||
|
||||
.tier-picker__feature--disabled {
|
||||
color: var(--color-text-dimmed);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tier-picker__check {
|
||||
color: var(--color-primary);
|
||||
font-weight: bold;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.tier-picker__dash {
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.tier-picker__btn {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background-color: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tier-picker__btn:hover {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tier-picker__btn--selected {
|
||||
background-color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tier-picker {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
89
frontend/src/components/TierPicker.jsx
Normal file
89
frontend/src/components/TierPicker.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
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: [],
|
||||
},
|
||||
{
|
||||
id: "premium",
|
||||
name: "Premium Monitoring",
|
||||
price: "$1.99",
|
||||
period: "/month",
|
||||
features: [
|
||||
"Monitor 3 blockchain addresses",
|
||||
"Configure two types of rule-based alerts to fire on trigger events for each of the three addresses",
|
||||
"Daily email digest alert",
|
||||
"Real-time Discord alerts",
|
||||
"Real-time Telegram alerts",
|
||||
],
|
||||
disabledFeatures: [],
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Professional Monitoring",
|
||||
price: "$11.99",
|
||||
period: "/month",
|
||||
features: [
|
||||
"Monitor unlimited blockchain addresses",
|
||||
"Configure unlimited alert rules to fire on unlimited events on any address",
|
||||
"Daily email digest alert",
|
||||
"Real-time Discord alerts",
|
||||
"Real-time Telegram alerts",
|
||||
"Real-time Slack alerts configurable for multiple Slack groups or channels",
|
||||
"Unlimited transaction alert types per monitored address",
|
||||
],
|
||||
disabledFeatures: [],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TierPicker({ onSelect, selectedTier }) {
|
||||
return (
|
||||
<div className="tier-picker">
|
||||
{TIERS.map((tier) => (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={`tier-picker__card${tier.highlighted ? " tier-picker__card--highlighted" : ""}${selectedTier === tier.id ? " tier-picker__card--selected" : ""}`}
|
||||
>
|
||||
{tier.highlighted && (
|
||||
<div className="tier-picker__badge">Most Popular</div>
|
||||
)}
|
||||
<h3 className="tier-picker__name">{tier.name}</h3>
|
||||
<div className="tier-picker__price">
|
||||
<span className="tier-picker__amount">{tier.price}</span>
|
||||
{tier.period && (
|
||||
<span className="tier-picker__period">{tier.period}</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="tier-picker__features">
|
||||
{tier.features.map((f) => (
|
||||
<li key={f} className="tier-picker__feature">
|
||||
<span className="tier-picker__check">✓</span> {f}
|
||||
</li>
|
||||
))}
|
||||
{tier.disabledFeatures.map((f) => (
|
||||
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
|
||||
<span className="tier-picker__dash">—</span> {f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
|
||||
onClick={() => onSelect(tier.id)}
|
||||
>
|
||||
{selectedTier === tier.id ? "Selected" : "Select"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/UpgradeBanner.css
Normal file
32
frontend/src/components/UpgradeBanner.css
Normal file
@@ -0,0 +1,32 @@
|
||||
.upgrade-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: #3a2e00;
|
||||
border: 1px solid #aa7700;
|
||||
color: #ffcc44;
|
||||
}
|
||||
|
||||
.upgrade-banner__message {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.upgrade-banner__link {
|
||||
background: none;
|
||||
border: 1px solid #aa7700;
|
||||
color: #ffcc44;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upgrade-banner__link:hover {
|
||||
background-color: #4a3800;
|
||||
}
|
||||
18
frontend/src/components/UpgradeBanner.jsx
Normal file
18
frontend/src/components/UpgradeBanner.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./UpgradeBanner.css";
|
||||
|
||||
export default function UpgradeBanner({ message, linkTo = "/subscribe?upgrade=true" }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="upgrade-banner">
|
||||
<span className="upgrade-banner__message">{message}</span>
|
||||
<button
|
||||
className="upgrade-banner__link"
|
||||
onClick={() => navigate(linkTo)}
|
||||
>
|
||||
Upgrade
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/navPanel/NavPanel.css
Normal file
78
frontend/src/components/navPanel/NavPanel.css
Normal file
@@ -0,0 +1,78 @@
|
||||
/* ── Overlay Backdrop ─────────────────────────────────────── */
|
||||
|
||||
.nav-panel__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
.nav-panel__overlay--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ── Slide-out Panel ─────────────────────────────────────── */
|
||||
|
||||
.nav-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-right: 1px solid var(--color-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 950;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.nav-panel--open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* ── Nav Links ───────────────────────────────────────────── */
|
||||
|
||||
.nav-panel__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-panel__link {
|
||||
padding: 0.9rem 1.5rem;
|
||||
text-decoration: none;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 200;
|
||||
color: var(--color-text-subtle);
|
||||
border-left: 3px solid transparent;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-panel__link--active {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg-card);
|
||||
border-left-color: var(--color-brand);
|
||||
}
|
||||
|
||||
/* ── Logout Button ───────────────────────────────────────── */
|
||||
|
||||
.nav-panel__logout {
|
||||
margin: 1rem 1.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 400;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
52
frontend/src/components/navPanel/NavPanel.jsx
Normal file
52
frontend/src/components/navPanel/NavPanel.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import "./NavPanel.css";
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/addresses", label: "Addresses" },
|
||||
{ to: "/alerts", label: "Configure Alerts" },
|
||||
{ to: "/alertevents", label: "Alert Event History" },
|
||||
];
|
||||
|
||||
export default function NavPanel({ isOpen, onClose }) {
|
||||
const { logout } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
const handleNavClick = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
onClose();
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`nav-panel__overlay${isOpen ? " nav-panel__overlay--visible" : ""}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside className={`nav-panel${isOpen ? " nav-panel--open" : ""}`}>
|
||||
<nav className="nav-panel__nav">
|
||||
{navLinks.map(({ to, label }) => {
|
||||
const isActive = location.pathname === to;
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={`nav-panel__link${isActive ? " nav-panel__link--active" : ""}`}
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<button onClick={handleLogout} className="nav-panel__logout">
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* AuthContext - Firebase Authentication State Management
|
||||
*
|
||||
* 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 {
|
||||
createUserWithEmailAndPassword,
|
||||
signInWithEmailAndPassword,
|
||||
@@ -12,9 +12,16 @@ import {
|
||||
onAuthStateChanged,
|
||||
} from "firebase/auth";
|
||||
import { auth } from "../firebase/config";
|
||||
import { getAccount } from "../api/account";
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
const DEFAULT_TIER_LIMITS = {
|
||||
max_addresses: 1,
|
||||
max_alert_types: 1,
|
||||
allowed_channels: ["email"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to access auth context
|
||||
* @returns {Object} Auth context value
|
||||
@@ -28,16 +35,30 @@ export function useAuth() {
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthProvider - Wraps app and provides auth state
|
||||
* AuthProvider - Wraps app and provides auth state + tier info
|
||||
*/
|
||||
export function AuthProvider({ children }) {
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
const [userTier, setUserTier] = useState("free");
|
||||
const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS);
|
||||
const [addressCount, setAddressCount] = useState(0);
|
||||
const [subscriptionStatus, setSubscriptionStatus] = useState("none");
|
||||
|
||||
const refreshAccount = useCallback(async () => {
|
||||
try {
|
||||
const data = await getAccount();
|
||||
setUserTier(data.subscription_tier || "free");
|
||||
setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS);
|
||||
setAddressCount(data.address_count || 0);
|
||||
setSubscriptionStatus(data.subscription_status || "none");
|
||||
} catch {
|
||||
// account fetch can fail during onboarding before subscription is active
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function signup(email, password) {
|
||||
try {
|
||||
setError(null);
|
||||
@@ -53,9 +74,6 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in with email and password
|
||||
*/
|
||||
async function login(email, password) {
|
||||
try {
|
||||
setError(null);
|
||||
@@ -71,12 +89,13 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out current user
|
||||
*/
|
||||
async function logout() {
|
||||
try {
|
||||
setError(null);
|
||||
setUserTier("free");
|
||||
setTierLimits(DEFAULT_TIER_LIMITS);
|
||||
setAddressCount(0);
|
||||
setSubscriptionStatus("none");
|
||||
await signOut(auth);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -84,18 +103,19 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for auth state changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||
setCurrentUser(user);
|
||||
setLoading(false);
|
||||
if (user) {
|
||||
refreshAccount();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup subscription
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
}, [refreshAccount]);
|
||||
|
||||
const isSubscribed =
|
||||
subscriptionStatus === "active" || subscriptionStatus === "trialing";
|
||||
|
||||
const value = {
|
||||
currentUser,
|
||||
@@ -104,6 +124,12 @@ export function AuthProvider({ children }) {
|
||||
logout,
|
||||
error,
|
||||
loading,
|
||||
userTier,
|
||||
tierLimits,
|
||||
addressCount,
|
||||
subscriptionStatus,
|
||||
isSubscribed,
|
||||
refreshAccount,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -356,3 +356,81 @@ button {
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Responsive Breakpoints ──────────────────────────────── */
|
||||
/* Mobile: <= 480px */
|
||||
/* Tablet: 481px – 768px */
|
||||
/* Laptop: 769px – 1024px */
|
||||
/* Desktop: >= 1025px (default styles above) */
|
||||
|
||||
/* Laptop & below */
|
||||
@media (max-width: 1024px) {
|
||||
.page--wide {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet & below */
|
||||
@media (max-width: 768px) {
|
||||
html {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.btn--lg {
|
||||
padding: 0.6rem 1.2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tier-locked: greyed out, non-interactive overlay for features above the user's plan */
|
||||
.tier-locked {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
filter: grayscale(40%);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 480px) {
|
||||
html {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn--lg {
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import AddressForm from "../components/AddressForm";
|
||||
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../api/addresses";
|
||||
import "./Addresses.css";
|
||||
|
||||
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("");
|
||||
|
||||
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);
|
||||
} 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 className="page">
|
||||
<h1>Add Addresses to Track</h1>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
.alerts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.alerts__address-info {
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
background-color: var(--color-bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.alerts__toggle-panel {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.alerts__toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.alerts__toggle-checkbox {
|
||||
margin-right: 0.5rem;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.alerts__toggle-hint {
|
||||
font-size: 0.978rem;
|
||||
color: var(--color-text-dimmed);
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 26px;
|
||||
}
|
||||
|
||||
.alerts__rule {
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.alerts__rule--disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.alerts__email-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.alerts__save-test {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -1,522 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import AlertForm from "../components/AlertForm";
|
||||
import Button from "../components/Button";
|
||||
import Input from "../components/Input";
|
||||
import { getAddresses } from "../api/addresses";
|
||||
import {
|
||||
getAlerts,
|
||||
createAlert,
|
||||
updateAlertStatus,
|
||||
deleteAlert,
|
||||
} from "../api/alerts";
|
||||
import {
|
||||
getNotificationConfig,
|
||||
updateNotificationConfig,
|
||||
testNotificationChannels,
|
||||
setupEmail,
|
||||
sendEmailDigest,
|
||||
} from "../api/notificationConfig";
|
||||
import "./Alerts.css";
|
||||
|
||||
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);
|
||||
|
||||
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 className="page">Loading addresses...</div>;
|
||||
}
|
||||
|
||||
if (addresses.length === 0) {
|
||||
return (
|
||||
<div className="page">
|
||||
<p>No addresses tracked yet. Add an address first to create alerts.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page page--wide">
|
||||
<h1 className="mb-xl">Alert Rules & Notifications</h1>
|
||||
|
||||
<div className="alerts-grid">
|
||||
{/* LEFT COLUMN: Alert Rules */}
|
||||
<div>
|
||||
<h2 className="mt-0">Alert Rules</h2>
|
||||
|
||||
<div className="mb-xl">
|
||||
<label className="form-label mb-sm">
|
||||
<strong>Select Address:</strong>
|
||||
</label>
|
||||
<select
|
||||
value={selectedAddressId || ""}
|
||||
onChange={(e) =>
|
||||
setSelectedAddressId(Number(e.target.value))
|
||||
}
|
||||
className="form-select"
|
||||
>
|
||||
{addresses.map((addr) => (
|
||||
<option key={addr.id} value={addr.id}>
|
||||
{addr.label || "Unlabeled"} - {addr.address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedAddress && (
|
||||
<>
|
||||
<div className="alerts__address-info">
|
||||
<div className="text-sm text-muted">
|
||||
Managing alerts for:
|
||||
</div>
|
||||
<div className="text-bold">
|
||||
{selectedAddress.label || "Unlabeled"}
|
||||
</div>
|
||||
<div className="text-mono text-sm text-muted">
|
||||
{selectedAddress.address}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-xl">
|
||||
<h3>Create New Alert</h3>
|
||||
<AlertForm onSubmit={handleAlertSubmit} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Active Alert Rules</h3>
|
||||
{error && (
|
||||
<p className="text-error">Error: {error}</p>
|
||||
)}
|
||||
{alerts.length === 0 ? (
|
||||
<p className="text-dimmed">
|
||||
No alert rules defined yet. Create one above.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="list-unstyled">
|
||||
{alerts.map((alert) => (
|
||||
<li
|
||||
key={alert.id}
|
||||
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
||||
>
|
||||
<div className="flex flex--between">
|
||||
<div>
|
||||
<div className="text-bold mb-sm">
|
||||
{formatAlertType(alert.type)}
|
||||
</div>
|
||||
{alert.threshold && (
|
||||
<div className="text-sm text-muted">
|
||||
Threshold: {alert.threshold} ETH
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-dimmed">
|
||||
Status: {alert.enabled ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex--center gap-sm">
|
||||
<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 className="mt-0">Notification Settings</h2>
|
||||
|
||||
{notificationSuccess && (
|
||||
<div className="alert alert--success">{notificationSuccess}</div>
|
||||
)}
|
||||
|
||||
{notificationError && (
|
||||
<div className="alert alert--error">{notificationError}</div>
|
||||
)}
|
||||
|
||||
{/* Master toggle */}
|
||||
<div className="alerts__toggle-panel">
|
||||
<label className="alerts__toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationEnabled}
|
||||
onChange={(e) =>
|
||||
setNotificationEnabled(e.target.checked)
|
||||
}
|
||||
className="alerts__toggle-checkbox"
|
||||
/>
|
||||
<span className="text-bold">Enable Notifications</span>
|
||||
</label>
|
||||
<div className="alerts__toggle-hint">
|
||||
Master switch for all notification channels
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All channel settings -- hidden when master toggle is off */}
|
||||
{notificationEnabled && (
|
||||
<>
|
||||
{/* Telegram */}
|
||||
<div className="section">
|
||||
<h3 className="mt-0 mb-md">Telegram</h3>
|
||||
<Input
|
||||
label="Bot Token"
|
||||
value={telegramBotToken}
|
||||
onChange={setTelegramBotToken}
|
||||
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
||||
/>
|
||||
<Input
|
||||
label="Chat ID"
|
||||
value={telegramChatId}
|
||||
onChange={setTelegramChatId}
|
||||
placeholder="-1001234567890"
|
||||
/>
|
||||
<a
|
||||
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
How to create a Telegram bot & get your Chat ID
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="section">
|
||||
<h3 className="mt-0 mb-md">Email</h3>
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<div className="help-link text-dimmed mb-md">
|
||||
Alert notifications and digests will be sent to this address
|
||||
</div>
|
||||
<div className="alerts__email-buttons">
|
||||
<Button
|
||||
onClick={handleSetupEmail}
|
||||
disabled={settingUpEmail || !email}
|
||||
className="btn--sm"
|
||||
>
|
||||
{settingUpEmail ? "Setting up..." : "Verify Email"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSendDigest}
|
||||
disabled={sendingDigest || !email}
|
||||
variant="secondary"
|
||||
className="btn--sm"
|
||||
>
|
||||
{sendingDigest ? "Sending..." : "Send Digest Now"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discord */}
|
||||
<div className="section">
|
||||
<h3 className="mt-0 mb-md">Discord</h3>
|
||||
<Input
|
||||
label="Discord Webhook URL"
|
||||
value={discordWebhookUrl}
|
||||
onChange={setDiscordWebhookUrl}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
<a
|
||||
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
How to get a Discord webhook URL
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Slack */}
|
||||
<div className="section">
|
||||
<h3 className="mt-0 mb-md">Slack</h3>
|
||||
<Input
|
||||
label="Slack Webhook URL"
|
||||
value={slackWebhookUrl}
|
||||
onChange={setSlackWebhookUrl}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
/>
|
||||
<a
|
||||
href="https://api.slack.com/messaging/webhooks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
How to set up Slack Incoming Webhooks
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Save & Test buttons */}
|
||||
<div className="alerts__save-test">
|
||||
<Button
|
||||
onClick={handleSaveNotificationConfig}
|
||||
disabled={notificationLoading}
|
||||
className="btn--lg"
|
||||
>
|
||||
{notificationLoading ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTestChannels}
|
||||
disabled={testingChannels}
|
||||
variant="success"
|
||||
className="btn--lg"
|
||||
>
|
||||
{testingChannels ? "Testing..." : "Test All Channels"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAlertType(type) {
|
||||
const labels = {
|
||||
incoming_tx: "Incoming transaction",
|
||||
outgoing_tx: "Outgoing transaction",
|
||||
large_transfer: "Large transfer",
|
||||
balance_below: "Balance below threshold",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background-image: url(/ping.png);
|
||||
background-size: 67%;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
max-width: 400px;
|
||||
margin: 20px auto;
|
||||
padding: 2rem;
|
||||
padding-top: 8rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.login-heading {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
top: -20px;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.2rem;
|
||||
margin-top: 16px;
|
||||
;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-signup-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form-hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.login-form-visible {
|
||||
animation: fadeIn 2s ease-in-out forwards;
|
||||
}
|
||||
@@ -1,644 +0,0 @@
|
||||
/**
|
||||
* Onboarding Wizard
|
||||
*
|
||||
* 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { createAddress, getAddresses } from "../api/addresses";
|
||||
import { createAlert } from "../api/alerts";
|
||||
import {
|
||||
updateNotificationConfig,
|
||||
testNotificationChannels,
|
||||
} from "../api/notificationConfig";
|
||||
import Input from "../components/Input";
|
||||
import Button from "../components/Button";
|
||||
import "./Onboarding.css";
|
||||
|
||||
const STEPS = [
|
||||
"Create Account",
|
||||
"Add Wallet",
|
||||
"Alert Rules",
|
||||
"Notifications",
|
||||
"Done",
|
||||
];
|
||||
|
||||
export default function Onboarding() {
|
||||
const { currentUser, signup } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [skipWarning, setSkipWarning] = useState("");
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
|
||||
const [data, setData] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
walletAddress: "",
|
||||
walletLabel: "",
|
||||
createdAddressId: null,
|
||||
alertIncomingTx: false,
|
||||
alertOutgoingTx: false,
|
||||
alertLargeTransfer: false,
|
||||
largeTransferThreshold: "",
|
||||
alertBalanceBelow: false,
|
||||
balanceBelowThreshold: "",
|
||||
discordWebhookUrl: "",
|
||||
slackWebhookUrl: "",
|
||||
notificationEmail: "",
|
||||
alertsCreated: [],
|
||||
notificationConfigured: false,
|
||||
});
|
||||
|
||||
function set(field, value) {
|
||||
setData((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
getAddresses()
|
||||
.then((addresses) => {
|
||||
if (addresses.length > 0) {
|
||||
navigate("/addresses", { replace: true });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [currentUser, navigate]);
|
||||
|
||||
// ── Step handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function handleStep1() {
|
||||
setError("");
|
||||
if (!data.email || !data.password || !data.confirmPassword) {
|
||||
setError("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
if (data.password !== data.confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (data.password.length < 6) {
|
||||
setError("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
if (!currentUser) {
|
||||
try {
|
||||
setLoading(true);
|
||||
await signup(data.email, data.password);
|
||||
} catch (err) {
|
||||
if (err.code === "auth/email-already-in-use") {
|
||||
setError("Email already in use. Try logging in instead.");
|
||||
} else if (err.code === "auth/invalid-email") {
|
||||
setError("Invalid email address");
|
||||
} else if (err.code === "auth/weak-password") {
|
||||
setError("Password is too weak");
|
||||
} else {
|
||||
setError("Failed to create account: " + err.message);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
async function handleStep2() {
|
||||
setError("");
|
||||
if (!data.walletAddress) {
|
||||
setError("Please enter a wallet address");
|
||||
return;
|
||||
}
|
||||
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
|
||||
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const created = await createAddress({
|
||||
address: data.walletAddress,
|
||||
label: data.walletLabel || undefined,
|
||||
});
|
||||
set("createdAddressId", created.id);
|
||||
setStep(3);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep3() {
|
||||
setError("");
|
||||
const rules = [];
|
||||
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
|
||||
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
|
||||
if (data.alertLargeTransfer) {
|
||||
if (!data.largeTransferThreshold) {
|
||||
setError("Please enter a threshold for large transfers");
|
||||
return;
|
||||
}
|
||||
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
|
||||
}
|
||||
if (data.alertBalanceBelow) {
|
||||
if (!data.balanceBelowThreshold) {
|
||||
setError("Please enter a threshold for balance below");
|
||||
return;
|
||||
}
|
||||
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
setStep(4);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const created = [];
|
||||
for (const rule of rules) {
|
||||
const result = await createAlert(data.createdAddressId, rule);
|
||||
created.push(result);
|
||||
}
|
||||
set("alertsCreated", created);
|
||||
setStep(4);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep4() {
|
||||
setError("");
|
||||
const hasAny =
|
||||
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
|
||||
if (!hasAny) {
|
||||
setStep(5);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
await updateNotificationConfig({
|
||||
notification_enabled: true,
|
||||
discord_webhook_url: data.discordWebhookUrl || undefined,
|
||||
slack_webhook_url: data.slackWebhookUrl || undefined,
|
||||
email: data.notificationEmail || undefined,
|
||||
});
|
||||
set("notificationConfigured", true);
|
||||
setStep(5);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestChannels() {
|
||||
setTestLoading(true);
|
||||
setTestResults(null);
|
||||
try {
|
||||
const results = await testNotificationChannels();
|
||||
setTestResults(results);
|
||||
} catch (err) {
|
||||
setTestResults({ error: err.message });
|
||||
} finally {
|
||||
setTestLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress bar ──────────────────────────────────────────────────────────
|
||||
|
||||
function ProgressBar() {
|
||||
return (
|
||||
<div className="progress-bar">
|
||||
{STEPS.map((label, i) => {
|
||||
const stepNum = i + 1;
|
||||
const done = step > stepNum;
|
||||
const active = step === stepNum;
|
||||
const dotClass = done
|
||||
? "progress-bar__dot--done"
|
||||
: active
|
||||
? "progress-bar__dot--active"
|
||||
: "progress-bar__dot--pending";
|
||||
|
||||
return (
|
||||
<div key={label} className="progress-bar__step">
|
||||
{i > 0 && (
|
||||
<div
|
||||
className={`progress-bar__connector ${
|
||||
done || active
|
||||
? "progress-bar__connector--active"
|
||||
: "progress-bar__connector--inactive"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className={`progress-bar__dot ${dotClass}`}>
|
||||
{done ? "\u2713" : stepNum}
|
||||
</div>
|
||||
<div
|
||||
className={`progress-bar__label ${
|
||||
active
|
||||
? "progress-bar__label--active"
|
||||
: "progress-bar__label--inactive"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step content ──────────────────────────────────────────────────────────
|
||||
|
||||
function Step1() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-lg">Create your account</h2>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={data.email}
|
||||
onChange={(v) => set("email", v)}
|
||||
disabled={loading}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={data.password}
|
||||
onChange={(v) => set("password", v)}
|
||||
disabled={loading}
|
||||
placeholder="At least 6 characters"
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={data.confirmPassword}
|
||||
onChange={(v) => set("confirmPassword", v)}
|
||||
disabled={loading}
|
||||
placeholder="Repeat your password"
|
||||
className="form-field--last"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step2() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-sm">Add a wallet address</h2>
|
||||
<p className="onboarding__subtitle">
|
||||
Enter the Ethereum address you want to monitor.
|
||||
</p>
|
||||
<Input
|
||||
label="ETH Address"
|
||||
value={data.walletAddress}
|
||||
onChange={(v) => set("walletAddress", v)}
|
||||
disabled={loading}
|
||||
placeholder="0x..."
|
||||
/>
|
||||
<Input
|
||||
label="Label (optional)"
|
||||
value={data.walletLabel}
|
||||
onChange={(v) => set("walletLabel", v)}
|
||||
disabled={loading}
|
||||
placeholder="e.g. My main wallet"
|
||||
className="form-field--last"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step3() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-sm">Configure alert rules</h2>
|
||||
<p className="onboarding__subtitle">
|
||||
Choose which events trigger notifications. You can change these later.
|
||||
</p>
|
||||
|
||||
<CheckboxRow
|
||||
checked={data.alertIncomingTx}
|
||||
onChange={(v) => set("alertIncomingTx", v)}
|
||||
label="Incoming transaction"
|
||||
/>
|
||||
<CheckboxRow
|
||||
checked={data.alertOutgoingTx}
|
||||
onChange={(v) => set("alertOutgoingTx", v)}
|
||||
label="Outgoing transaction"
|
||||
/>
|
||||
<CheckboxRow
|
||||
checked={data.alertLargeTransfer}
|
||||
onChange={(v) => set("alertLargeTransfer", v)}
|
||||
label="Large transfer"
|
||||
>
|
||||
{data.alertLargeTransfer && (
|
||||
<div className="checkbox-row__nested">
|
||||
<Input
|
||||
type="number"
|
||||
label=""
|
||||
value={data.largeTransferThreshold}
|
||||
onChange={(v) => set("largeTransferThreshold", v)}
|
||||
placeholder="Threshold (ETH)"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CheckboxRow>
|
||||
<CheckboxRow
|
||||
checked={data.alertBalanceBelow}
|
||||
onChange={(v) => set("alertBalanceBelow", v)}
|
||||
label="Balance below"
|
||||
>
|
||||
{data.alertBalanceBelow && (
|
||||
<div className="checkbox-row__nested">
|
||||
<Input
|
||||
type="number"
|
||||
label=""
|
||||
value={data.balanceBelowThreshold}
|
||||
onChange={(v) => set("balanceBelowThreshold", v)}
|
||||
placeholder="Threshold (ETH)"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CheckboxRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step4() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-sm">Set up notifications</h2>
|
||||
<p className="onboarding__subtitle">
|
||||
Add at least one channel so you receive alerts. All fields are optional.
|
||||
</p>
|
||||
|
||||
<div className="mb-md">
|
||||
<label className="form-label">
|
||||
Discord Webhook URL{" "}
|
||||
<a
|
||||
href="https://support.discord.com/hc/en-us/articles/228383668"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
(how to get one)
|
||||
</a>
|
||||
</label>
|
||||
<Input
|
||||
label=""
|
||||
type="url"
|
||||
value={data.discordWebhookUrl}
|
||||
onChange={(v) => set("discordWebhookUrl", v)}
|
||||
disabled={loading}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-md">
|
||||
<label className="form-label">
|
||||
Slack Webhook URL{" "}
|
||||
<a
|
||||
href="https://api.slack.com/messaging/webhooks"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
(how to get one)
|
||||
</a>
|
||||
</label>
|
||||
<Input
|
||||
label=""
|
||||
type="url"
|
||||
value={data.slackWebhookUrl}
|
||||
onChange={(v) => set("slackWebhookUrl", v)}
|
||||
disabled={loading}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Email address for alerts"
|
||||
type="email"
|
||||
value={data.notificationEmail}
|
||||
onChange={(v) => set("notificationEmail", v)}
|
||||
disabled={loading}
|
||||
placeholder="you@example.com"
|
||||
className="form-field--last"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step5() {
|
||||
const alertCount = data.alertsCreated.length;
|
||||
const hasNotif = data.notificationConfigured;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-md">You're all set!</h2>
|
||||
|
||||
<div className="onboarding__summary">
|
||||
<p className="onboarding__summary-title">Summary</p>
|
||||
<ul className="onboarding__summary-list">
|
||||
<li>
|
||||
Wallet address added:{" "}
|
||||
<span className="text-mono text-white-sm">
|
||||
{data.walletAddress}
|
||||
</span>
|
||||
{data.walletLabel && ` (${data.walletLabel})`}
|
||||
</li>
|
||||
<li>
|
||||
Alert rules configured:{" "}
|
||||
<span className="text-white">
|
||||
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
Notification channels:{" "}
|
||||
<span className="text-white">
|
||||
{hasNotif ? "Configured" : "Not set up (skipped)"}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{hasNotif && (
|
||||
<div className="mb-lg">
|
||||
<Button
|
||||
onClick={handleTestChannels}
|
||||
disabled={testLoading}
|
||||
variant="ghost"
|
||||
>
|
||||
{testLoading ? "Testing..." : "Test All Channels"}
|
||||
</Button>
|
||||
|
||||
{testResults && (
|
||||
<div className="mt-md">
|
||||
{testResults.error ? (
|
||||
<p className="text-error">{testResults.error}</p>
|
||||
) : (
|
||||
<ul className="list-unstyled">
|
||||
{Object.entries(testResults).map(([channel, result]) => (
|
||||
<li
|
||||
key={channel}
|
||||
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
|
||||
>
|
||||
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
|
||||
{result.message || (result.success ? "OK" : "Failed")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
|
||||
Go to Dashboard →
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function CheckboxRow({ checked, onChange, label, children }) {
|
||||
return (
|
||||
<div className="checkbox-row">
|
||||
<label className="checkbox-row__label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="checkbox-row__input"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Footer navigation ─────────────────────────────────────────────────────
|
||||
|
||||
function Footer() {
|
||||
if (step === 5) return null;
|
||||
|
||||
const canSkip = step === 3 || step === 4;
|
||||
const canBack = step > 1;
|
||||
|
||||
async function handleNext() {
|
||||
setSkipWarning("");
|
||||
if (step === 1) await handleStep1();
|
||||
else if (step === 2) await handleStep2();
|
||||
else if (step === 3) await handleStep3();
|
||||
else if (step === 4) await handleStep4();
|
||||
}
|
||||
|
||||
function handleSkip() {
|
||||
setError("");
|
||||
setSkipWarning("");
|
||||
setStep((s) => s + 1);
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
setError("");
|
||||
setSkipWarning("");
|
||||
setStep((s) => s - 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="onboarding__footer">
|
||||
<div>
|
||||
{canBack && (
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
disabled={loading}
|
||||
variant="ghost"
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-md">
|
||||
{canSkip && (
|
||||
<Button
|
||||
onClick={handleSkip}
|
||||
disabled={loading}
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={loading}
|
||||
className="text-bold"
|
||||
>
|
||||
{loading ? "Please wait..." : step === 4 ? "Finish" : "Next →"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
const stepContent = {
|
||||
1: <Step1 />,
|
||||
2: <Step2 />,
|
||||
3: <Step3 />,
|
||||
4: <Step4 />,
|
||||
5: <Step5 />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="onboarding">
|
||||
<div className="onboarding__container">
|
||||
<h1 className="onboarding__title">Koin Ping</h1>
|
||||
|
||||
<ProgressBar />
|
||||
|
||||
{error && (
|
||||
<div className="alert alert--error">{error}</div>
|
||||
)}
|
||||
|
||||
{skipWarning && (
|
||||
<div className="alert alert--warning">{skipWarning}</div>
|
||||
)}
|
||||
|
||||
<div className="onboarding__card">
|
||||
{stepContent[step]}
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<p className="onboarding__login-link">
|
||||
Already have an account?{" "}
|
||||
<a href="/login">Log in here</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
export default function Signup() {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
return <Navigate to="/subscribe" replace />;
|
||||
}
|
||||
|
||||
@@ -28,3 +28,13 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
174
frontend/src/pages/addresses/Addresses.jsx
Normal file
174
frontend/src/pages/addresses/Addresses.jsx
Normal file
@@ -0,0 +1,174 @@
|
||||
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.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`mb-xl${atLimit ? " tier-locked" : ""}`}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
67
frontend/src/pages/alertHistory/AlertHistory.jsx
Normal file
67
frontend/src/pages/alertHistory/AlertHistory.jsx
Normal 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();
|
||||
}
|
||||
191
frontend/src/pages/alerts/Alerts.css
Normal file
191
frontend/src/pages/alerts/Alerts.css
Normal 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;
|
||||
}
|
||||
}
|
||||
709
frontend/src/pages/alerts/Alerts.jsx
Normal file
709
frontend/src/pages/alerts/Alerts.jsx
Normal file
@@ -0,0 +1,709 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import AlertForm from "../../components/AlertForm";
|
||||
import Button from "../../components/Button";
|
||||
import Input from "../../components/Input";
|
||||
import UpgradeBanner from "../../components/UpgradeBanner";
|
||||
import { getAddresses } from "../../api/addresses";
|
||||
import {
|
||||
getAlerts,
|
||||
createAlert,
|
||||
updateAlertStatus,
|
||||
updateAlertThresholds,
|
||||
deleteAlert,
|
||||
} from "../../api/alerts";
|
||||
import {
|
||||
getNotificationConfig,
|
||||
updateNotificationConfig,
|
||||
testNotificationChannels,
|
||||
setupEmail,
|
||||
sendEmailDigest,
|
||||
} from "../../api/notificationConfig";
|
||||
import "./Alerts.css";
|
||||
|
||||
export default function Alerts() {
|
||||
const { tierLimits, userTier } = useAuth();
|
||||
|
||||
const [addresses, setAddresses] = useState([]);
|
||||
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
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);
|
||||
|
||||
const [openAccordions, setOpenAccordions] = useState({});
|
||||
const [thresholdEdits, setThresholdEdits] = useState({});
|
||||
|
||||
const allowedChannels = tierLimits.allowed_channels || ["email"];
|
||||
const canTelegram = allowedChannels.includes("telegram");
|
||||
const canDiscord = allowedChannels.includes("discord");
|
||||
const canSlack = allowedChannels.includes("slack");
|
||||
|
||||
const maxAlertTypes = tierLimits.max_alert_types;
|
||||
const isUnlimitedAlerts = maxAlertTypes === -1;
|
||||
|
||||
function distinctAlertTypeCount() {
|
||||
const types = new Set(alerts.map((a) => a.type));
|
||||
return types.size;
|
||||
}
|
||||
|
||||
const atAlertLimit = !isUnlimitedAlerts && distinctAlertTypeCount() >= maxAlertTypes;
|
||||
|
||||
function toggleAccordion(alertId) {
|
||||
setOpenAccordions((prev) => ({ ...prev, [alertId]: !prev[alertId] }));
|
||||
}
|
||||
|
||||
function getThresholdEdit(alert) {
|
||||
if (thresholdEdits[alert.id]) return thresholdEdits[alert.id];
|
||||
return {
|
||||
minimum: alert.minimum != null ? String(alert.minimum) : "",
|
||||
maximum: alert.maximum != null ? String(alert.maximum) : "",
|
||||
};
|
||||
}
|
||||
|
||||
function setThresholdEdit(alertId, field, value) {
|
||||
setThresholdEdits((prev) => {
|
||||
const current = prev[alertId] || getThresholdEditForAlert(alertId);
|
||||
return { ...prev, [alertId]: { ...current, [field]: value } };
|
||||
});
|
||||
}
|
||||
|
||||
function getThresholdEditForAlert(alertId) {
|
||||
const alert = alerts.find((a) => a.id === alertId);
|
||||
return {
|
||||
minimum: alert?.minimum != null ? String(alert.minimum) : "",
|
||||
maximum: alert?.maximum != null ? String(alert.maximum) : "",
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSaveThresholds(alertId) {
|
||||
const edit = thresholdEdits[alertId];
|
||||
if (!edit) return;
|
||||
|
||||
const min = edit.minimum.trim() === "" ? null : Number(edit.minimum);
|
||||
const max = edit.maximum.trim() === "" ? null : Number(edit.maximum);
|
||||
|
||||
if (min !== null && isNaN(min)) return;
|
||||
if (max !== null && isNaN(max)) return;
|
||||
if (min !== null && max !== null && min > max) return;
|
||||
|
||||
try {
|
||||
const updated = await updateAlertThresholds(alertId, min, max);
|
||||
setAlerts((prev) =>
|
||||
prev.map((a) => (a.id === alertId ? updated : a)),
|
||||
);
|
||||
setThresholdEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[alertId];
|
||||
return next;
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to update thresholds:", err);
|
||||
}
|
||||
}
|
||||
|
||||
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: canDiscord ? (discordWebhookUrl || null) : null,
|
||||
telegram_bot_token: canTelegram ? (telegramBotToken || null) : null,
|
||||
telegram_chat_id: canTelegram ? (telegramChatId || null) : null,
|
||||
email: email || null,
|
||||
slack_webhook_url: canSlack ? (slackWebhookUrl || null) : 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 className="page">Loading addresses...</div>;
|
||||
}
|
||||
|
||||
if (addresses.length === 0) {
|
||||
return (
|
||||
<div className="page">
|
||||
<p>No addresses tracked yet. Add an address first to create alerts.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page page--wide">
|
||||
<h1 className="mb-xl">Alert Rules & Notifications</h1>
|
||||
|
||||
<div className="alerts-grid">
|
||||
{/* LEFT COLUMN: Alert Rules */}
|
||||
<div>
|
||||
<h2 className="mt-0">Alert Rules</h2>
|
||||
|
||||
<div className="mb-xl">
|
||||
<label className="form-label mb-sm">
|
||||
<strong>Select Address:</strong>
|
||||
</label>
|
||||
<select
|
||||
value={selectedAddressId || ""}
|
||||
onChange={(e) =>
|
||||
setSelectedAddressId(Number(e.target.value))
|
||||
}
|
||||
className="form-select"
|
||||
>
|
||||
{addresses.map((addr) => (
|
||||
<option key={addr.id} value={addr.id}>
|
||||
{addr.label || "Unlabeled"} - {addr.address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedAddress && (
|
||||
<>
|
||||
<div className="alerts__address-info">
|
||||
<div className="text-sm text-muted">
|
||||
Managing alerts for:
|
||||
</div>
|
||||
<div className="text-bold">
|
||||
{selectedAddress.label || "Unlabeled"}
|
||||
</div>
|
||||
<div className="text-mono text-sm text-muted">
|
||||
{selectedAddress.address}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{atAlertLimit && userTier !== "pro" && (
|
||||
<UpgradeBanner
|
||||
message={`Your ${userTier} plan allows ${maxAlertTypes} alert type${maxAlertTypes !== 1 ? "s" : ""} per address. Upgrade for more.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`mb-xl${atAlertLimit ? " tier-locked" : ""}`}>
|
||||
<h3>Create New Alert</h3>
|
||||
<AlertForm onSubmit={handleAlertSubmit} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Active Alert Rules</h3>
|
||||
{error && (
|
||||
<p className="text-error">Error: {error}</p>
|
||||
)}
|
||||
{alerts.length === 0 ? (
|
||||
<p className="text-dimmed">
|
||||
No alert rules defined yet. Create one above.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="list-unstyled">
|
||||
{alerts.map((alert) => {
|
||||
const hasAccordion =
|
||||
alert.type === "incoming_tx" ||
|
||||
alert.type === "outgoing_tx";
|
||||
const isOpen = !!openAccordions[alert.id];
|
||||
const edit = getThresholdEdit(alert);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={alert.id}
|
||||
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
||||
>
|
||||
<div className="flex flex--between">
|
||||
<div>
|
||||
<div className="text-bold mb-sm">
|
||||
{formatAlertType(alert.type)}
|
||||
</div>
|
||||
{alert.threshold && (
|
||||
<div className="text-sm text-muted">
|
||||
Threshold: {alert.threshold} ETH
|
||||
</div>
|
||||
)}
|
||||
{alert.minimum != null && (
|
||||
<div className="text-sm text-muted">
|
||||
Min: {alert.minimum} ETH
|
||||
</div>
|
||||
)}
|
||||
{alert.maximum != null && (
|
||||
<div className="text-sm text-muted">
|
||||
Max: {alert.maximum} ETH
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-dimmed">
|
||||
Status:{" "}
|
||||
{alert.enabled ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex--center gap-sm">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleToggleAlert(
|
||||
alert.id,
|
||||
alert.enabled,
|
||||
)
|
||||
}
|
||||
>
|
||||
{alert.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleDeleteAlert(alert.id)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasAccordion && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="alerts__accordion-toggle"
|
||||
onClick={() =>
|
||||
toggleAccordion(alert.id)
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`alerts__accordion-chevron ${isOpen ? "alerts__accordion-chevron--open" : ""}`}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
Add optional minimum and maximum
|
||||
threshold values
|
||||
</button>
|
||||
<div
|
||||
className={`alerts__accordion-panel ${isOpen ? "alerts__accordion-panel--open" : ""}`}
|
||||
>
|
||||
<div className="alerts__threshold-inputs">
|
||||
<Input
|
||||
label="Minimum"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
value={edit.minimum}
|
||||
onChange={(v) =>
|
||||
setThresholdEdit(
|
||||
alert.id,
|
||||
"minimum",
|
||||
v,
|
||||
)
|
||||
}
|
||||
placeholder="No minimum"
|
||||
/>
|
||||
<Input
|
||||
label="Maximum"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
value={edit.maximum}
|
||||
onChange={(v) =>
|
||||
setThresholdEdit(
|
||||
alert.id,
|
||||
"maximum",
|
||||
v,
|
||||
)
|
||||
}
|
||||
placeholder="No maximum"
|
||||
/>
|
||||
</div>
|
||||
<div className="alerts__threshold-actions">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleSaveThresholds(alert.id)
|
||||
}
|
||||
className="btn--sm"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: Notification Settings */}
|
||||
<div>
|
||||
<h2 className="mt-0">Notification Settings</h2>
|
||||
|
||||
{notificationSuccess && (
|
||||
<div className="alert alert--success">{notificationSuccess}</div>
|
||||
)}
|
||||
|
||||
{notificationError && (
|
||||
<div className="alert alert--error">{notificationError}</div>
|
||||
)}
|
||||
|
||||
{/* Master toggle */}
|
||||
<div className="alerts__toggle-panel">
|
||||
<label className="alerts__toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationEnabled}
|
||||
onChange={(e) =>
|
||||
setNotificationEnabled(e.target.checked)
|
||||
}
|
||||
className="alerts__toggle-checkbox"
|
||||
/>
|
||||
<span className="text-bold">Enable Notifications</span>
|
||||
</label>
|
||||
<div className="alerts__toggle-hint">
|
||||
Master switch for all notification channels
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All channel settings -- hidden when master toggle is off */}
|
||||
{notificationEnabled && (
|
||||
<>
|
||||
{/* Telegram */}
|
||||
<div className={`section${!canTelegram ? " alerts__channel-locked" : ""}`}>
|
||||
<h3 className="mt-0 mb-md">Telegram</h3>
|
||||
{!canTelegram && (
|
||||
<UpgradeBanner message="Upgrade to Premium to enable Telegram alerts" />
|
||||
)}
|
||||
<Input
|
||||
label="Bot Token"
|
||||
value={telegramBotToken}
|
||||
onChange={setTelegramBotToken}
|
||||
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
||||
disabled={!canTelegram}
|
||||
/>
|
||||
<Input
|
||||
label="Chat ID"
|
||||
value={telegramChatId}
|
||||
onChange={setTelegramChatId}
|
||||
placeholder="-1001234567890"
|
||||
disabled={!canTelegram}
|
||||
/>
|
||||
<a
|
||||
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
How to create a Telegram bot & get your Chat ID
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="section">
|
||||
<h3 className="mt-0 mb-md">Email</h3>
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<div className="help-link text-dimmed mb-md">
|
||||
Alert notifications and digests will be sent to this address
|
||||
</div>
|
||||
<div className="alerts__email-buttons">
|
||||
<Button
|
||||
onClick={handleSetupEmail}
|
||||
disabled={settingUpEmail || !email}
|
||||
className="btn--sm"
|
||||
>
|
||||
{settingUpEmail ? "Setting up..." : "Verify Email"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSendDigest}
|
||||
disabled={sendingDigest || !email}
|
||||
variant="secondary"
|
||||
className="btn--sm"
|
||||
>
|
||||
{sendingDigest ? "Sending..." : "Send Digest Now"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discord */}
|
||||
<div className={`section${!canDiscord ? " alerts__channel-locked" : ""}`}>
|
||||
<h3 className="mt-0 mb-md">Discord</h3>
|
||||
{!canDiscord && (
|
||||
<UpgradeBanner message="Upgrade to Premium to enable Discord alerts" />
|
||||
)}
|
||||
<Input
|
||||
label="Discord Webhook URL"
|
||||
value={discordWebhookUrl}
|
||||
onChange={setDiscordWebhookUrl}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
disabled={!canDiscord}
|
||||
/>
|
||||
<a
|
||||
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
How to get a Discord webhook URL
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Slack */}
|
||||
<div className={`section${!canSlack ? " alerts__channel-locked" : ""}`}>
|
||||
<h3 className="mt-0 mb-md">Slack</h3>
|
||||
{!canSlack && (
|
||||
<UpgradeBanner message="Upgrade to Pro to enable Slack alerts" />
|
||||
)}
|
||||
<Input
|
||||
label="Slack Webhook URL"
|
||||
value={slackWebhookUrl}
|
||||
onChange={setSlackWebhookUrl}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
disabled={!canSlack}
|
||||
/>
|
||||
<a
|
||||
href="https://api.slack.com/messaging/webhooks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
How to set up Slack Incoming Webhooks
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Save & Test buttons */}
|
||||
<div className="alerts__save-test">
|
||||
<Button
|
||||
onClick={handleSaveNotificationConfig}
|
||||
disabled={notificationLoading}
|
||||
className="btn--lg"
|
||||
>
|
||||
{notificationLoading ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTestChannels}
|
||||
disabled={testingChannels}
|
||||
variant="success"
|
||||
className="btn--lg"
|
||||
>
|
||||
{testingChannels ? "Testing..." : "Test All Channels"}
|
||||
</Button>
|
||||
</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;
|
||||
}
|
||||
117
frontend/src/pages/login/Login.css
Normal file
117
frontend/src/pages/login/Login.css
Normal file
@@ -0,0 +1,117 @@
|
||||
.login-page {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.login-bg-video {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
filter: grayscale(85%);
|
||||
-webkit-filter: grayscale(85%);
|
||||
opacity: 0.05;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
padding-top: 8rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.login-heading {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
top: -20px;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.2rem;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-signup-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.login-card-fadein {
|
||||
opacity: 0;
|
||||
animation: fadeIn 2s ease-in-out forwards;
|
||||
animation-delay: 3s;
|
||||
}
|
||||
|
||||
.login-tagline {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 630px;
|
||||
margin: 5rem auto 0;
|
||||
padding-left: 2rem;
|
||||
font-style: italic;
|
||||
color: #FFFFFF;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-interactive-fadein {
|
||||
opacity: 0;
|
||||
animation: fadeIn 2s ease-in-out forwards;
|
||||
animation-delay: 6s;
|
||||
}
|
||||
|
||||
/* ── Login Responsive ────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
max-width: 100%;
|
||||
margin: 0 0.75rem;
|
||||
padding: 1.5rem;
|
||||
padding-top: 6rem;
|
||||
}
|
||||
|
||||
.login-tagline {
|
||||
margin-top: 3rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import Input from "../components/Input";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import Input from "../../components/Input";
|
||||
import "./Login.css";
|
||||
|
||||
export default function Login() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -36,12 +35,22 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card" onMouseEnter={() => setIsVisible(true)}>
|
||||
<video
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className="login-bg-video"
|
||||
>
|
||||
<source src="/koin_exp_2.mp4" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
<div className="login-card login-card-fadein">
|
||||
<h1 className="login-heading">
|
||||
<span className="login-brand">Koin Ping</span> - Login
|
||||
</h1>
|
||||
|
||||
<div className={isVisible ? "login-form-visible" : "login-form-hidden"}>
|
||||
<div className="login-interactive-fadein">
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -78,6 +87,11 @@ export default function Login() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="login-tagline login-card-fadein">
|
||||
A lightweight, on-chain monitoring system giving users real-time
|
||||
situational awareness over blockchain addresses.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user