Compare commits
29 Commits
stripe-2
...
add-alert-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,9 +0,0 @@
|
|||||||
name: check
|
|
||||||
on: [push]
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
||||||
- run: docker build .
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,7 +21,7 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Go build artifacts
|
# Go build artifacts
|
||||||
backend-go/bin/
|
backend/bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/build/
|
frontend/build/
|
||||||
backend-go/bin/
|
backend/bin/
|
||||||
*.lock
|
*.lock
|
||||||
Prompts/
|
Prompts/
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
3
LICENSE
3
LICENSE
@@ -2,8 +2,7 @@ MIT License
|
|||||||
|
|
||||||
Copyright (c) 2026 Steven Jannette
|
Copyright (c) 2026 Steven Jannette
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -2,7 +2,7 @@
|
|||||||
test-go test-js lint-go lint-js fmt-go fmt-js fmt-check-go fmt-check-js \
|
test-go test-js lint-go lint-js fmt-go fmt-js fmt-check-go fmt-check-js \
|
||||||
build-go build-js
|
build-go build-js
|
||||||
|
|
||||||
GODIR := backend-go
|
GODIR := backend
|
||||||
JSDIR := frontend
|
JSDIR := frontend
|
||||||
PRETTIER := $(JSDIR)/node_modules/.bin/prettier
|
PRETTIER := $(JSDIR)/node_modules/.bin/prettier
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ make hooks
|
|||||||
cd frontend && npm install && cd ..
|
cd frontend && npm install && cd ..
|
||||||
|
|
||||||
# Copy and fill in environment variables
|
# Copy and fill in environment variables
|
||||||
cp backend-go/.env.example backend-go/.env
|
cp backend/.env.example backend/.env
|
||||||
# edit backend-go/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
|
# edit backend/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
|
||||||
|
|
||||||
# Run checks (requires golangci-lint)
|
# Run checks (requires golangci-lint)
|
||||||
make check
|
make check
|
||||||
@@ -38,7 +38,7 @@ make check
|
|||||||
make run
|
make run
|
||||||
|
|
||||||
# Start the poller (separate terminal)
|
# Start the poller (separate terminal)
|
||||||
cd backend-go && go run ./cmd/poller
|
cd backend && go run ./cmd/poller
|
||||||
|
|
||||||
# Start the frontend dev server (separate terminal)
|
# Start the frontend dev server (separate terminal)
|
||||||
cd frontend && npm run dev
|
cd frontend && npm run dev
|
||||||
@@ -63,7 +63,7 @@ frontend:
|
|||||||
|
|
||||||
```
|
```
|
||||||
koin_ping_0.2.0/
|
koin_ping_0.2.0/
|
||||||
├── backend-go/ # Go monorepo root
|
├── backend/ # Go monorepo root
|
||||||
│ ├── cmd/api/ # HTTP REST API server
|
│ ├── cmd/api/ # HTTP REST API server
|
||||||
│ ├── cmd/poller/ # Blockchain polling daemon
|
│ ├── cmd/poller/ # Blockchain polling daemon
|
||||||
│ └── internal/
|
│ └── internal/
|
||||||
|
|||||||
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,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
|
|
||||||
}
|
|
||||||
@@ -2,16 +2,16 @@ Start DB:
|
|||||||
|
|
||||||
brew services start postgresql@15
|
brew services start postgresql@15
|
||||||
|
|
||||||
From the backend-go directory, you have a few options:
|
From the backend directory, you have a few options:
|
||||||
|
|
||||||
Option 1: Single command (both API + poller)
|
Option 1: Single command (both API + poller)
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gomake dev-all
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
|
||||||
|
|
||||||
Option 2: Two separate terminals
|
Option 2: Two separate terminals
|
||||||
Terminal 1 (API server):
|
Terminal 1 (API server):
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-go go run ./cmd/api
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
|
||||||
Terminal 2 (Poller):
|
Terminal 2 (Poller):
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-go go run ./cmd/poller
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/poller
|
||||||
|
|
||||||
make run — Builds and runs the API server.
|
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 dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
|
||||||
@@ -8,13 +8,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
"github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
"github.com/kjannette/koin-ping/backend/internal/handlers"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -62,6 +62,7 @@ func main() {
|
|||||||
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||||
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||||
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
|
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
|
||||||
|
accountHandler := handlers.NewAccountHandler(userModel, cfg)
|
||||||
|
|
||||||
authenticate := middleware.Authenticate(userModel)
|
authenticate := middleware.Authenticate(userModel)
|
||||||
requireSub := middleware.RequireSubscription(userModel)
|
requireSub := middleware.RequireSubscription(userModel)
|
||||||
@@ -88,6 +89,12 @@ func main() {
|
|||||||
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
|
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
|
||||||
mux.Handle("POST "+b+"/stripe/verify-checkout",
|
mux.Handle("POST "+b+"/stripe/verify-checkout",
|
||||||
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
|
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
|
||||||
|
mux.Handle("POST "+b+"/stripe/create-portal-session",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.CreatePortalSession)))
|
||||||
|
|
||||||
|
// Account route (auth required, NO subscription required)
|
||||||
|
mux.Handle("GET "+b+"/user/account",
|
||||||
|
authenticate(http.HandlerFunc(accountHandler.GetAccount)))
|
||||||
|
|
||||||
// Authenticated + subscribed routes — addresses
|
// Authenticated + subscribed routes — addresses
|
||||||
mux.Handle("POST "+b+"/addresses",
|
mux.Handle("POST "+b+"/addresses",
|
||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -138,6 +138,8 @@ func runCycle(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
evaluator.WaitForNotifications()
|
||||||
|
|
||||||
duration := time.Since(startTime)
|
duration := time.Since(startTime)
|
||||||
log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s",
|
log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s",
|
||||||
time.Now().UTC().Format(time.RFC3339),
|
time.Now().UTC().Format(time.RFC3339),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module github.com/kjannette/koin-ping/backend-go
|
module github.com/kjannette/koin-ping/backend
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -35,6 +35,8 @@ CREATE TABLE alert_rules (
|
|||||||
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
|
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
|
||||||
type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'
|
type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'
|
||||||
threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it)
|
threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it)
|
||||||
|
minimum DECIMAL(20, 6), -- Optional min amount filter for incoming/outgoing alerts
|
||||||
|
maximum DECIMAL(20, 6), -- Optional max amount filter for incoming/outgoing alerts
|
||||||
enabled BOOLEAN DEFAULT TRUE,
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
@@ -44,7 +46,11 @@ CREATE TABLE alert_rules (
|
|||||||
|
|
||||||
CONSTRAINT positive_threshold CHECK (
|
CONSTRAINT positive_threshold CHECK (
|
||||||
threshold IS NULL OR threshold > 0
|
threshold IS NULL OR threshold > 0
|
||||||
)
|
),
|
||||||
|
|
||||||
|
CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0),
|
||||||
|
CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0),
|
||||||
|
CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ var ThresholdRequiredTypes = []AlertType{ //nolint:gochecknoglobals
|
|||||||
AlertBalanceBelow,
|
AlertBalanceBelow,
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValidAlertType returns true if the given string matches a known AlertType.
|
// returns true if the given string matches a known AlertType.
|
||||||
func IsValidAlertType(t string) bool {
|
func IsValidAlertType(t string) bool {
|
||||||
for _, v := range ValidAlertTypes {
|
for _, v := range ValidAlertTypes {
|
||||||
if string(v) == t {
|
if string(v) == t {
|
||||||
@@ -58,7 +58,6 @@ func IsValidAlertType(t string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsThresholdRequired returns true if the given AlertType requires a threshold.
|
|
||||||
func IsThresholdRequired(t AlertType) bool {
|
func IsThresholdRequired(t AlertType) bool {
|
||||||
for _, v := range ThresholdRequiredTypes {
|
for _, v := range ThresholdRequiredTypes {
|
||||||
if v == t {
|
if v == t {
|
||||||
@@ -74,6 +73,8 @@ type AlertRule struct {
|
|||||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
Type AlertType `json:"type"`
|
Type AlertType `json:"type"`
|
||||||
Threshold *float64 `json:"threshold"`
|
Threshold *float64 `json:"threshold"`
|
||||||
|
Minimum *float64 `json:"minimum"`
|
||||||
|
Maximum *float64 `json:"maximum"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,6 @@ type AddressCheckpoint struct {
|
|||||||
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckpointDetail combines checkpoint and address info for reporting.
|
|
||||||
type CheckpointDetail struct {
|
type CheckpointDetail struct {
|
||||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
@@ -102,7 +102,7 @@ type CheckpointDetail struct {
|
|||||||
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationConfig holds a user's notification preferences.
|
// holds a user's notification preferences.
|
||||||
type NotificationConfig struct {
|
type NotificationConfig struct {
|
||||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||||
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
||||||
@@ -131,14 +131,12 @@ type NormalizedTx struct {
|
|||||||
TokenValue *string `json:"token_value,omitempty"` //nolint:tagliatelle
|
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 {
|
func (tx NormalizedTx) IsTokenTransfer() bool {
|
||||||
return tx.TokenContract != nil
|
return tx.TokenContract != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type Direction string
|
type Direction string
|
||||||
|
|
||||||
// String implements fmt.Stringer.
|
|
||||||
func (d Direction) String() string { return string(d) }
|
func (d Direction) String() string { return string(d) }
|
||||||
|
|
||||||
const (
|
const (
|
||||||
66
backend/internal/handlers/account.go
Normal file
66
backend/internal/handlers/account.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountHandler struct {
|
||||||
|
users *models.UserModel
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAccountHandler(users *models.UserModel, cfg *config.Config) *AccountHandler {
|
||||||
|
return &AccountHandler{users: users, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountResponse struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
SubscriptionStatus string `json:"subscription_status"`
|
||||||
|
SubscriptionPlan string `json:"subscription_plan"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := accountResponse{
|
||||||
|
UserID: user.ID,
|
||||||
|
Email: email,
|
||||||
|
UserName: email,
|
||||||
|
SubscriptionStatus: user.SubscriptionStatus,
|
||||||
|
SubscriptionPlan: "monthly/$1.99",
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.SubscriptionCreatedAt != nil {
|
||||||
|
t := user.SubscriptionCreatedAt.Format(time.DateOnly)
|
||||||
|
resp.MemberSince = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// MOCKED: NextBillingDate, CancelAtPeriodEnd, PeriodEndDate.
|
||||||
|
// TODO: stripe-go v82 removed Subscription.CurrentPeriodEnd / CancelAtPeriodEnd.
|
||||||
|
// Research SubscriptionItem.CurrentPeriodEnd or use Stripe REST API directly.
|
||||||
|
resp.NextBillingDate = nil
|
||||||
|
resp.CancelAtPeriodEnd = false
|
||||||
|
resp.PeriodEndDate = nil
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
|
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
|
||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertEventHandler handles HTTP requests for alert event history.
|
// AlertEventHandler handles HTTP requests for alert event history.
|
||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errThresholdFormat = errors.New("unsupported threshold format")
|
var errThresholdFormat = errors.New("unsupported threshold format")
|
||||||
@@ -37,6 +37,8 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Threshold json.RawMessage `json:"threshold"`
|
Threshold json.RawMessage `json:"threshold"`
|
||||||
|
Minimum json.RawMessage `json:"minimum"`
|
||||||
|
Maximum json.RawMessage `json:"maximum"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
log.Printf("Failed to decode alert request body: %v", err)
|
log.Printf("Failed to decode alert request body: %v", err)
|
||||||
@@ -53,6 +55,40 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
minimum, err := parseThreshold(body.Minimum)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to parse minimum: %v", err)
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maximum, err := parseThreshold(body.Maximum)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to parse maximum: %v", err)
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if minimum != nil && *minimum < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if maximum != nil && *maximum < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if minimum != nil && maximum != nil && *minimum > *maximum {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID)
|
log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID)
|
||||||
|
|
||||||
if body.Type == "" {
|
if body.Type == "" {
|
||||||
@@ -96,7 +132,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold)
|
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold, minimum, maximum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error creating alert rule: %v", err)
|
log.Printf("Error creating alert rule: %v", err)
|
||||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
|
||||||
@@ -182,7 +218,7 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, alerts)
|
writeJSON(w, http.StatusOK, alerts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateStatus handles PATCH requests to enable or disable an alert rule.
|
// UpdateStatus handles PATCH requests to enable/disable an alert rule and/or update min/max thresholds.
|
||||||
func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.GetUserID(r.Context())
|
userID := middleware.GetUserID(r.Context())
|
||||||
alertID, ok := parseIntParam(r.PathValue("alertId"))
|
alertID, ok := parseIntParam(r.PathValue("alertId"))
|
||||||
@@ -194,6 +230,9 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
Minimum json.RawMessage `json:"minimum"`
|
||||||
|
Maximum json.RawMessage `json:"maximum"`
|
||||||
|
UpdateMinMax bool `json:"update_min_max"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
log.Printf("Failed to decode update request body: %v", err)
|
log.Printf("Failed to decode update request body: %v", err)
|
||||||
@@ -204,8 +243,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
log.Printf("User %s updating alert ID: %d", userID, alertID)
|
log.Printf("User %s updating alert ID: %d", userID, alertID)
|
||||||
|
|
||||||
if body.Enabled == nil {
|
if body.Enabled == nil && !body.UpdateMinMax {
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value")
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "must provide enabled (boolean) or update_min_max with minimum/maximum values")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -224,7 +263,54 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, err := h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
|
var updated *domain.AlertRule
|
||||||
|
|
||||||
|
if body.UpdateMinMax {
|
||||||
|
minimum, parseErr := parseThreshold(body.Minimum)
|
||||||
|
if parseErr != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maximum, parseErr := parseThreshold(body.Maximum)
|
||||||
|
if parseErr != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if minimum != nil && *minimum < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if maximum != nil && *maximum < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if minimum != nil && maximum != nil && *minimum > *maximum {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err = h.alertRules.UpdateThresholds(r.Context(), alertID, minimum, maximum)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating alert thresholds: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Alert %d thresholds updated: min=%v, max=%v", alertID, minimum, maximum)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Enabled != nil {
|
||||||
|
updated, err = h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error updating alert: %v", err)
|
log.Printf("Error updating alert: %v", err)
|
||||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||||
@@ -233,6 +319,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
|
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, updated)
|
writeJSON(w, http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EmailDigestHandler struct {
|
type EmailDigestHandler struct {
|
||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||||
)
|
)
|
||||||
|
|
||||||
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatusHandler handles the system status endpoint.
|
// StatusHandler handles the system status endpoint.
|
||||||
@@ -7,12 +7,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/stripe/stripe-go/v82"
|
"github.com/stripe/stripe-go/v82"
|
||||||
|
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
|
||||||
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
|
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
|
||||||
"github.com/stripe/stripe-go/v82/webhook"
|
"github.com/stripe/stripe-go/v82/webhook"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
const webhookMaxBodyBytes = 65536
|
const webhookMaxBodyBytes = 65536
|
||||||
@@ -139,6 +140,38 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"subscription_status": "active"})
|
writeJSON(w, http.StatusOK, map[string]string{"subscription_status": "active"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// HandleWebhook processes incoming Stripe webhook events.
|
||||||
// This endpoint must NOT require authentication (Stripe calls it directly).
|
// This endpoint must NOT require authentication (Stripe calls it directly).
|
||||||
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
fbauth "github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AddressModel struct {
|
type AddressModel struct {
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertEventModel struct {
|
type AlertEventModel struct {
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertRuleModel struct {
|
type AlertRuleModel struct {
|
||||||
@@ -17,14 +17,14 @@ func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel {
|
|||||||
return &AlertRuleModel{pool: pool}
|
return &AlertRuleModel{pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold *float64) (*domain.AlertRule, error) {
|
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold, minimum, maximum *float64) (*domain.AlertRule, error) {
|
||||||
var r domain.AlertRule
|
var r domain.AlertRule
|
||||||
err := m.pool.QueryRow(ctx,
|
err := m.pool.QueryRow(ctx,
|
||||||
`INSERT INTO alert_rules (address_id, type, threshold, enabled)
|
`INSERT INTO alert_rules (address_id, type, threshold, minimum, maximum, enabled)
|
||||||
VALUES ($1, $2, $3, TRUE)
|
VALUES ($1, $2, $3, $4, $5, TRUE)
|
||||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||||
addressID, alertType.String(), threshold,
|
addressID, alertType.String(), threshold, minimum, maximum,
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType do
|
|||||||
|
|
||||||
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
|
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT id, address_id, type, threshold, enabled, created_at
|
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
|
||||||
FROM alert_rules
|
FROM alert_rules
|
||||||
WHERE address_id = $1
|
WHERE address_id = $1
|
||||||
ORDER BY created_at DESC`,
|
ORDER BY created_at DESC`,
|
||||||
@@ -47,7 +47,7 @@ func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]do
|
|||||||
var rules []domain.AlertRule
|
var rules []domain.AlertRule
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r domain.AlertRule
|
var r domain.AlertRule
|
||||||
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt); err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
rules = append(rules, r)
|
rules = append(rules, r)
|
||||||
@@ -61,19 +61,19 @@ func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (
|
|||||||
|
|
||||||
if userID != nil {
|
if userID != nil {
|
||||||
err = m.pool.QueryRow(ctx,
|
err = m.pool.QueryRow(ctx,
|
||||||
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.enabled, ar.created_at
|
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.minimum, ar.maximum, ar.enabled, ar.created_at
|
||||||
FROM alert_rules ar
|
FROM alert_rules ar
|
||||||
JOIN addresses a ON a.id = ar.address_id
|
JOIN addresses a ON a.id = ar.address_id
|
||||||
WHERE ar.id = $1 AND a.user_id = $2`,
|
WHERE ar.id = $1 AND a.user_id = $2`,
|
||||||
id, *userID,
|
id, *userID,
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
} else {
|
} else {
|
||||||
err = m.pool.QueryRow(ctx,
|
err = m.pool.QueryRow(ctx,
|
||||||
`SELECT id, address_id, type, threshold, enabled, created_at
|
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
|
||||||
FROM alert_rules
|
FROM alert_rules
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
id,
|
id,
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,9 +91,27 @@ func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool
|
|||||||
`UPDATE alert_rules
|
`UPDATE alert_rules
|
||||||
SET enabled = $2
|
SET enabled = $2
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||||
id, enabled,
|
id, enabled,
|
||||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CheckpointModel struct {
|
type CheckpointModel struct {
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NotificationConfigModel struct {
|
type NotificationConfigModel struct {
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserModel struct {
|
type UserModel struct {
|
||||||
@@ -21,17 +21,14 @@ const (
|
|||||||
colorBlue = 0x0099ff
|
colorBlue = 0x0099ff
|
||||||
)
|
)
|
||||||
|
|
||||||
// discordHTTPClient is a shared HTTP client with a timeout for Discord requests.
|
|
||||||
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiscordNotifier sends alert notifications via a Discord webhook.
|
// sends alert notifications via a Discord webhook.
|
||||||
type DiscordNotifier struct {
|
type DiscordNotifier struct {
|
||||||
WebhookURL string
|
WebhookURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send implements Notifier for Discord.
|
|
||||||
func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||||
_, err := SendDiscordNotification(d.WebhookURL, message, meta)
|
_, err := SendDiscordNotification(d.WebhookURL, message, meta)
|
||||||
return err
|
return err
|
||||||
@@ -104,8 +101,12 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
|
||||||
log.Printf("Discord webhook failed: HTTP %d", resp.StatusCode)
|
log.Printf("Discord webhook failed: HTTP %d", resp.StatusCode)
|
||||||
return false, fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
|
if isPermanentStatusCode(resp.StatusCode) {
|
||||||
|
return false, &PermanentError{Err: err}
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -10,14 +10,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailNotifier sends alert notifications via email (Resend).
|
|
||||||
type EmailNotifier struct {
|
type EmailNotifier struct {
|
||||||
APIKey string
|
APIKey string
|
||||||
From string
|
From string
|
||||||
To string
|
To string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send implements Notifier for email.
|
|
||||||
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||||
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
|
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
|
||||||
return err
|
return err
|
||||||
@@ -99,8 +97,12 @@ func SendEmailNotification(apiKey, fromAddress, toAddress, message string, meta
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
||||||
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
|
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
|
||||||
return false, fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
if isPermanentStatusCode(resp.StatusCode) {
|
||||||
|
return false, &PermanentError{Err: err}
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
||||||
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
||||||
return false, fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
if isPermanentStatusCode(resp.StatusCode) {
|
||||||
|
return false, &PermanentError{Err: err}
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -63,8 +63,12 @@ func SendTelegramNotification(botToken, chatID, message string, meta AlertMetada
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
||||||
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
|
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
|
||||||
return false, fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
if isPermanentStatusCode(resp.StatusCode) {
|
||||||
|
return false, &PermanentError{Err: err}
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -12,18 +12,20 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
rpcTimeoutMS = 30000
|
rpcTimeoutMS = 30000
|
||||||
rpcMaxRetries = 3
|
rpcMaxRetries = 3
|
||||||
rpcRetryBaseMS = 1000
|
rpcRetryBaseMS = 2000
|
||||||
|
rpcMinIntervalMS = 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
type JsonRpcEthereum struct {
|
type JsonRpcEthereum struct {
|
||||||
rpcURL string
|
rpcURL string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
lastCallAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
|
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
|
||||||
@@ -58,6 +60,8 @@ type rpcError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
|
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
|
||||||
|
j.throttle(ctx)
|
||||||
|
|
||||||
if params == nil {
|
if params == nil {
|
||||||
params = []interface{}{}
|
params = []interface{}{}
|
||||||
}
|
}
|
||||||
@@ -72,7 +76,25 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
|
|||||||
return nil, fmt.Errorf("marshal RPC request: %w", err)
|
return nil, fmt.Errorf("marshal RPC request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return j.callWithRetry(ctx, method, body)
|
result, callErr := j.callWithRetry(ctx, method, body)
|
||||||
|
j.lastCallAt = time.Now()
|
||||||
|
|
||||||
|
return result, callErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JsonRpcEthereum) throttle(ctx context.Context) {
|
||||||
|
if j.lastCallAt.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
minInterval := time.Duration(rpcMinIntervalMS) * time.Millisecond
|
||||||
|
elapsed := time.Since(j.lastCallAt)
|
||||||
|
if elapsed >= minInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(minInterval - elapsed):
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
|
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
|
||||||
@@ -3,7 +3,7 @@ package ethereum
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EthereumObserver defines the interface for blockchain interaction.
|
// EthereumObserver defines the interface for blockchain interaction.
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -20,7 +20,7 @@ const (
|
|||||||
|
|
||||||
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
|
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
|
||||||
|
|
||||||
// EmailDigestService handles email setup and digest sending via Resend.
|
// handles email setup and digest sending via Resend.
|
||||||
type EmailDigestService struct {
|
type EmailDigestService struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
fromAddress string
|
fromAddress string
|
||||||
@@ -66,7 +66,6 @@ func (s *EmailDigestService) SetupEmail(toAddress string) error {
|
|||||||
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
|
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendDigest compiles recent alert events for a user and sends a digest email.
|
|
||||||
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
|
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
|
||||||
if !s.Configured() {
|
if !s.Configured() {
|
||||||
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
||||||
@@ -131,8 +130,6 @@ func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress s
|
|||||||
return s.send(toAddress, subject, html)
|
return s.send(toAddress, subject, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendDigestsForAllUsers sends a digest email to every user that has
|
|
||||||
// notifications enabled and an email configured.
|
|
||||||
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
|
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
|
||||||
if !s.Configured() {
|
if !s.Configured() {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@@ -4,19 +4,23 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"golang.org/x/sync/semaphore"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/wei"
|
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||||
|
"github.com/kjannette/koin-ping/backend/internal/wei"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
notificationTimeout = 30 * time.Second
|
notificationTimeout = 30 * time.Second
|
||||||
notificationMaxRetries = 3
|
notificationMaxRetries = 3
|
||||||
notificationRetryBase = time.Second
|
notificationRetryBase = time.Second
|
||||||
|
maxConcurrentNotifications = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
type EvaluatorService struct {
|
type EvaluatorService struct {
|
||||||
@@ -27,6 +31,8 @@ type EvaluatorService struct {
|
|||||||
notifConfigs *models.NotificationConfigModel
|
notifConfigs *models.NotificationConfigModel
|
||||||
resendAPIKey string
|
resendAPIKey string
|
||||||
emailFrom string
|
emailFrom string
|
||||||
|
notifSem *semaphore.Weighted
|
||||||
|
notifWg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEvaluatorService(
|
func NewEvaluatorService(
|
||||||
@@ -46,6 +52,7 @@ func NewEvaluatorService(
|
|||||||
notifConfigs: notifConfigs,
|
notifConfigs: notifConfigs,
|
||||||
resendAPIKey: resendAPIKey,
|
resendAPIKey: resendAPIKey,
|
||||||
emailFrom: emailFrom,
|
emailFrom: emailFrom,
|
||||||
|
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,9 +104,9 @@ func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.O
|
|||||||
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||||
switch rule.Type {
|
switch rule.Type {
|
||||||
case domain.AlertIncomingTx:
|
case domain.AlertIncomingTx:
|
||||||
return obs.Direction == domain.DirectionIncoming, nil
|
return s.matchesDirectionalTx(rule, obs, domain.DirectionIncoming)
|
||||||
case domain.AlertOutgoingTx:
|
case domain.AlertOutgoingTx:
|
||||||
return obs.Direction == domain.DirectionOutgoing, nil
|
return s.matchesDirectionalTx(rule, obs, domain.DirectionOutgoing)
|
||||||
case domain.AlertLargeTransfer:
|
case domain.AlertLargeTransfer:
|
||||||
return s.matchesLargeTransfer(rule, obs)
|
return s.matchesLargeTransfer(rule, obs)
|
||||||
case domain.AlertBalanceBelow:
|
case domain.AlertBalanceBelow:
|
||||||
@@ -110,6 +117,42 @@ func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *EvaluatorService) matchesDirectionalTx(rule domain.AlertRule, obs domain.ObservedTx, expected domain.Direction) (bool, error) {
|
||||||
|
if obs.Direction != expected {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.Minimum != nil {
|
||||||
|
minWei, err := wei.FromEth(*rule.Minimum)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
aboveMin, err := wei.GreaterThanOrEqual(obs.Value, minWei)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !aboveMin {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.Maximum != nil {
|
||||||
|
maxWei, err := wei.FromEth(*rule.Maximum)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
belowMax, err := wei.LessThanOrEqual(obs.Value, maxWei)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !belowMax {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||||
if rule.Threshold == nil {
|
if rule.Threshold == nil {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -189,7 +232,14 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
|||||||
if addr != nil {
|
if addr != nil {
|
||||||
userID := addr.UserID
|
userID := addr.UserID
|
||||||
address := addr.Address
|
address := addr.Address
|
||||||
|
if err := s.notifSem.Acquire(ctx, 1); err != nil {
|
||||||
|
log.Printf("Failed to acquire notification semaphore for rule %d: %v", rule.ID, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.notifWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer s.notifSem.Release(1)
|
||||||
|
defer s.notifWg.Done()
|
||||||
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
|
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
|
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
|
||||||
@@ -199,6 +249,11 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WaitForNotifications blocks until all in-flight notification goroutines finish.
|
||||||
|
func (s *EvaluatorService) WaitForNotifications() {
|
||||||
|
s.notifWg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
|
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
|
||||||
var notifiers []notifications.Notifier
|
var notifiers []notifications.Notifier
|
||||||
|
|
||||||
@@ -242,8 +297,14 @@ func sendWithRetry(ctx context.Context, n notifications.Notifier, message string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := n.Send(ctx, message, meta); err != nil {
|
if err := n.Send(ctx, message, meta); err != nil {
|
||||||
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
|
if notifications.IsPermanent(err) {
|
||||||
|
log.Printf("Permanent notification failure, skipping retries: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxBlocksPerRun = 100
|
const maxBlocksPerRun = 100
|
||||||
@@ -90,6 +90,14 @@ func LessThan(weiA, weiB string) (bool, error) {
|
|||||||
return cmp < 0, nil
|
return cmp < 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LessThanOrEqual(weiA, weiB string) (bool, error) {
|
||||||
|
cmp, err := Compare(weiA, weiB)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return cmp <= 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FormatAsEth formats a Wei string as "X.XXXX ETH".
|
// FormatAsEth formats a Wei string as "X.XXXX ETH".
|
||||||
func FormatAsEth(weiString string, decimals int) (string, error) {
|
func FormatAsEth(weiString string, decimals int) (string, error) {
|
||||||
eth, err := ToEth(weiString)
|
eth, err := ToEth(weiString)
|
||||||
BIN
backend/poller
Executable file
BIN
backend/poller
Executable file
Binary file not shown.
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.
@@ -3,10 +3,11 @@ import { useAuth } from "./contexts/AuthContext";
|
|||||||
import Navbar from "./components/Navbar";
|
import Navbar from "./components/Navbar";
|
||||||
import Login from "./pages/login/Login";
|
import Login from "./pages/login/Login";
|
||||||
import Signup from "./pages/Signup";
|
import Signup from "./pages/Signup";
|
||||||
import Subscribe from "./pages/subscribe/subscribe";
|
import Subscribe from "./pages/subscribe/Subscribe";
|
||||||
import Addresses from "./pages/addresses/Addresses";
|
import Addresses from "./pages/addresses/Addresses";
|
||||||
import Alerts from "./pages/alerts/Alerts";
|
import Alerts from "./pages/alerts/Alerts";
|
||||||
import AlertHistory from "./pages/alertHistory/AlertHistory";
|
import AlertHistory from "./pages/alertHistory/AlertHistory";
|
||||||
|
import Account from "./pages/user_account/Account";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
@@ -30,6 +31,7 @@ export default function App() {
|
|||||||
<Route path="/addresses" element={<Addresses />} />
|
<Route path="/addresses" element={<Addresses />} />
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
<Route path="/alertevents" element={<AlertHistory />} />
|
<Route path="/alertevents" element={<AlertHistory />} />
|
||||||
|
<Route path="/account" element={<Account />} />
|
||||||
<Route path="/subscribe" element={<Subscribe />} />
|
<Route path="/subscribe" element={<Subscribe />} />
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
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
|
* Delete an alert rule
|
||||||
* @param {number} alertId - Alert rule ID
|
* @param {number} alertId - Alert rule ID
|
||||||
|
|||||||
@@ -8,6 +8,19 @@
|
|||||||
border-bottom: 1px solid var(--color-border-light);
|
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 {
|
.navbar__brand {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
@@ -38,11 +51,33 @@
|
|||||||
background-color: var(--color-bg-card);
|
background-color: var(--color-bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Right Group: Email + Logout + Hamburger ─────────────── */
|
||||||
|
|
||||||
|
.navbar__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar__user {
|
.navbar__user {
|
||||||
font-size: 1.275rem;
|
font-size: 1.275rem;
|
||||||
color: #777;
|
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 {
|
.navbar__logout {
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.4rem 1rem;
|
||||||
font-size: 1.275rem;
|
font-size: 1.275rem;
|
||||||
@@ -54,3 +89,79 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
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,5 +1,7 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import NavPanel from "./navPanel/NavPanel";
|
||||||
import "./Navbar.css";
|
import "./Navbar.css";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
@@ -11,13 +13,20 @@ const navLinks = [
|
|||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { currentUser, logout } = useAuth();
|
const { currentUser, logout } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
|
||||||
|
|
||||||
if (!currentUser) return null;
|
if (!currentUser) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<nav className="navbar">
|
<nav className="navbar">
|
||||||
<div className="flex flex--center gap-sm">
|
<div className="navbar__left">
|
||||||
|
<div className="navbar__brand-group">
|
||||||
<span className="navbar__brand">Koin Ping</span>
|
<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">
|
<div className="navbar__links">
|
||||||
{navLinks.map(({ to, label }) => {
|
{navLinks.map(({ to, label }) => {
|
||||||
const isActive = location.pathname === to;
|
const isActive = location.pathname === to;
|
||||||
@@ -34,12 +43,29 @@ export default function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex--center gap-lg">
|
<div className="navbar__right">
|
||||||
<span className="navbar__user">{currentUser.email}</span>
|
<Link to="/account" className="navbar__user navbar__user-link navbar__user-link--desktop">
|
||||||
|
{currentUser.email}
|
||||||
|
</Link>
|
||||||
<button onClick={logout} className="navbar__logout">
|
<button onClick={logout} className="navbar__logout">
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="navbar__hamburger"
|
||||||
|
onClick={() => setIsNavPanelOpen(true)}
|
||||||
|
aria-label="Open navigation menu"
|
||||||
|
>
|
||||||
|
<span className="navbar__hamburger-bar" />
|
||||||
|
<span className="navbar__hamburger-bar" />
|
||||||
|
<span className="navbar__hamburger-bar" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<NavPanel
|
||||||
|
isOpen={isNavPanelOpen}
|
||||||
|
onClose={() => setIsNavPanelOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -356,3 +356,73 @@ button {
|
|||||||
color: white;
|
color: white;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Responsive Breakpoints ──────────────────────────────── */
|
||||||
|
/* Mobile: <= 480px */
|
||||||
|
/* Tablet: 481px – 768px */
|
||||||
|
/* Laptop: 769px – 1024px */
|
||||||
|
/* Desktop: >= 1025px (default styles above) */
|
||||||
|
|
||||||
|
/* Laptop & below */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.page--wide {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet & below */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
html {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--lg {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
html {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--lg {
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,3 +28,13 @@
|
|||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Addresses Responsive ────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.address__remove {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,69 @@
|
|||||||
opacity: 0.6;
|
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 {
|
.alerts__email-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -61,3 +124,54 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getAlerts,
|
getAlerts,
|
||||||
createAlert,
|
createAlert,
|
||||||
updateAlertStatus,
|
updateAlertStatus,
|
||||||
|
updateAlertThresholds,
|
||||||
deleteAlert,
|
deleteAlert,
|
||||||
} from "../../api/alerts";
|
} from "../../api/alerts";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +40,64 @@ export default function Alerts() {
|
|||||||
const [sendingDigest, setSendingDigest] = useState(false);
|
const [sendingDigest, setSendingDigest] = useState(false);
|
||||||
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
||||||
|
|
||||||
|
const [openAccordions, setOpenAccordions] = useState({});
|
||||||
|
const [thresholdEdits, setThresholdEdits] = useState({});
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
@@ -314,7 +373,14 @@ export default function Alerts() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="list-unstyled">
|
<ul className="list-unstyled">
|
||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => {
|
||||||
|
const hasAccordion =
|
||||||
|
alert.type === "incoming_tx" ||
|
||||||
|
alert.type === "outgoing_tx";
|
||||||
|
const isOpen = !!openAccordions[alert.id];
|
||||||
|
const edit = getThresholdEdit(alert);
|
||||||
|
|
||||||
|
return (
|
||||||
<li
|
<li
|
||||||
key={alert.id}
|
key={alert.id}
|
||||||
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
||||||
@@ -329,27 +395,110 @@ export default function Alerts() {
|
|||||||
Threshold: {alert.threshold} ETH
|
Threshold: {alert.threshold} ETH
|
||||||
</div>
|
</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">
|
<div className="text-xs text-dimmed">
|
||||||
Status: {alert.enabled ? "Enabled" : "Disabled"}
|
Status:{" "}
|
||||||
|
{alert.enabled ? "Enabled" : "Disabled"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex--center gap-sm">
|
<div className="flex flex--center gap-sm">
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleToggleAlert(alert.id, alert.enabled)
|
handleToggleAlert(
|
||||||
|
alert.id,
|
||||||
|
alert.enabled,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{alert.enabled ? "Disable" : "Enable"}
|
{alert.enabled ? "Disable" : "Enable"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleDeleteAlert(alert.id)}
|
onClick={() =>
|
||||||
|
handleDeleteAlert(alert.id)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
.login-page {
|
.login-page {
|
||||||
min-height: 100vh;
|
position: relative;
|
||||||
background-image: url(/ping.png);
|
width: 100vw;
|
||||||
background-size: 67%;
|
height: 100vh;
|
||||||
background-position: center;
|
overflow: hidden;
|
||||||
background-repeat: no-repeat;
|
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 {
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin: 20px auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
padding-top: 8rem;
|
padding-top: 8rem;
|
||||||
border: 1px solid var(--color-border-light);
|
border: 1px solid var(--color-border-light);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
background-color: rgba(0, 0, 0, 0.75);
|
background-color: rgba(0, 0, 0, 0.75);
|
||||||
|
top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-heading {
|
.login-heading {
|
||||||
@@ -32,7 +51,6 @@
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-footer {
|
.login-footer {
|
||||||
@@ -55,10 +73,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form-hidden {
|
.login-card-fadein {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
animation: fadeIn 2s ease-in-out forwards;
|
||||||
|
animation-delay: 3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form-visible {
|
.login-tagline {
|
||||||
animation: fadeIn 2s ease-in-out forwards;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@ import Input from "../../components/Input";
|
|||||||
import "./Login.css";
|
import "./Login.css";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -36,12 +35,22 @@ export default function Login() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-page">
|
<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">
|
<h1 className="login-heading">
|
||||||
<span className="login-brand">Koin Ping</span> - Login
|
<span className="login-brand">Koin Ping</span> - Login
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className={isVisible ? "login-form-visible" : "login-form-hidden"}>
|
<div className="login-interactive-fadein">
|
||||||
{error && <div className="alert alert--error">{error}</div>}
|
{error && <div className="alert alert--error">{error}</div>}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -78,6 +87,11 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,3 +236,25 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Subscribe Responsive ────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.subscribe {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe__card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar__connector {
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar__dot {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,3 @@
|
|||||||
/**
|
|
||||||
* Subscribe / Subscribe Wizard
|
|
||||||
*
|
|
||||||
* 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
|
|
||||||
* After account creation, user is redirected to Stripe Checkout for payment.
|
|
||||||
* On successful payment, they return here at step 2 (Add Wallet).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
77
frontend/src/pages/user_account/Account.css
Normal file
77
frontend/src/pages/user_account/Account.css
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
.account-page h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__section-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__row--last {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__value {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__uuid {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
word-break: break-all;
|
||||||
|
max-width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__status {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__status--active {
|
||||||
|
color: var(--color-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__status--canceled {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__status--canceling {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__status--past_due {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__portal-section {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__portal-hint {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__password-form {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
202
frontend/src/pages/user_account/Account.jsx
Normal file
202
frontend/src/pages/user_account/Account.jsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { updatePassword } from "firebase/auth";
|
||||||
|
import { auth } from "../../firebase/config";
|
||||||
|
import { getAccount, createPortalSession } from "../../api/account";
|
||||||
|
import "./Account.css";
|
||||||
|
|
||||||
|
export default function Account() {
|
||||||
|
const [account, setAccount] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [passwordMsg, setPasswordMsg] = useState(null);
|
||||||
|
const [passwordErr, setPasswordErr] = useState(null);
|
||||||
|
const [changingPassword, setChangingPassword] = useState(false);
|
||||||
|
|
||||||
|
const [portalLoading, setPortalLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchAccount() {
|
||||||
|
try {
|
||||||
|
const data = await getAccount();
|
||||||
|
setAccount(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchAccount();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handlePasswordChange(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPasswordMsg(null);
|
||||||
|
setPasswordErr(null);
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setPasswordErr("Password must be at least 6 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordErr("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setChangingPassword(true);
|
||||||
|
await updatePassword(auth.currentUser, newPassword);
|
||||||
|
setPasswordMsg("Password updated successfully");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
} catch (err) {
|
||||||
|
setPasswordErr(err.message);
|
||||||
|
} finally {
|
||||||
|
setChangingPassword(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleManageSubscription() {
|
||||||
|
try {
|
||||||
|
setPortalLoading(true);
|
||||||
|
const { url } = await createPortalSession();
|
||||||
|
window.location.href = url;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setPortalLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="page">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !account) {
|
||||||
|
return <div className="page text-error">Error: {error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCanceling = account.cancel_at_period_end;
|
||||||
|
const statusLabel = isCanceling
|
||||||
|
? "Canceling"
|
||||||
|
: account.subscription_status === "active"
|
||||||
|
? "Active"
|
||||||
|
: account.subscription_status.charAt(0).toUpperCase() +
|
||||||
|
account.subscription_status.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page account-page">
|
||||||
|
<h1 className="mb-lg">Account</h1>
|
||||||
|
|
||||||
|
{error && <div className="alert alert--error mb-lg">{error}</div>}
|
||||||
|
|
||||||
|
{/* ── Profile Section ──────────────────────── */}
|
||||||
|
<div className="section mb-lg">
|
||||||
|
<h2 className="account__section-title">Profile</h2>
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">Email</span>
|
||||||
|
<span className="account__value">{account.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">User Name</span>
|
||||||
|
<span className="account__value">{account.user_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="account__row account__row--last">
|
||||||
|
<span className="account__label">User ID</span>
|
||||||
|
<span className="account__value text-mono account__uuid">{account.user_id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Subscription Section ─────────────────── */}
|
||||||
|
<div className="section mb-lg">
|
||||||
|
<h2 className="account__section-title">Subscription</h2>
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">Plan</span>
|
||||||
|
<span className="account__value">{account.subscription_plan}</span>
|
||||||
|
</div>
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">Status</span>
|
||||||
|
<span className={`account__value account__status account__status--${isCanceling ? "canceling" : account.subscription_status}`}>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{account.member_since && (
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">Member Since</span>
|
||||||
|
<span className="account__value">{account.member_since}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{account.next_billing_date && !isCanceling && (
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">Next Billing Date</span>
|
||||||
|
<span className="account__value">{account.next_billing_date}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isCanceling && account.period_end_date && (
|
||||||
|
<div className="account__row">
|
||||||
|
<span className="account__label">Access Until</span>
|
||||||
|
<span className="account__value">{account.period_end_date}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCanceling && (
|
||||||
|
<div className="alert alert--warning mt-md">
|
||||||
|
Your subscription has been canceled and will not renew.
|
||||||
|
You have full access until {account.period_end_date}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="account__portal-section">
|
||||||
|
<button
|
||||||
|
onClick={handleManageSubscription}
|
||||||
|
disabled={portalLoading}
|
||||||
|
className="btn btn--ghost"
|
||||||
|
>
|
||||||
|
{portalLoading ? "Redirecting..." : "Manage Subscription"}
|
||||||
|
</button>
|
||||||
|
<p className="text-dimmed text-sm account__portal-hint">
|
||||||
|
Cancel subscription, update payment method, or view invoices via Stripe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Change Password Section ──────────────── */}
|
||||||
|
<div className="section">
|
||||||
|
<h2 className="account__section-title">Change Password</h2>
|
||||||
|
|
||||||
|
{passwordMsg && <div className="alert alert--success mb-md">{passwordMsg}</div>}
|
||||||
|
{passwordErr && <div className="alert alert--error mb-md">{passwordErr}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handlePasswordChange} className="account__password-form">
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-label">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
disabled={changingPassword}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-field form-field--last">
|
||||||
|
<label className="form-label">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={changingPassword}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={changingPassword} className="btn btn--primary">
|
||||||
|
{changingPassword ? "Updating..." : "Update Password"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user