Compare commits

...

40 Commits

Author SHA1 Message Date
KS Jannette
965830fcde tweak 2026-03-29 08:17:11 -04:00
KS Jannette
0412ea3e99 metric f ton 2026-03-29 07:46:56 -04:00
KS Jannette
0920985a74 more 2026-03-29 07:04:25 -04:00
KS Jannette
9d2a0678be more 2026-03-29 07:02:20 -04:00
KS Jannette
1418e7f97c more 2026-03-29 01:48:55 -04:00
KS Jannette
c0aaaedaf1 more 2026-03-29 01:31:20 -04:00
KS Jannette
3a52e7afb3 more 2026-03-29 00:02:38 -04:00
S Jannette
33ec729631 Merge pull request #29 from kjannette/implement-tiers-end-to-end
Implement tiers end to end
2026-03-28 23:09:45 -04:00
KS Jannette
0d7bc65995 adjust flow 2026-03-28 23:09:03 -04:00
KS Jannette
05453895b9 m 2026-03-28 22:54:52 -04:00
S Jannette
14ed0a23a6 Merge pull request #28 from kjannette/mega-blast
Mega blast
2026-03-28 22:34:56 -04:00
KS Jannette
40ed4f6afd m 2026-03-28 22:34:23 -04:00
KS Jannette
26324150d2 removed F up 2026-03-28 11:58:29 -04:00
KS Jannette
b0572451d3 but 2026-03-28 11:55:51 -04:00
S Jannette
d9c3bd1db5 Merge pull request #27 from kjannette/setup-free-tier
Setup free tier
2026-03-28 11:27:18 -04:00
KS Jannette
615dd1dddc Chockfull 2026-03-28 11:10:25 -04:00
KS Jannette
288092e4b4 More 2026-03-28 10:31:14 -04:00
KS Jannette
69a2112df9 update readme 2026-03-28 08:27:06 -04:00
KS Jannette
4ba91c7d9b Stripe integration tweaks 2026-03-10 17:18:54 -04:00
KS Jannette
f9fa7def2b add subscription tiers 2026-03-10 15:22:48 -04:00
S Jannette
7b45c5b2ab Merge pull request #26 from kjannette/add-alert-thresholds
enhanced alert granularity with min/max thresholds
2026-03-06 23:39:15 -05:00
KS Jannette
0fb4dcd7d1 enhanced alert granularity with min/max thresholds 2026-03-06 23:33:12 -05:00
KS Jannette
d3530deb46 m 2026-03-06 03:09:44 -05:00
KS Jannette
aadcdc628e hot 3 2026-03-06 03:08:47 -05:00
KS Jannette
2d4fcefdcf hottie 2026-03-06 03:00:14 -05:00
KS Jannette
81303573fd quick style changes 2026-03-06 02:51:35 -05:00
KS Jannette
76fadf05d9 hot f 2026-03-05 16:14:51 -05:00
KS Jannette
b9e3d8ae82 hotfix 2026-03-05 14:52:35 -05:00
S Jannette
6c5ffc2340 Merge pull request #25 from kjannette/media-queries-2
mobile navigation
2026-03-05 11:04:07 -05:00
KS Jannette
e32aa4b6aa mobile navigation
Some checks are pending
check / check (push) Waiting to run
2026-03-05 11:03:42 -05:00
S Jannette
682c168250 Merge pull request #24 from kjannette/media-queries
media queries
2026-03-05 10:31:18 -05:00
KS Jannette
e5c8f91e3f media queries
Some checks are pending
check / check (push) Waiting to run
2026-03-05 10:29:37 -05:00
S Jannette
f6255ec1f1 Merge pull request #23 from kjannette/account-info-page
Account info page
2026-03-05 08:26:39 -05:00
KS Jannette
4b77e7a4d4 add user account page and associated functionaility to support it
Some checks are pending
check / check (push) Waiting to run
2026-03-05 08:25:24 -05:00
KS Jannette
8f1212813f edits 2026-03-05 07:39:46 -05:00
S Jannette
95ec1a520a Merge pull request #22 from kjannette/frontend-feature-roundup
style
2026-03-05 07:27:41 -05:00
KS Jannette
4b228d9b57 style
Some checks are pending
check / check (push) Waiting to run
2026-03-05 07:19:12 -05:00
S Jannette
08bc3e1ea2 Merge pull request #21 from kjannette/poller-refinements-3
adjust rpc call to eth block timing
2026-03-05 06:51:54 -05:00
KS Jannette
a091d39c08 adjust rpc call to eth block timing
Some checks are pending
check / check (push) Waiting to run
2026-03-05 06:48:11 -05:00
S Jannette
35699a41bd Merge pull request #20 from kjannette/loginpage-style
Loginpage style
2026-03-05 02:18:22 -05:00
64 changed files with 3339 additions and 1314 deletions

View File

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

2
.gitignore vendored
View File

@@ -22,6 +22,8 @@ node_modules/
# Go build artifacts # Go build artifacts
backend/bin/ backend/bin/
backend/api
backend/poller
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll

View File

@@ -2,8 +2,7 @@ MIT License
Copyright (c) 2026 Steven Jannette Copyright (c) 2026 Steven Jannette
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is

View File

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

View File

@@ -37,7 +37,7 @@ tidy:
# Database setup # Database setup
db-setup: db-setup:
psql -d koin_ping_dev -f infra/schema.sql psql -d koin_ping -f infra/schema.sql
vet: vet:
go vet ./... go vet ./...

View File

@@ -1,20 +1,33 @@
Start DB: Backend startup quickstart:
brew services start postgresql@15 -----------------------------> BEST
## 1. brew services start postgresql@15
From the backend directory, you have a few options: OR
Option 1: Single command (both API + poller) /opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
Option 2: Two separate terminals ## 2. ALLIN ONE:
Terminal 1 (API server): Make dev-all — Runs both the API and poller concurrently.
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api -----------------------------> BEST
Terminal 2 (Poller):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/poller - OR -
## 3. Option 1: Single command (both API + poller)
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
## 4. Option 2: Two separate terminals
Terminal 1 (API server):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
make run — Builds and runs the API server.
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
## 5. Terminal 2 (Poller):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend
go run ./cmd/poller
make poller — Builds and runs the poller.
make poller-dev — Runs the poller with auto-reload.
make run — Builds and runs the API server.
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
make poller — Builds and runs the poller.
make poller-dev — Runs the poller with auto-reload.
make dev-all — Runs both the API and poller concurrently.

Binary file not shown.

View File

@@ -10,7 +10,6 @@ import (
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/kjannette/koin-ping/backend/internal/config" "github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/database" "github.com/kjannette/koin-ping/backend/internal/database"
"github.com/kjannette/koin-ping/backend/internal/firebase"
"github.com/kjannette/koin-ping/backend/internal/handlers" "github.com/kjannette/koin-ping/backend/internal/handlers"
"github.com/kjannette/koin-ping/backend/internal/middleware" "github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models" "github.com/kjannette/koin-ping/backend/internal/models"
@@ -38,10 +37,6 @@ func main() {
log.Fatalf("Failed to connect to database: %v", err) log.Fatalf("Failed to connect to database: %v", err)
} }
if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
log.Fatalf("Failed to initialize Firebase: %v", err)
}
defer database.Close() defer database.Close()
userModel := models.NewUserModel(pool) userModel := models.NewUserModel(pool)
@@ -55,15 +50,17 @@ func main() {
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel, cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
) )
addressHandler := handlers.NewAddressHandler(addressModel) addressHandler := handlers.NewAddressHandler(addressModel, userModel)
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel) alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel, userModel)
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel) alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg) notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel) emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel) statusHandler := handlers.NewStatusHandler(checkpointModel)
stripeHandler := handlers.NewStripeHandler(userModel, cfg) stripeHandler := handlers.NewStripeHandler(userModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
authHandler := handlers.NewAuthHandler(userModel, cfg)
authenticate := middleware.Authenticate(userModel) authenticate := middleware.Authenticate(userModel, cfg.JWTSecret)
requireSub := middleware.RequireSubscription(userModel) requireSub := middleware.RequireSubscription(userModel)
// authAndSub chains authentication + subscription check for protected routes. // authAndSub chains authentication + subscription check for protected routes.
@@ -78,9 +75,16 @@ func main() {
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck) mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus) mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
// Auth routes (public — no token required)
mux.HandleFunc("POST "+b+"/auth/login", authHandler.Login)
mux.HandleFunc("POST "+b+"/auth/register", authHandler.RegisterAfterCheckout)
// Stripe webhook (public — called by Stripe, not authenticated) // Stripe webhook (public — called by Stripe, not authenticated)
mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook) mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook)
// Onboarding checkout (public — account doesn't exist yet)
mux.HandleFunc("POST "+b+"/stripe/create-onboarding-checkout", stripeHandler.CreateOnboardingCheckout)
// Stripe routes (auth required, NO subscription required) // Stripe routes (auth required, NO subscription required)
mux.Handle("POST "+b+"/stripe/create-checkout-session", mux.Handle("POST "+b+"/stripe/create-checkout-session",
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession))) authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
@@ -88,6 +92,14 @@ 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)))
mux.Handle("POST "+b+"/stripe/activate-free",
authenticate(http.HandlerFunc(stripeHandler.ActivateFreeTier)))
// Account route (auth required, NO subscription required)
mux.Handle("GET "+b+"/user/account",
authenticate(http.HandlerFunc(accountHandler.GetAccount)))
// Authenticated + subscribed routes — addresses // Authenticated + subscribed routes — addresses
mux.Handle("POST "+b+"/addresses", mux.Handle("POST "+b+"/addresses",

View File

@@ -51,6 +51,7 @@ func main() {
defer database.Close() defer database.Close()
userModel := models.NewUserModel(pool)
addressModel := models.NewAddressModel(pool) addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool) alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool) alertEventModel := models.NewAlertEventModel(pool)
@@ -59,8 +60,7 @@ func main() {
observer := services.NewObserverService(eth, addressModel, checkpointModel) observer := services.NewObserverService(eth, addressModel, checkpointModel)
evaluator := services.NewEvaluatorService( evaluator := services.NewEvaluatorService(
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel, eth, alertRuleModel, alertEventModel, addressModel, userModel, notifConfigModel,
cfg.ResendAPIKey, cfg.EmailFrom,
) )
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel) digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)

View File

@@ -32,6 +32,7 @@ require (
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
@@ -54,12 +55,12 @@ require (
go.opentelemetry.io/otel/sdk v1.39.0 // indirect go.opentelemetry.io/otel/sdk v1.39.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect

View File

@@ -61,6 +61,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
@@ -128,18 +130,24 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -147,6 +155,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -155,6 +165,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -0,0 +1,6 @@
ALTER TABLE alert_rules ADD COLUMN minimum DECIMAL(20, 6);
ALTER TABLE alert_rules ADD COLUMN maximum DECIMAL(20, 6);
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0);
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0);
ALTER TABLE alert_rules ADD CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum);

View File

@@ -0,0 +1,8 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_tier VARCHAR(20) DEFAULT 'free';
-- Existing active/trialing subscribers were on the single paid plan,
-- which is now the "premium" tier. Backfill them so they aren't downgraded.
UPDATE users
SET subscription_tier = 'premium'
WHERE subscription_status IN ('active', 'trialing')
AND stripe_subscription_id IS NOT NULL;

View File

@@ -0,0 +1,11 @@
-- Migration 010: Add password-based authentication
-- Adds password_hash column, makes firebase_uid optional, ensures email uniqueness.
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255);
ALTER TABLE users ALTER COLUMN firebase_uid DROP NOT NULL;
-- Ensure email is unique so it can serve as the login identifier.
-- Drop the old index first if it exists, then create a unique one.
DROP INDEX IF EXISTS idx_users_email_unique;
CREATE UNIQUE INDEX idx_users_email_unique ON users (email);

View File

@@ -14,6 +14,7 @@ CREATE TABLE users (
stripe_customer_id VARCHAR(255), stripe_customer_id VARCHAR(255),
stripe_subscription_id VARCHAR(255), stripe_subscription_id VARCHAR(255),
subscription_status VARCHAR(50) DEFAULT 'none', subscription_status VARCHAR(50) DEFAULT 'none',
subscription_tier VARCHAR(20) DEFAULT 'free',
subscription_created_at TIMESTAMP, subscription_created_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW() updated_at TIMESTAMP DEFAULT NOW()
@@ -35,6 +36,8 @@ CREATE TABLE alert_rules (
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE, address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below' type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'
threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it) threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it)
minimum DECIMAL(20, 6), -- Optional min amount filter for incoming/outgoing alerts
maximum DECIMAL(20, 6), -- Optional max amount filter for incoming/outgoing alerts
enabled BOOLEAN DEFAULT TRUE, enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
@@ -44,7 +47,11 @@ CREATE TABLE alert_rules (
CONSTRAINT positive_threshold CHECK ( CONSTRAINT positive_threshold CHECK (
threshold IS NULL OR threshold > 0 threshold IS NULL OR threshold > 0
) ),
CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0),
CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0),
CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum)
); );

View File

@@ -32,11 +32,13 @@ type Config struct {
ResendAPIKey string ResendAPIKey string
EmailFrom string EmailFrom string
DigestIntervalHours int DigestIntervalHours int
StripeSecretKey string StripeSecretKey string
StripeWebhookSecret string StripeWebhookSecret string
StripePriceID string StripePriceIDPremium string
StripePublishableKey string StripePriceIDPro string
FrontendURL string StripePublishableKey string
FrontendURL string
JWTSecret string
} }
// Load reads configuration from environment variables and returns a Config. // Load reads configuration from environment variables and returns a Config.
@@ -57,11 +59,13 @@ func Load() (*Config, error) {
ResendAPIKey: os.Getenv("RESEND_API_KEY"), ResendAPIKey: os.Getenv("RESEND_API_KEY"),
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"), EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours), DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
StripePriceID: os.Getenv("STRIPE_PRICE_ID"), StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"), StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
} }
if cfg.PollIntervalMS < minPollIntervalMS { if cfg.PollIntervalMS < minPollIntervalMS {
@@ -92,6 +96,19 @@ func (c *Config) DSN() string {
) )
} }
// TierForPriceID maps a Stripe price ID back to the corresponding
// subscription tier. Returns empty string if the price is unrecognised.
func (c *Config) TierForPriceID(priceID string) string {
switch priceID {
case c.StripePriceIDPremium:
return "premium"
case c.StripePriceIDPro:
return "pro"
default:
return ""
}
}
func getEnv(key, fallback string) string { func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v

View File

@@ -0,0 +1,28 @@
package config
import "testing"
func TestTierForPriceID(t *testing.T) {
t.Parallel()
cfg := &Config{
StripePriceIDPremium: "price_premium_123",
StripePriceIDPro: "price_pro_456",
}
tests := []struct {
priceID string
want string
}{
{"price_premium_123", "premium"},
{"price_pro_456", "pro"},
{"price_unknown", ""},
{"", ""},
}
for _, tt := range tests {
if got := cfg.TierForPriceID(tt.priceID); got != tt.want {
t.Errorf("TierForPriceID(%q) = %q, want %q", tt.priceID, got, tt.want)
}
}
}

View File

@@ -2,17 +2,78 @@ package domain
import "time" import "time"
type SubscriptionTier string
const (
TierFree SubscriptionTier = "free"
TierPremium SubscriptionTier = "premium"
TierPro SubscriptionTier = "pro"
)
func IsValidTier(t string) bool {
switch SubscriptionTier(t) {
case TierFree, TierPremium, TierPro:
return true
}
return false
}
const unlimitedLimit = -1
type TierLimits struct {
MaxAddresses int `json:"max_addresses"`
MaxAlertTypes int `json:"max_alert_types"`
AllowedChannels []string `json:"allowed_channels"`
}
func GetTierLimits(tier SubscriptionTier) TierLimits {
switch tier {
case TierPremium:
return TierLimits{
MaxAddresses: 3,
MaxAlertTypes: 2,
AllowedChannels: []string{"email", "discord", "telegram"},
}
case TierPro:
return TierLimits{
MaxAddresses: unlimitedLimit,
MaxAlertTypes: unlimitedLimit,
AllowedChannels: []string{"email", "discord", "telegram", "slack"},
}
default:
return TierLimits{
MaxAddresses: 1,
MaxAlertTypes: 1,
AllowedChannels: []string{"email"},
}
}
}
func (l TierLimits) IsUnlimitedAddresses() bool { return l.MaxAddresses == unlimitedLimit }
func (l TierLimits) IsUnlimitedAlertTypes() bool { return l.MaxAlertTypes == unlimitedLimit }
func (l TierLimits) ChannelAllowed(channel string) bool {
for _, c := range l.AllowedChannels {
if c == channel {
return true
}
}
return false
}
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
FirebaseUID string `json:"-"` FirebaseUID *string `json:"-"`
Email string `json:"email"` Email string `json:"email"`
DisplayName *string `json:"display_name"` //nolint:tagliatelle DisplayName *string `json:"display_name"` //nolint:tagliatelle
StripeCustomerID *string `json:"-"` PasswordHash *string `json:"-"`
StripeSubscriptionID *string `json:"-"` StripeCustomerID *string `json:"-"`
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle StripeSubscriptionID *string `json:"-"`
SubscriptionCreatedAt *time.Time `json:"subscription_created_at,omitempty"` //nolint:tagliatelle SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle SubscriptionTier SubscriptionTier `json:"subscription_tier"` //nolint:tagliatelle
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle SubscriptionCreatedAt *time.Time `json:"subscription_created_at,omitempty"` //nolint:tagliatelle
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
} }
type Address struct { type Address struct {
@@ -73,6 +134,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
} }

View File

@@ -0,0 +1,104 @@
package domain
import "testing"
func TestIsValidTier(t *testing.T) {
t.Parallel()
valid := []string{"free", "premium", "pro"}
for _, tier := range valid {
if !IsValidTier(tier) {
t.Errorf("expected %q to be valid", tier)
}
}
invalid := []string{"", "basic", "enterprise", "FREE", "Pro"}
for _, tier := range invalid {
if IsValidTier(tier) {
t.Errorf("expected %q to be invalid", tier)
}
}
}
func TestGetTierLimits_Free(t *testing.T) {
t.Parallel()
limits := GetTierLimits(TierFree)
if limits.MaxAddresses != 1 {
t.Errorf("free MaxAddresses = %d, want 1", limits.MaxAddresses)
}
if limits.MaxAlertTypes != 1 {
t.Errorf("free MaxAlertTypes = %d, want 1", limits.MaxAlertTypes)
}
if len(limits.AllowedChannels) != 1 || limits.AllowedChannels[0] != "email" {
t.Errorf("free AllowedChannels = %v, want [email]", limits.AllowedChannels)
}
if limits.IsUnlimitedAddresses() {
t.Error("free should not have unlimited addresses")
}
if limits.IsUnlimitedAlertTypes() {
t.Error("free should not have unlimited alert types")
}
}
func TestGetTierLimits_Premium(t *testing.T) {
t.Parallel()
limits := GetTierLimits(TierPremium)
if limits.MaxAddresses != 3 {
t.Errorf("premium MaxAddresses = %d, want 3", limits.MaxAddresses)
}
if limits.MaxAlertTypes != 2 {
t.Errorf("premium MaxAlertTypes = %d, want 2", limits.MaxAlertTypes)
}
if !limits.ChannelAllowed("email") {
t.Error("premium should allow email")
}
if !limits.ChannelAllowed("discord") {
t.Error("premium should allow discord")
}
if !limits.ChannelAllowed("telegram") {
t.Error("premium should allow telegram")
}
if limits.ChannelAllowed("slack") {
t.Error("premium should NOT allow slack")
}
}
func TestGetTierLimits_Pro(t *testing.T) {
t.Parallel()
limits := GetTierLimits(TierPro)
if !limits.IsUnlimitedAddresses() {
t.Error("pro should have unlimited addresses")
}
if !limits.IsUnlimitedAlertTypes() {
t.Error("pro should have unlimited alert types")
}
for _, ch := range []string{"email", "discord", "telegram", "slack"} {
if !limits.ChannelAllowed(ch) {
t.Errorf("pro should allow %s", ch)
}
}
}
func TestGetTierLimits_Unknown(t *testing.T) {
t.Parallel()
limits := GetTierLimits(SubscriptionTier("unknown"))
if limits.MaxAddresses != 1 {
t.Errorf("unknown tier should default to free limits, got MaxAddresses=%d", limits.MaxAddresses)
}
}
func TestChannelAllowed_NotInList(t *testing.T) {
t.Parallel()
limits := GetTierLimits(TierFree)
if limits.ChannelAllowed("discord") {
t.Error("free should not allow discord")
}
if limits.ChannelAllowed("nonexistent") {
t.Error("nonexistent channel should not be allowed")
}
}

View File

@@ -0,0 +1,88 @@
package handlers
import (
"log"
"net/http"
"time"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
)
type AccountHandler struct {
users *models.UserModel
addresses *models.AddressModel
cfg *config.Config
}
func NewAccountHandler(users *models.UserModel, addresses *models.AddressModel, cfg *config.Config) *AccountHandler {
return &AccountHandler{users: users, addresses: addresses, cfg: cfg}
}
type accountResponse struct {
UserID string `json:"user_id"`
Email string `json:"email"`
UserName string `json:"user_name"`
SubscriptionStatus string `json:"subscription_status"`
SubscriptionTier string `json:"subscription_tier"`
SubscriptionPlan string `json:"subscription_plan"`
TierLimits domain.TierLimits `json:"tier_limits"`
AddressCount int `json:"address_count"`
MemberSince *string `json:"member_since,omitempty"`
NextBillingDate *string `json:"next_billing_date,omitempty"`
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
PeriodEndDate *string `json:"period_end_date,omitempty"`
}
var tierPlanLabels = map[domain.SubscriptionTier]string{ //nolint:gochecknoglobals
domain.TierFree: "Free Trial",
domain.TierPremium: "Premium / $1.99 mo",
domain.TierPro: "Pro / $11.99 mo",
}
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
email := middleware.GetUserEmail(r.Context())
user, err := h.users.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("Account: failed to get user %s: %v", userID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
return
}
addrCount, err := h.addresses.CountByUser(r.Context(), userID)
if err != nil {
log.Printf("Account: failed to count addresses for %s: %v", userID, err)
addrCount = 0
}
planLabel := tierPlanLabels[user.SubscriptionTier]
if planLabel == "" {
planLabel = "Free Trial"
}
resp := accountResponse{
UserID: user.ID,
Email: email,
UserName: email,
SubscriptionStatus: user.SubscriptionStatus,
SubscriptionTier: string(user.SubscriptionTier),
SubscriptionPlan: planLabel,
TierLimits: domain.GetTierLimits(user.SubscriptionTier),
AddressCount: addrCount,
}
if user.SubscriptionCreatedAt != nil {
t := user.SubscriptionCreatedAt.Format(time.DateOnly)
resp.MemberSince = &t
}
resp.NextBillingDate = nil
resp.CancelAtPeriodEnd = false
resp.PeriodEndDate = nil
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -3,6 +3,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"regexp" "regexp"
@@ -17,10 +18,11 @@ var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
type AddressHandler struct { type AddressHandler struct {
addresses *models.AddressModel addresses *models.AddressModel
users *models.UserModel
} }
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler { func NewAddressHandler(addresses *models.AddressModel, users *models.UserModel) *AddressHandler {
return &AddressHandler{addresses: addresses} return &AddressHandler{addresses: addresses, users: users}
} }
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
@@ -49,6 +51,28 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
user, err := h.users.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("Failed to get user %s for tier check: %v", userID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
return
}
limits := domain.GetTierLimits(user.SubscriptionTier)
if !limits.IsUnlimitedAddresses() {
count, err := h.addresses.CountByUser(r.Context(), userID)
if err != nil {
log.Printf("Failed to count addresses for user %s: %v", userID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address")
return
}
if count >= limits.MaxAddresses {
writeError(w, http.StatusForbidden, "TIER_LIMIT_REACHED",
fmt.Sprintf("Your %s plan allows %d address(es). Upgrade to track more.", user.SubscriptionTier, limits.MaxAddresses))
return
}
}
log.Printf("User %s creating address: %s", userID, body.Address) log.Printf("User %s creating address: %s", userID, body.Address)
addr, err := h.addresses.Create(r.Context(), userID, body.Address, body.Label) addr, err := h.addresses.Create(r.Context(), userID, body.Address, body.Label)

View File

@@ -19,10 +19,11 @@ var errThresholdFormat = errors.New("unsupported threshold format")
type AlertRuleHandler struct { type AlertRuleHandler struct {
alertRules *models.AlertRuleModel alertRules *models.AlertRuleModel
addresses *models.AddressModel addresses *models.AddressModel
users *models.UserModel
} }
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler { func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel, users *models.UserModel) *AlertRuleHandler {
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses} return &AlertRuleHandler{alertRules: alertRules, addresses: addresses, users: users}
} }
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
@@ -37,6 +38,8 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
var body struct { 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 +56,40 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
minimum, err := parseThreshold(body.Minimum)
if err != nil {
log.Printf("Failed to parse minimum: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
return
}
maximum, err := parseThreshold(body.Maximum)
if err != nil {
log.Printf("Failed to parse maximum: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
return
}
if minimum != nil && *minimum < 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
return
}
if maximum != nil && *maximum < 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
return
}
if minimum != nil && maximum != nil && *minimum > *maximum {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
return
}
log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID) log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID)
if body.Type == "" { if body.Type == "" {
@@ -96,7 +133,29 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold) user, userErr := h.users.GetByID(r.Context(), userID)
if userErr != nil || user == nil {
log.Printf("Failed to get user %s for tier check: %v", userID, userErr)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
return
}
limits := domain.GetTierLimits(user.SubscriptionTier)
if !limits.IsUnlimitedAlertTypes() {
typeCount, countErr := h.alertRules.CountDistinctTypesByAddress(r.Context(), addressID)
if countErr != nil {
log.Printf("Failed to count alert types for address %d: %v", addressID, countErr)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
return
}
if typeCount >= limits.MaxAlertTypes {
writeError(w, http.StatusForbidden, "TIER_LIMIT_REACHED",
fmt.Sprintf("Your %s plan allows %d alert type(s) per address. Upgrade for more.", user.SubscriptionTier, limits.MaxAlertTypes))
return
}
}
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold, minimum, maximum)
if err != nil { if err != nil {
log.Printf("Error creating alert rule: %v", err) log.Printf("Error creating alert rule: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
@@ -182,7 +241,7 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, alerts) writeJSON(w, http.StatusOK, alerts)
} }
// UpdateStatus handles PATCH requests to enable or disable an alert rule. // UpdateStatus handles PATCH requests to enable/disable an alert rule and/or update min/max thresholds.
func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) { func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
alertID, ok := parseIntParam(r.PathValue("alertId")) alertID, ok := parseIntParam(r.PathValue("alertId"))
@@ -193,7 +252,10 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
} }
var body struct { var body struct {
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
Minimum json.RawMessage `json:"minimum"`
Maximum json.RawMessage `json:"maximum"`
UpdateMinMax bool `json:"update_min_max"` //nolint:tagliatelle
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode update request body: %v", err) log.Printf("Failed to decode update request body: %v", err)
@@ -204,8 +266,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
log.Printf("User %s updating alert ID: %d", userID, alertID) log.Printf("User %s updating alert ID: %d", userID, alertID)
if body.Enabled == nil { if body.Enabled == nil && !body.UpdateMinMax {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "must provide enabled (boolean) or update_min_max with minimum/maximum values")
return return
} }
@@ -224,15 +286,64 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
return return
} }
updated, err := h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled) var updated *domain.AlertRule
if err != nil {
log.Printf("Error updating alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
return if body.UpdateMinMax {
minimum, parseErr := parseThreshold(body.Minimum)
if parseErr != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
return
}
maximum, parseErr := parseThreshold(body.Maximum)
if parseErr != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
return
}
if minimum != nil && *minimum < 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
return
}
if maximum != nil && *maximum < 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
return
}
if minimum != nil && maximum != nil && *minimum > *maximum {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
return
}
updated, err = h.alertRules.UpdateThresholds(r.Context(), alertID, minimum, maximum)
if err != nil {
log.Printf("Error updating alert thresholds: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
return
}
log.Printf("Alert %d thresholds updated: min=%v, max=%v", alertID, minimum, maximum)
}
if body.Enabled != nil {
updated, err = h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
if err != nil {
log.Printf("Error updating alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
return
}
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
} }
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, updated)
} }

View File

@@ -0,0 +1,230 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
"golang.org/x/crypto/bcrypt"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/models"
)
const (
bcryptCost = 12
jwtTTLHours = 72
minPasswordLen = 6
)
type AuthHandler struct {
users *models.UserModel
cfg *config.Config
}
func NewAuthHandler(users *models.UserModel, cfg *config.Config) *AuthHandler {
return &AuthHandler{users: users, cfg: cfg}
}
type authResponse struct {
Token string `json:"token"`
UserID string `json:"user_id"` //nolint:tagliatelle
Email string `json:"email"`
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
SubscriptionTier string `json:"subscription_tier"` //nolint:tagliatelle
}
func (h *AuthHandler) issueJWT(userID, email string) (string, error) {
claims := jwt.MapClaims{
"sub": userID,
"email": email,
"iat": time.Now().Unix(),
"exp": time.Now().Add(jwtTTLHours * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(h.cfg.JWTSecret))
}
// Login authenticates an existing user by email + password.
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
return
}
if body.Email == "" || body.Password == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email and password are required")
return
}
user, err := h.users.FindByEmail(r.Context(), body.Email)
if err != nil {
log.Printf("Login: DB error looking up %s: %v", body.Email, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Login failed")
return
}
if user == nil {
writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid email or password")
return
}
if user.PasswordHash == nil || *user.PasswordHash == "" {
writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid email or password")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(body.Password)); err != nil {
writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid email or password")
return
}
token, err := h.issueJWT(user.ID, user.Email)
if err != nil {
log.Printf("Login: failed to issue JWT for %s: %v", user.ID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Login failed")
return
}
log.Printf("Login successful for user %s (%s)", user.ID, user.Email)
writeJSON(w, http.StatusOK, authResponse{
Token: token,
UserID: user.ID,
Email: user.Email,
SubscriptionStatus: user.SubscriptionStatus,
SubscriptionTier: string(user.SubscriptionTier),
})
}
// RegisterAfterCheckout creates a new user account after a successful Stripe
// checkout. It verifies the Stripe session was paid, hashes the password,
// inserts the user into Postgres, links the Stripe customer/subscription,
// and returns a JWT so the frontend is immediately authenticated.
func (h *AuthHandler) RegisterAfterCheckout(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
SessionID string `json:"session_id"` //nolint:tagliatelle
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
return
}
if body.Email == "" || body.Password == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email and password are required")
return
}
if len(body.Password) < minPasswordLen {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Password must be at least 6 characters")
return
}
existing, err := h.users.FindByEmail(r.Context(), body.Email)
if err != nil {
log.Printf("Register: DB error looking up %s: %v", body.Email, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed")
return
}
if existing != nil {
writeError(w, http.StatusConflict, "EMAIL_IN_USE", "An account with this email already exists")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcryptCost)
if err != nil {
log.Printf("Register: bcrypt error: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed")
return
}
user, err := h.users.CreateWithPassword(r.Context(), body.Email, string(hash))
if err != nil {
log.Printf("Register: failed to create user %s: %v", body.Email, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed")
return
}
// If a Stripe session_id was provided (paid tier checkout), link the
// Stripe customer and activate the subscription immediately.
if body.SessionID != "" {
h.linkStripeSession(r, user.ID, body.SessionID)
}
// Re-fetch to pick up updated subscription fields after Stripe link.
user, err = h.users.GetByID(r.Context(), user.ID)
if err != nil || user == nil {
log.Printf("Register: failed to re-fetch user %s: %v", body.Email, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed")
return
}
token, err := h.issueJWT(user.ID, user.Email)
if err != nil {
log.Printf("Register: failed to issue JWT for %s: %v", user.ID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed")
return
}
log.Printf("Registration successful for user %s (%s)", user.ID, user.Email)
writeJSON(w, http.StatusOK, authResponse{
Token: token,
UserID: user.ID,
Email: user.Email,
SubscriptionStatus: user.SubscriptionStatus,
SubscriptionTier: string(user.SubscriptionTier),
})
}
// linkStripeSession retrieves the Stripe checkout session by ID, verifies
// payment, and writes the Stripe customer + subscription to the user record.
func (h *AuthHandler) linkStripeSession(r *http.Request, userID, sessionID string) {
s, err := checkoutsession.Get(sessionID, nil)
if err != nil {
log.Printf("linkStripeSession: failed to retrieve session %s: %v", sessionID, err)
return
}
fullJSON, _ := json.MarshalIndent(s, "", " ")
log.Printf("STRIPE REGISTER LINK — FULL SESSION:\n%s", string(fullJSON))
if s.PaymentStatus != "paid" {
log.Printf("linkStripeSession: session %s not paid (status=%s)", sessionID, s.PaymentStatus)
return
}
tier := domain.TierPremium
if t, ok := s.Metadata["tier"]; ok && domain.IsValidTier(t) {
tier = domain.SubscriptionTier(t)
}
customerID := ""
if s.Customer != nil {
customerID = s.Customer.ID
}
subscriptionID := ""
if s.Subscription != nil {
subscriptionID = s.Subscription.ID
}
if customerID != "" {
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
log.Printf("linkStripeSession: failed to save customer ID: %v", err)
}
}
if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
log.Printf("linkStripeSession: failed to activate subscription: %v", err)
}
}
log.Printf("linkStripeSession: linked user %s → customer %s, subscription %s, tier %s",
userID, customerID, subscriptionID, tier)
}

View File

@@ -18,11 +18,12 @@ var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
type NotificationConfigHandler struct { type NotificationConfigHandler struct {
configs *models.NotificationConfigModel configs *models.NotificationConfigModel
users *models.UserModel
cfg *config.Config cfg *config.Config
} }
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, cfg *config.Config) *NotificationConfigHandler { func NewNotificationConfigHandler(configs *models.NotificationConfigModel, users *models.UserModel, cfg *config.Config) *NotificationConfigHandler {
return &NotificationConfigHandler{configs: configs, cfg: cfg} return &NotificationConfigHandler{configs: configs, users: users, cfg: cfg}
} }
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) { func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
@@ -95,6 +96,26 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
return return
} }
user, userErr := h.users.GetByID(r.Context(), userID)
if userErr != nil || user == nil {
log.Printf("Failed to get user %s for tier check: %v", userID, userErr)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to verify account")
return
}
limits := domain.GetTierLimits(user.SubscriptionTier)
if !limits.ChannelAllowed("discord") {
body.DiscordWebhookURL = nil
}
if !limits.ChannelAllowed("telegram") {
body.TelegramBotToken = nil
body.TelegramChatID = nil
}
if !limits.ChannelAllowed("slack") {
body.SlackWebhookURL = nil
}
enabled := true enabled := true
if body.NotificationEnabled != nil { if body.NotificationEnabled != nil {
enabled = *body.NotificationEnabled enabled = *body.NotificationEnabled

View File

@@ -2,15 +2,18 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log" "log"
"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/internal/config" "github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware" "github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models" "github.com/kjannette/koin-ping/backend/internal/models"
) )
@@ -27,10 +30,45 @@ func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandle
return &StripeHandler{users: users, cfg: cfg} return &StripeHandler{users: users, cfg: cfg}
} }
// CreateCheckoutSession creates a Stripe Checkout session for the monthly subscription. func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier) (string, error) {
switch tier {
case domain.TierPremium:
return h.cfg.StripePriceIDPremium, nil
case domain.TierPro:
return h.cfg.StripePriceIDPro, nil
default:
return "", fmt.Errorf("no Stripe price for tier %q", tier) //nolint:err113
}
}
// CreateCheckoutSession creates a Stripe Checkout session for the selected tier.
func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) { func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
var body struct {
Tier string `json:"tier"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
return
}
if body.Tier == "" {
body.Tier = "premium"
}
tier := domain.SubscriptionTier(body.Tier)
if tier != domain.TierPremium && tier != domain.TierPro {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'")
return
}
priceID, err := h.priceIDForTier(tier)
if err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return
}
user, err := h.users.GetByID(r.Context(), userID) user, err := h.users.GetByID(r.Context(), userID)
if err != nil || user == nil { if err != nil || user == nil {
log.Printf("Failed to get user %s: %v", userID, err) log.Printf("Failed to get user %s: %v", userID, err)
@@ -42,7 +80,7 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{ LineItems: []*stripe.CheckoutSessionLineItemParams{
{ {
Price: stripe.String(h.cfg.StripePriceID), Price: stripe.String(priceID),
Quantity: stripe.Int64(1), Quantity: stripe.Int64(1),
}, },
}, },
@@ -52,6 +90,8 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
CustomerEmail: stripe.String(user.Email), CustomerEmail: stripe.String(user.Email),
} }
params.AddMetadata("tier", string(tier))
if user.StripeCustomerID != nil && *user.StripeCustomerID != "" { if user.StripeCustomerID != nil && *user.StripeCustomerID != "" {
params.Customer = user.StripeCustomerID params.Customer = user.StripeCustomerID
params.CustomerEmail = nil params.CustomerEmail = nil
@@ -79,14 +119,14 @@ func (h *StripeHandler) GetSubscriptionStatus(w http.ResponseWriter, r *http.Req
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"subscription_status": user.SubscriptionStatus, "subscription_status": user.SubscriptionStatus,
"subscription_tier": user.SubscriptionTier,
"subscription_created_at": user.SubscriptionCreatedAt, "subscription_created_at": user.SubscriptionCreatedAt,
}) })
} }
// VerifyCheckoutSession retrieves a completed checkout session from Stripe, // VerifyCheckoutSession retrieves a completed checkout session from Stripe,
// confirms payment, and activates the user's subscription in the database. // confirms payment, and activates the user's subscription in the database.
// This is the primary activation path; webhooks serve as a backup.
func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Request) { func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
@@ -105,7 +145,11 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
return return
} }
if s.ClientReferenceID != userID { if fullJSON, marshalErr := json.MarshalIndent(s, "", " "); marshalErr == nil {
log.Printf("STRIPE VERIFY CHECKOUT — FULL SESSION:\n%s", string(fullJSON))
}
if s.ClientReferenceID != "" && s.ClientReferenceID != userID {
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user") writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
return return
} }
@@ -115,6 +159,11 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
return return
} }
tier := domain.TierPremium
if t, ok := s.Metadata["tier"]; ok && domain.IsValidTier(t) {
tier = domain.SubscriptionTier(t)
}
customerID := "" customerID := ""
if s.Customer != nil { if s.Customer != nil {
customerID = s.Customer.ID customerID = s.Customer.ID
@@ -130,17 +179,131 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
} }
} }
if subscriptionID != "" && customerID != "" { if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil { if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
log.Printf("VerifyCheckout: failed to activate subscription: %v", err) log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
} }
} }
log.Printf("Checkout verified for user %s, customer %s, subscription %s", userID, customerID, subscriptionID) log.Printf("Checkout verified for user %s, customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier)
writeJSON(w, http.StatusOK, map[string]string{"subscription_status": "active"}) writeJSON(w, http.StatusOK, map[string]string{
"subscription_status": "active",
"subscription_tier": string(tier),
})
}
// ActivateFreeTier sets the user to the free tier without Stripe involvement.
func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
if err := h.users.ActivateFreeTier(r.Context(), userID); err != nil {
log.Printf("ActivateFreeTier: failed for user %s: %v", userID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to activate free tier")
return
}
log.Printf("Free tier activated for user %s", userID)
writeJSON(w, http.StatusOK, map[string]string{
"subscription_status": "active",
"subscription_tier": "free",
})
}
// CreateOnboardingCheckout creates a Stripe Checkout session for a user who
// has not yet created an account. This is a public endpoint (no auth required).
// The Firebase account is created on the frontend only after payment succeeds.
func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Tier string `json:"tier"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
return
}
if body.Email == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email is required")
return
}
if body.Tier == "" {
body.Tier = "premium"
}
tier := domain.SubscriptionTier(body.Tier)
if tier != domain.TierPremium && tier != domain.TierPro {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'")
return
}
priceID, err := h.priceIDForTier(tier)
if err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return
}
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
},
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
CustomerEmail: stripe.String(body.Email),
}
params.AddMetadata("tier", string(tier))
params.AddMetadata("onboarding", "true")
s, err := checkoutsession.New(params)
if err != nil {
log.Printf("Failed to create onboarding checkout session: %v", err)
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
return
}
if fullJSON, marshalErr := json.MarshalIndent(s, "", " "); marshalErr == nil {
log.Printf("STRIPE ONBOARDING CHECKOUT CREATED — FULL SESSION:\n%s", string(fullJSON))
}
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
}
// CreatePortalSession creates a Stripe Billing Portal session so the user can
// manage their subscription (cancel, update payment method, view invoices).
func (h *StripeHandler) CreatePortalSession(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
user, err := h.users.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("Portal: failed to get user %s: %v", userID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
return
}
if user.StripeCustomerID == nil || *user.StripeCustomerID == "" {
writeError(w, http.StatusBadRequest, "NO_CUSTOMER", "No Stripe customer on file")
return
}
params := &stripe.BillingPortalSessionParams{
Customer: user.StripeCustomerID,
ReturnURL: stripe.String(h.cfg.FrontendURL + "/account"),
}
s, err := portalsession.New(params)
if err != nil {
log.Printf("Portal: failed to create portal session: %v", err)
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create portal session")
return
}
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
} }
// HandleWebhook processes incoming Stripe webhook events. // HandleWebhook processes incoming Stripe webhook events.
// 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) {
payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes)) payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes))
if err != nil { if err != nil {
@@ -172,6 +335,8 @@ func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
} }
func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Event) { func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Event) {
log.Printf("STRIPE CHECKOUT COMPLETED — FULL RAW PAYLOAD:\n%s", string(event.Data.Raw))
var session stripe.CheckoutSession var session stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &session); err != nil { if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
log.Printf("Error parsing checkout session: %v", err) log.Printf("Error parsing checkout session: %v", err)
@@ -184,6 +349,11 @@ func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Ev
return return
} }
tier := domain.TierPremium
if t, ok := session.Metadata["tier"]; ok && domain.IsValidTier(t) {
tier = domain.SubscriptionTier(t)
}
customerID := "" customerID := ""
if session.Customer != nil { if session.Customer != nil {
customerID = session.Customer.ID customerID = session.Customer.ID
@@ -200,12 +370,12 @@ func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Ev
} }
if subscriptionID != "" && customerID != "" { if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil { if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
log.Printf("Failed to activate subscription: %v", err) log.Printf("Failed to activate subscription: %v", err)
} }
} }
log.Printf("Checkout completed for user %s, customer %s, subscription %s", userID, customerID, subscriptionID) log.Printf("Checkout completed for user %s, customer %s, subscription %s, tier %s", userID, customerID, subscriptionID, tier)
} }
func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.Event) { func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.Event) {
@@ -224,11 +394,26 @@ func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.
} }
status := string(sub.Status) status := string(sub.Status)
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status); err != nil {
log.Printf("Failed to update subscription status: %v", err) // Detect tier from the subscription's current price so that
// upgrades/downgrades via the Stripe portal are reflected.
tier := domain.TierPremium
if sub.Items != nil {
for _, item := range sub.Items.Data {
if item.Price != nil {
if t := h.cfg.TierForPriceID(item.Price.ID); t != "" {
tier = domain.SubscriptionTier(t)
break
}
}
}
} }
log.Printf("Subscription %s updated to %s for customer %s", sub.ID, status, customerID) if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil {
log.Printf("Failed to update subscription: %v", err)
}
log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID)
} }
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) { func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {

View File

@@ -8,7 +8,8 @@ import (
"net/http" "net/http"
"strings" "strings"
fbauth "github.com/kjannette/koin-ping/backend/internal/firebase" "github.com/golang-jwt/jwt/v5"
"github.com/kjannette/koin-ping/backend/internal/models" "github.com/kjannette/koin-ping/backend/internal/models"
) )
@@ -17,6 +18,7 @@ type contextKey string
const ( const (
UserIDKey contextKey = "user_id" UserIDKey contextKey = "user_id"
UserEmailKey contextKey = "user_email" UserEmailKey contextKey = "user_email"
UserTierKey contextKey = "user_tier"
) )
type errorResponse struct { type errorResponse struct {
@@ -30,10 +32,9 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
json.NewEncoder(w).Encode(v) //nolint:errcheck json.NewEncoder(w).Encode(v) //nolint:errcheck
} }
// Authenticate verifies the Firebase ID token and auto-provisions a local user // Authenticate verifies the JWT from the Authorization header, loads the local
// record. The local user UUID (not the Firebase UID) is placed into context so // user from Postgres, and injects the user UUID + email + tier into context.
// all downstream handlers use it as the canonical user identifier. func Authenticate(userModel *models.UserModel, jwtSecret string) func(http.Handler) http.Handler {
func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
@@ -46,9 +47,8 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
return return
} }
token := strings.TrimPrefix(authHeader, "Bearer ") rawToken := strings.TrimPrefix(authHeader, "Bearer ")
if token == "" { if rawToken == "" {
log.Println("Empty token")
writeJSON(w, http.StatusUnauthorized, errorResponse{ writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED", Error: "UNAUTHORIZED",
Message: "Invalid token format", Message: "Invalid token format",
@@ -56,12 +56,19 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
return return
} }
log.Println("Verifying Firebase token...") parsed, err := jwt.Parse(rawToken, func(t *jwt.Token) (interface{}, error) {
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token) if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
if err != nil { return nil, jwt.ErrSignatureInvalid
log.Printf("Token verification failed: %v", err) }
return []byte(jwtSecret), nil
})
if err != nil || !parsed.Valid {
log.Printf("JWT verification failed: %v", err)
errMsg := err.Error() errMsg := ""
if err != nil {
errMsg = err.Error()
}
if strings.Contains(errMsg, "expired") { if strings.Contains(errMsg, "expired") {
writeJSON(w, http.StatusUnauthorized, errorResponse{ writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "TOKEN_EXPIRED", Error: "TOKEN_EXPIRED",
@@ -77,23 +84,41 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
return return
} }
firebaseUID := decoded.UID claims, ok := parsed.Claims.(jwt.MapClaims)
email, _ := decoded.Claims["email"].(string) if !ok {
writeJSON(w, http.StatusUnauthorized, errorResponse{
user, err := userModel.FindOrCreateByFirebaseUID(r.Context(), firebaseUID, email) Error: "UNAUTHORIZED",
if err != nil { Message: "Invalid token claims",
log.Printf("Failed to provision local user for Firebase UID %s: %v", firebaseUID, err)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "Failed to initialize user account",
}) })
return return
} }
log.Printf("Token verified! User UUID: %s, Email: %s", user.ID, email) userID, _ := claims["sub"].(string)
email, _ := claims["email"].(string)
if userID == "" {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Invalid token: missing user ID",
})
return
}
user, err := userModel.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("JWT auth: user %s not found in DB: %v", userID, err)
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "User account not found",
})
return
}
log.Printf("JWT verified — User UUID: %s, Email: %s", user.ID, email)
ctx := context.WithValue(r.Context(), UserIDKey, user.ID) ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
ctx = context.WithValue(ctx, UserEmailKey, email) ctx = context.WithValue(ctx, UserEmailKey, email)
ctx = context.WithValue(ctx, UserTierKey, string(user.SubscriptionTier))
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
@@ -150,3 +175,10 @@ func GetUserEmail(ctx context.Context) string {
} }
return "" return ""
} }
func GetUserTier(ctx context.Context) string {
if v, ok := ctx.Value(UserTierKey).(string); ok {
return v
}
return "free"
}

View File

@@ -0,0 +1,60 @@
package middleware
import (
"context"
"testing"
)
func TestGetUserID_Empty(t *testing.T) {
t.Parallel()
ctx := context.Background()
if id := GetUserID(ctx); id != "" {
t.Errorf("expected empty user ID, got %q", id)
}
}
func TestGetUserID_Set(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), UserIDKey, "abc-123")
if id := GetUserID(ctx); id != "abc-123" {
t.Errorf("expected abc-123, got %q", id)
}
}
func TestGetUserEmail_Empty(t *testing.T) {
t.Parallel()
ctx := context.Background()
if email := GetUserEmail(ctx); email != "" {
t.Errorf("expected empty email, got %q", email)
}
}
func TestGetUserEmail_Set(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), UserEmailKey, "test@example.com")
if email := GetUserEmail(ctx); email != "test@example.com" {
t.Errorf("expected test@example.com, got %q", email)
}
}
func TestGetUserTier_Default(t *testing.T) {
t.Parallel()
ctx := context.Background()
if tier := GetUserTier(ctx); tier != "free" {
t.Errorf("expected default tier 'free', got %q", tier)
}
}
func TestGetUserTier_Set(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), UserTierKey, "pro")
if tier := GetUserTier(ctx); tier != "pro" {
t.Errorf("expected 'pro', got %q", tier)
}
}

View File

@@ -55,12 +55,14 @@ func (m *AddressModel) ListByUser(ctx context.Context, userID string) ([]domain.
return addresses, rows.Err() return addresses, rows.Err()
} }
// ListAll returns all addresses system-wide (used by the poller). // ListAll returns addresses for users with an active subscription (used by the poller).
func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) { func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) {
rows, err := m.pool.Query(ctx, rows, err := m.pool.Query(ctx,
`SELECT id, user_id, address, label, created_at `SELECT a.id, a.user_id, a.address, a.label, a.created_at
FROM addresses FROM addresses a
ORDER BY created_at DESC`, JOIN users u ON u.id = a.user_id
WHERE u.subscription_status IN ('active', 'trialing')
ORDER BY a.created_at DESC`,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -125,6 +127,15 @@ func (m *AddressModel) UpdateLabel(ctx context.Context, id int, userID string, l
return &a, nil return &a, nil
} }
func (m *AddressModel) CountByUser(ctx context.Context, userID string) (int, error) {
var count int
err := m.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM addresses WHERE user_id = $1`,
userID,
).Scan(&count)
return count, err
}
func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) { func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) {
tag, err := m.pool.Exec(ctx, tag, err := m.pool.Exec(ctx,
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`, `DELETE FROM addresses WHERE id = $1 AND user_id = $2`,

View File

@@ -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,9 @@ 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
@@ -103,6 +103,33 @@ func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool
return &r, nil return &r, nil
} }
func (m *AlertRuleModel) UpdateThresholds(ctx context.Context, id int, minimum, maximum *float64) (*domain.AlertRule, error) {
var r domain.AlertRule
err := m.pool.QueryRow(ctx,
`UPDATE alert_rules
SET minimum = $2, maximum = $3
WHERE id = $1
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
id, minimum, maximum,
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &r, nil
}
func (m *AlertRuleModel) CountDistinctTypesByAddress(ctx context.Context, addressID int) (int, error) {
var count int
err := m.pool.QueryRow(ctx,
`SELECT COUNT(DISTINCT type) FROM alert_rules WHERE address_id = $1`,
addressID,
).Scan(&count)
return count, err
}
func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) { func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
tag, err := m.pool.Exec(ctx, tag, err := m.pool.Exec(ctx,
`DELETE FROM alert_rules WHERE id = $1`, `DELETE FROM alert_rules WHERE id = $1`,

View File

@@ -77,10 +77,12 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) { func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
rows, err := m.pool.Query(ctx, rows, err := m.pool.Query(ctx,
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, `SELECT nc.user_id, nc.discord_webhook_url, nc.telegram_chat_id, nc.telegram_bot_token,
email, slack_webhook_url nc.email, nc.slack_webhook_url
FROM user_notification_configs FROM user_notification_configs nc
WHERE notification_enabled = TRUE`, JOIN users u ON u.id = nc.user_id
WHERE nc.notification_enabled = TRUE
AND u.subscription_status IN ('active', 'trialing')`,
) )
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -17,16 +17,16 @@ func NewUserModel(pool *pgxpool.Pool) *UserModel {
return &UserModel{pool: pool} return &UserModel{pool: pool}
} }
const userColumns = `id, firebase_uid, email, display_name, const userColumns = `id, firebase_uid, email, display_name, password_hash,
stripe_customer_id, stripe_subscription_id, subscription_status, stripe_customer_id, stripe_subscription_id, subscription_status,
subscription_created_at, created_at, updated_at` subscription_tier, subscription_created_at, created_at, updated_at`
func scanUser(row pgx.Row) (*domain.User, error) { func scanUser(row pgx.Row) (*domain.User, error) {
var u domain.User var u domain.User
err := row.Scan( err := row.Scan(
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.PasswordHash,
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus, &u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
&u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt, &u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
) )
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@@ -51,6 +51,23 @@ func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID,
return scanUser(row) return scanUser(row)
} }
func (m *UserModel) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
row := m.pool.QueryRow(ctx,
`SELECT `+userColumns+` FROM users WHERE email = $1`, email,
)
return scanUser(row)
}
func (m *UserModel) CreateWithPassword(ctx context.Context, email, passwordHash string) (*domain.User, error) {
row := m.pool.QueryRow(ctx,
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING `+userColumns,
email, passwordHash,
)
return scanUser(row)
}
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) { func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
row := m.pool.QueryRow(ctx, row := m.pool.QueryRow(ctx,
`SELECT `+userColumns+` FROM users WHERE id = $1`, id, `SELECT `+userColumns+` FROM users WHERE id = $1`, id,
@@ -66,15 +83,37 @@ func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCust
return err return err
} }
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string) error { func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string, tier domain.SubscriptionTier) error {
_, err := m.pool.Exec(ctx, _, err := m.pool.Exec(ctx,
`UPDATE users `UPDATE users
SET stripe_subscription_id = $2, SET stripe_subscription_id = $2,
subscription_status = $3, subscription_status = $3,
subscription_tier = $4,
subscription_created_at = COALESCE(subscription_created_at, NOW()), subscription_created_at = COALESCE(subscription_created_at, NOW()),
updated_at = NOW() updated_at = NOW()
WHERE stripe_customer_id = $1`, WHERE stripe_customer_id = $1`,
stripeCustomerID, subscriptionID, status, stripeCustomerID, subscriptionID, status, string(tier),
)
return err
}
func (m *UserModel) UpdateSubscriptionTier(ctx context.Context, userID string, tier domain.SubscriptionTier) error {
_, err := m.pool.Exec(ctx,
`UPDATE users SET subscription_tier = $2, updated_at = NOW() WHERE id = $1`,
userID, string(tier),
)
return err
}
func (m *UserModel) ActivateFreeTier(ctx context.Context, userID string) error {
_, err := m.pool.Exec(ctx,
`UPDATE users
SET subscription_status = 'active',
subscription_tier = 'free',
subscription_created_at = COALESCE(subscription_created_at, NOW()),
updated_at = NOW()
WHERE id = $1`,
userID,
) )
return err return err
} }

View File

@@ -16,14 +16,16 @@ import (
) )
const ( const (
rpcTimeoutMS = 30000 rpcTimeoutMS = 30000
rpcMaxRetries = 3 rpcMaxRetries = 3
rpcRetryBaseMS = 2000 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.

View File

@@ -28,9 +28,8 @@ type EvaluatorService struct {
alertRules *models.AlertRuleModel alertRules *models.AlertRuleModel
alertEvents *models.AlertEventModel alertEvents *models.AlertEventModel
addresses *models.AddressModel addresses *models.AddressModel
users *models.UserModel
notifConfigs *models.NotificationConfigModel notifConfigs *models.NotificationConfigModel
resendAPIKey string
emailFrom string
notifSem *semaphore.Weighted notifSem *semaphore.Weighted
notifWg sync.WaitGroup notifWg sync.WaitGroup
} }
@@ -40,18 +39,16 @@ func NewEvaluatorService(
alertRules *models.AlertRuleModel, alertRules *models.AlertRuleModel,
alertEvents *models.AlertEventModel, alertEvents *models.AlertEventModel,
addresses *models.AddressModel, addresses *models.AddressModel,
users *models.UserModel,
notifConfigs *models.NotificationConfigModel, notifConfigs *models.NotificationConfigModel,
resendAPIKey string,
emailFrom string,
) *EvaluatorService { ) *EvaluatorService {
return &EvaluatorService{ return &EvaluatorService{
eth: eth, eth: eth,
alertRules: alertRules, alertRules: alertRules,
alertEvents: alertEvents, alertEvents: alertEvents,
addresses: addresses, addresses: addresses,
users: users,
notifConfigs: notifConfigs, notifConfigs: notifConfigs,
resendAPIKey: resendAPIKey,
emailFrom: emailFrom,
notifSem: semaphore.NewWeighted(maxConcurrentNotifications), notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
} }
} }
@@ -104,9 +101,9 @@ func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.O
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) { 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:
@@ -117,6 +114,42 @@ func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRul
} }
} }
func (s *EvaluatorService) matchesDirectionalTx(rule domain.AlertRule, obs domain.ObservedTx, expected domain.Direction) (bool, error) {
if obs.Direction != expected {
return false, nil
}
if rule.Minimum != nil {
minWei, err := wei.FromEth(*rule.Minimum)
if err != nil {
return false, err
}
aboveMin, err := wei.GreaterThanOrEqual(obs.Value, minWei)
if err != nil {
return false, err
}
if !aboveMin {
return false, nil
}
}
if rule.Maximum != nil {
maxWei, err := wei.FromEth(*rule.Maximum)
if err != nil {
return false, err
}
belowMax, err := wei.LessThanOrEqual(obs.Value, maxWei)
if err != nil {
return false, err
}
if !belowMax {
return false, nil
}
}
return true, nil
}
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) { 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
@@ -218,14 +251,16 @@ func (s *EvaluatorService) WaitForNotifications() {
s.notifWg.Wait() s.notifWg.Wait()
} }
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier { func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig, limits domain.TierLimits) []notifications.Notifier {
var notifiers []notifications.Notifier var notifiers []notifications.Notifier
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" { if limits.ChannelAllowed("discord") &&
cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
notifiers = append(notifiers, &notifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL}) notifiers = append(notifiers, &notifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
} }
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" && if limits.ChannelAllowed("telegram") &&
cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" { cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
notifiers = append(notifiers, &notifications.TelegramNotifier{ notifiers = append(notifiers, &notifications.TelegramNotifier{
BotToken: *cfg.TelegramBotToken, BotToken: *cfg.TelegramBotToken,
@@ -233,17 +268,12 @@ func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []noti
}) })
} }
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" { if limits.ChannelAllowed("slack") &&
cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
notifiers = append(notifiers, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL}) notifiers = append(notifiers, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
} }
if cfg.Email != nil && *cfg.Email != "" { // Email is delivered exclusively via the daily digest service, never real-time.
notifiers = append(notifiers, &notifications.EmailNotifier{
APIKey: s.resendAPIKey,
From: s.emailFrom,
To: *cfg.Email,
})
}
return notifiers return notifiers
} }
@@ -289,6 +319,13 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
return return
} }
user, err := s.users.GetByID(ctx, userID)
if err != nil || user == nil {
log.Printf("Failed to load user %s for tier check in notification: %v", userID, err)
return
}
limits := domain.GetTierLimits(user.SubscriptionTier)
meta := notifications.AlertMetadata{ meta := notifications.AlertMetadata{
TxHash: obs.Hash, TxHash: obs.Hash,
AddressLabel: addressLabel, AddressLabel: addressLabel,
@@ -296,7 +333,7 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
Address: address, Address: address,
} }
for _, n := range s.buildNotifiers(notifConfig) { for _, n := range s.buildNotifiers(notifConfig, limits) {
if err := sendWithRetry(ctx, n, message, meta); err != nil { if err := sendWithRetry(ctx, n, message, meta); err != nil {
log.Printf("Notification channel failed for user %s after retries: %v", userID, err) log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
} else { } else {

View File

@@ -90,6 +90,14 @@ func LessThan(weiA, weiB string) (bool, error) {
return cmp < 0, nil return cmp < 0, nil
} }
func LessThanOrEqual(weiA, weiB string) (bool, error) {
cmp, err := Compare(weiA, weiB)
if err != nil {
return false, err
}
return cmp <= 0, nil
}
// FormatAsEth formats a Wei string as "X.XXXX ETH". // FormatAsEth formats a Wei string as "X.XXXX ETH".
func FormatAsEth(weiString string, decimals int) (string, error) { func FormatAsEth(weiString string, decimals int) (string, error) {
eth, err := ToEth(weiString) eth, err := ToEth(weiString)

Binary file not shown.

View File

@@ -2,26 +2,38 @@ import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./contexts/AuthContext"; import { useAuth } from "./contexts/AuthContext";
import Navbar from "./components/Navbar"; import Navbar from "./components/Navbar";
import Login from "./pages/login/Login"; import Login from "./pages/login/Login";
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 { isAuthenticated, isSubscribed, user } = useAuth();
if (!currentUser) { // Unauthenticated: show login + onboarding routes only
if (!isAuthenticated) {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/subscribe" element={<Subscribe />} /> <Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/login" />} /> <Route path="*" element={<Navigate to="/login" />} />
</Routes> </Routes>
); );
} }
// Authenticated but no active subscription: force through subscribe flow
if (!isSubscribed) {
return (
<Routes>
<Route path="/subscribe" element={<Subscribe />} />
<Route path="/account" element={<><Navbar /><Account /></>} />
<Route path="*" element={<Navigate to="/subscribe" />} />
</Routes>
);
}
// Fully authenticated + subscribed: main app
return ( return (
<div> <div>
<Navbar /> <Navbar />
@@ -30,6 +42,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>

View File

@@ -0,0 +1,25 @@
import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from "./config";
export async function getAccount() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/user/account`, { headers });
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to load account");
}
return res.json();
}
export async function createPortalSession() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/create-portal-session`, {
method: "POST",
headers,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to create portal session");
}
return res.json();
}

View File

@@ -129,6 +129,48 @@ export async function updateAlertStatus(alertId, enabled) {
} }
} }
/**
* Update the min/max thresholds on an alert rule
* @param {number} alertId - Alert rule ID
* @param {number|null} minimum - Minimum amount (null to clear)
* @param {number|null} maximum - Maximum amount (null to clear)
* @returns {Promise<Object>} Updated alert rule
*/
export async function updateAlertThresholds(alertId, minimum, maximum) {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
method: "PATCH",
headers: headers,
body: JSON.stringify({
update_min_max: true,
minimum: minimum ?? null,
maximum: maximum ?? null,
}),
});
if (!response.ok) {
let errorMessage = "Failed to update alert thresholds";
try {
const error = await response.json();
errorMessage = error.message || errorMessage;
} catch {
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
if (error.message.includes("fetch")) {
throw new Error(
"Cannot connect to server. Is the backend running?",
);
}
throw error;
}
}
/** /**
* Delete an alert rule * Delete an alert rule
* @param {number} alertId - Alert rule ID * @param {number} alertId - Alert rule ID

27
frontend/src/api/auth.js Normal file
View File

@@ -0,0 +1,27 @@
import { API_BASE } from "./config";
export async function loginUser(email, password) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Login failed");
}
return data;
}
export async function registerAfterCheckout(email, password, sessionId) {
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, session_id: sessionId || "" }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Registration failed");
}
return data;
}

View File

@@ -1,46 +1,31 @@
/** /**
* Auth Headers Helper * Auth Headers Helper
* *
* Provides authentication headers for API calls * Provides authentication headers for API calls.
* Includes Firebase ID token in Authorization header * Reads the JWT from localStorage (set by AuthContext on login).
*/ */
import { auth } from "../firebase/config"; export function getAuthHeaders() {
const token = localStorage.getItem("kp_token");
/** if (!token) {
* Get headers with authentication token throw new Error("No authenticated user");
* @returns {Promise<Object>} Headers object with Authorization }
*/
export async function getAuthHeaders() {
const currentUser = auth.currentUser;
if (!currentUser) { return {
throw new Error("No authenticated user"); "Content-Type": "application/json",
} Authorization: `Bearer ${token}`,
};
// Get Firebase ID token
const token = await currentUser.getIdToken();
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
} }
/** export function getAuthHeadersSimple() {
* Get headers for non-JSON requests (e.g., DELETE with no body) const token = localStorage.getItem("kp_token");
* @returns {Promise<Object>} Headers object with Authorization
*/
export async function getAuthHeadersSimple() {
const currentUser = auth.currentUser;
if (!currentUser) { if (!token) {
throw new Error("No authenticated user"); throw new Error("No authenticated user");
} }
const token = await currentUser.getIdToken(); return {
Authorization: `Bearer ${token}`,
return { };
Authorization: `Bearer ${token}`,
};
} }

View File

@@ -1,15 +1,42 @@
import { getAuthHeaders } from "./authHeaders"; import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from "./config"; import { API_BASE } from "./config";
export async function createCheckoutSession() { export async function createCheckoutSession(tier = "premium") {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, { const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ tier }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to create checkout session");
}
return res.json();
}
export async function createOnboardingCheckout(email, tier = "premium") {
const res = await fetch(`${API_BASE}/stripe/create-onboarding-checkout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, tier }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to create checkout session");
}
return res.json();
}
export async function activateFreeTier() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/activate-free`, {
method: "POST", method: "POST",
headers, headers,
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();
throw new Error(data.message || "Failed to create checkout session"); throw new Error(data.message || "Failed to activate free tier");
} }
return res.json(); return res.json();
} }

View File

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

View File

@@ -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 = [
@@ -9,37 +11,61 @@ const navLinks = [
]; ];
export default function Navbar() { export default function Navbar() {
const { currentUser, logout } = useAuth(); const { user, logout } = useAuth();
const location = useLocation(); const location = useLocation();
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
if (!currentUser) return null; if (!user) return null;
return ( return (
<nav className="navbar"> <>
<div className="flex flex--center gap-sm"> <nav className="navbar">
<span className="navbar__brand">Koin Ping</span> <div className="navbar__left">
<div className="navbar__links"> <div className="navbar__brand-group">
{navLinks.map(({ to, label }) => { <span className="navbar__brand">Koin Ping</span>
const isActive = location.pathname === to; <Link to="/account" className="navbar__user-link navbar__user-link--mobile">
return ( {user.email}
<Link </Link>
key={to} </div>
to={to} <div className="navbar__links">
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`} {navLinks.map(({ to, label }) => {
> const isActive = location.pathname === to;
{label} return (
</Link> <Link
); key={to}
})} to={to}
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
>
{label}
</Link>
);
})}
</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">
<button onClick={logout} className="navbar__logout"> {user.email}
Logout </Link>
</button> <button onClick={logout} className="navbar__logout">
</div> Logout
</nav> </button>
<button
className="navbar__hamburger"
onClick={() => setIsNavPanelOpen(true)}
aria-label="Open navigation menu"
>
<span className="navbar__hamburger-bar" />
<span className="navbar__hamburger-bar" />
<span className="navbar__hamburger-bar" />
</button>
</div>
</nav>
<NavPanel
isOpen={isNavPanelOpen}
onClose={() => setIsNavPanelOpen(false)}
/>
</>
); );
} }

View File

@@ -0,0 +1,120 @@
.tier-picker {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.tier-picker__card {
position: relative;
display: flex;
flex-direction: column;
padding: 1.5rem 1.25rem;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
text-align: center;
}
.tier-picker__card--highlighted {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
}
.tier-picker__card--selected {
border-color: var(--color-success);
box-shadow: 0 0 0 1px var(--color-success);
}
.tier-picker__badge {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
background-color: var(--color-primary);
color: white;
font-size: 0.7rem;
font-weight: 700;
padding: 2px 12px;
border-radius: 10px;
white-space: nowrap;
}
.tier-picker__name {
margin-bottom: 0.5rem;
font-size: 1.05rem;
color: var(--color-text);
}
.tier-picker__price {
margin-bottom: 1.25rem;
}
.tier-picker__amount {
font-size: 2rem;
font-weight: 700;
color: white;
}
.tier-picker__period {
font-size: 0.85rem;
color: var(--color-text-dimmed);
}
.tier-picker__features {
list-style: none;
padding: 0;
margin: 0 0 1.25rem;
text-align: left;
flex-grow: 1;
}
.tier-picker__feature {
padding: 0.3rem 0;
font-size: 0.82rem;
color: var(--color-text-label);
}
.tier-picker__feature--disabled {
color: var(--color-text-dimmed);
opacity: 0.55;
}
.tier-picker__check {
color: var(--color-primary);
font-weight: bold;
margin-right: 0.4rem;
}
.tier-picker__dash {
margin-right: 0.4rem;
}
.tier-picker__btn {
width: 100%;
padding: 0.6rem;
background-color: var(--color-bg-card);
border: 1px solid var(--color-border);
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-md);
font-weight: 600;
}
.tier-picker__btn:hover {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
.tier-picker__btn--selected {
background-color: var(--color-success);
border-color: var(--color-success);
color: white;
}
@media (max-width: 768px) {
.tier-picker {
grid-template-columns: 1fr;
max-width: 400px;
margin: 0 auto;
}
}

View File

@@ -0,0 +1,89 @@
import "./TierPicker.css";
const TIERS = [
{
id: "free",
name: "Trial Monitoring",
price: "$0",
period: "",
features: [
"Monitor 1 blockchain address 24/7",
"Configure alerts to fire on trigger events",
"1 transaction alert type per trigger (email digest)",
],
disabledFeatures: [],
},
{
id: "premium",
name: "Premium Monitoring",
price: "$1.99",
period: "/month",
features: [
"Monitor 3 blockchain addresses",
"Configure two types of rule-based alerts to fire on trigger events for each of the three addresses",
"Daily email digest alert",
"Real-time Discord alerts",
"Real-time Telegram alerts",
],
disabledFeatures: [],
highlighted: true,
},
{
id: "pro",
name: "Professional Monitoring",
price: "$11.99",
period: "/month",
features: [
"Monitor unlimited blockchain addresses",
"Configure unlimited alert rules to fire on unlimited events on any address",
"Daily email digest alert",
"Real-time Discord alerts",
"Real-time Telegram alerts",
"Real-time Slack alerts configurable for multiple Slack groups or channels",
"Unlimited transaction alert types per monitored address",
],
disabledFeatures: [],
},
];
export default function TierPicker({ onSelect, selectedTier }) {
return (
<div className="tier-picker">
{TIERS.map((tier) => (
<div
key={tier.id}
className={`tier-picker__card${tier.highlighted ? " tier-picker__card--highlighted" : ""}${selectedTier === tier.id ? " tier-picker__card--selected" : ""}`}
>
{tier.highlighted && (
<div className="tier-picker__badge">Most Popular</div>
)}
<h3 className="tier-picker__name">{tier.name}</h3>
<div className="tier-picker__price">
<span className="tier-picker__amount">{tier.price}</span>
{tier.period && (
<span className="tier-picker__period">{tier.period}</span>
)}
</div>
<ul className="tier-picker__features">
{tier.features.map((f) => (
<li key={f} className="tier-picker__feature">
<span className="tier-picker__check">&#10003;</span> {f}
</li>
))}
{tier.disabledFeatures.map((f) => (
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
<span className="tier-picker__dash">&mdash;</span> {f}
</li>
))}
</ul>
<button
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
onClick={() => onSelect(tier.id)}
>
{selectedTier === tier.id ? "Selected" : "Select"}
</button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,32 @@
.upgrade-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: var(--radius-md);
background-color: #3a2e00;
border: 1px solid #aa7700;
color: #ffcc44;
}
.upgrade-banner__message {
font-size: 0.88rem;
}
.upgrade-banner__link {
background: none;
border: 1px solid #aa7700;
color: #ffcc44;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
}
.upgrade-banner__link:hover {
background-color: #4a3800;
}

View File

@@ -0,0 +1,18 @@
import { useNavigate } from "react-router-dom";
import "./UpgradeBanner.css";
export default function UpgradeBanner({ message, linkTo = "/subscribe?upgrade=true" }) {
const navigate = useNavigate();
return (
<div className="upgrade-banner">
<span className="upgrade-banner__message">{message}</span>
<button
className="upgrade-banner__link"
onClick={() => navigate(linkTo)}
>
Upgrade
</button>
</div>
);
}

View File

@@ -0,0 +1,78 @@
/* ── Overlay Backdrop ─────────────────────────────────────── */
.nav-panel__overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.45);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 900;
}
.nav-panel__overlay--visible {
opacity: 1;
pointer-events: auto;
}
/* ── Slide-out Panel ─────────────────────────────────────── */
.nav-panel {
position: fixed;
top: 0;
left: 0;
width: 260px;
height: 100%;
background-color: var(--color-bg-elevated);
border-right: 1px solid var(--color-border);
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 950;
display: flex;
flex-direction: column;
padding: 1.5rem 0;
}
.nav-panel--open {
transform: translateX(0);
}
/* ── Nav Links ───────────────────────────────────────────── */
.nav-panel__nav {
display: flex;
flex-direction: column;
flex: 1;
}
.nav-panel__link {
padding: 0.9rem 1.5rem;
text-decoration: none;
font-size: 1.3rem;
font-weight: 200;
color: var(--color-text-subtle);
border-left: 3px solid transparent;
transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-panel__link--active {
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg-card);
border-left-color: var(--color-brand);
}
/* ── Logout Button ───────────────────────────────────────── */
.nav-panel__logout {
margin: 1rem 1.5rem;
padding: 0.5rem 1rem;
font-size: 1.15rem;
font-weight: 400;
background-color: transparent;
color: var(--color-text-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.15s ease;
}

View File

@@ -0,0 +1,52 @@
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
import "./NavPanel.css";
const navLinks = [
{ to: "/addresses", label: "Addresses" },
{ to: "/alerts", label: "Configure Alerts" },
{ to: "/alertevents", label: "Alert Event History" },
];
export default function NavPanel({ isOpen, onClose }) {
const { logout } = useAuth();
const location = useLocation();
const handleNavClick = () => {
onClose();
};
const handleLogout = () => {
onClose();
logout();
};
return (
<>
<div
className={`nav-panel__overlay${isOpen ? " nav-panel__overlay--visible" : ""}`}
onClick={onClose}
/>
<aside className={`nav-panel${isOpen ? " nav-panel--open" : ""}`}>
<nav className="nav-panel__nav">
{navLinks.map(({ to, label }) => {
const isActive = location.pathname === to;
return (
<Link
key={to}
to={to}
className={`nav-panel__link${isActive ? " nav-panel__link--active" : ""}`}
onClick={handleNavClick}
>
{label}
</Link>
);
})}
</nav>
<button onClick={handleLogout} className="nav-panel__logout">
Logout
</button>
</aside>
</>
);
}

View File

@@ -1,114 +1,98 @@
/** import { createContext, useReducer, useContext, useEffect, useCallback, useRef } from "react";
* AuthContext - Firebase Authentication State Management import authReducer, { initialState, ACTION_TYPES } from "../reducers/authReducer";
* import { loginUser, registerAfterCheckout } from "../api/auth";
* Provides authentication state and methods throughout the app import { getAccount } from "../api/account";
*/
import { createContext, useContext, useEffect, useState } from "react"; const AuthContext = createContext(null);
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
onAuthStateChanged,
} from "firebase/auth";
import { auth } from "../firebase/config";
const AuthContext = createContext();
/**
* Hook to access auth context
* @returns {Object} Auth context value
*/
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
/**
* AuthProvider - Wraps app and provides auth state
*/
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [currentUser, setCurrentUser] = useState(null); const [state, dispatch] = useReducer(authReducer, initialState);
const [loading, setLoading] = useState(true); const fetchedRef = useRef(false);
const [error, setError] = useState(null);
/** const fetchAccount = useCallback(async () => {
* Sign up with email and password try {
*/ const account = await getAccount();
async function signup(email, password) { dispatch({
try { type: ACTION_TYPES.SET_USER,
setError(null); payload: {
const result = await createUserWithEmailAndPassword( id: account.user_id,
auth, email: account.email,
email, subscriptionStatus: account.subscription_status,
password, subscriptionTier: account.subscription_tier,
); tierLimits: account.tier_limits,
return result.user; addressCount: account.address_count,
} catch (err) { },
setError(err.message); });
throw err; } catch {
} dispatch({ type: ACTION_TYPES.LOGOUT });
} }
}, []);
/** // On mount, if we have a token in localStorage, hydrate user from backend.
* Log in with email and password useEffect(() => {
*/ if (!state.token || fetchedRef.current) return;
async function login(email, password) { fetchedRef.current = true;
try { fetchAccount();
setError(null); }, [state.token, fetchAccount]);
const result = await signInWithEmailAndPassword(
auth,
email,
password,
);
return result.user;
} catch (err) {
setError(err.message);
throw err;
}
}
/** async function login(email, password) {
* Log out current user const data = await loginUser(email, password);
*/ dispatch({ type: ACTION_TYPES.LOGIN_SUCCESS, payload: data });
async function logout() { fetchedRef.current = false;
try { return data;
setError(null); }
await signOut(auth);
} catch (err) {
setError(err.message);
throw err;
}
}
/** async function register(email, password, sessionId) {
* Listen for auth state changes const data = await registerAfterCheckout(email, password, sessionId);
*/ dispatch({ type: ACTION_TYPES.LOGIN_SUCCESS, payload: data });
useEffect(() => { fetchedRef.current = false;
const unsubscribe = onAuthStateChanged(auth, (user) => { return data;
setCurrentUser(user); }
setLoading(false);
});
// Cleanup subscription function logout() {
return unsubscribe; fetchedRef.current = false;
}, []); dispatch({ type: ACTION_TYPES.LOGOUT });
}
const value = { function refreshAccount() {
currentUser, fetchAccount();
signup, }
login,
logout,
error,
loading,
};
return ( const isAuthenticated = state.isAuthenticated && !!state.token;
<AuthContext.Provider value={value}> const isSubscribed =
{!loading && children} state.user?.subscriptionStatus === "active" ||
</AuthContext.Provider> state.user?.subscriptionStatus === "trialing";
);
const tierLimits = state.user?.tierLimits || {
max_addresses: 1,
max_alert_types: 1,
allowed_channels: ["email"],
};
const value = {
user: state.user,
token: state.token,
isAuthenticated,
isSubscribed,
tierLimits,
userTier: state.user?.subscriptionTier || "free",
login,
register,
logout,
refreshAccount,
dispatch,
ACTION_TYPES,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
} }
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
export default useAuth;

View File

@@ -355,4 +355,82 @@ button {
.text-white-sm { .text-white-sm {
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;
}
}
/* Tier-locked: greyed out, non-interactive overlay for features above the user's plan */
.tier-locked {
opacity: 0.45;
pointer-events: none;
filter: grayscale(40%);
user-select: none;
}
/* Mobile */
@media (max-width: 480px) {
html {
font-size: 12px;
}
.page {
padding: 1rem 0.75rem;
}
.form-control,
.form-select {
font-size: 0.95rem;
}
.btn {
padding: 0.45rem 0.85rem;
font-size: 0.95rem;
}
.btn--lg {
padding: 0.55rem 1rem;
font-size: 1rem;
}
.section {
padding: 0.75rem;
}
} }

View File

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

View File

@@ -1,15 +1,22 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useAuth } from "../../contexts/AuthContext";
import AddressForm from "../../components/AddressForm"; import AddressForm from "../../components/AddressForm";
import UpgradeBanner from "../../components/UpgradeBanner";
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../../api/addresses"; import { getAddresses, createAddress, deleteAddress, updateAddress } from "../../api/addresses";
import "./Addresses.css"; import "./Addresses.css";
export default function Addresses() { export default function Addresses() {
const { tierLimits, refreshAccount } = useAuth();
const [addresses, setAddresses] = useState([]); const [addresses, setAddresses] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [editLabel, setEditLabel] = useState(""); const [editLabel, setEditLabel] = useState("");
const maxAddresses = tierLimits.max_addresses;
const isUnlimited = maxAddresses === -1;
const atLimit = !isUnlimited && addresses.length >= maxAddresses;
useEffect(() => { useEffect(() => {
async function fetchAddresses() { async function fetchAddresses() {
try { try {
@@ -32,6 +39,7 @@ export default function Addresses() {
const newAddress = await createAddress(data); const newAddress = await createAddress(data);
setAddresses((prev) => [...prev, newAddress]); setAddresses((prev) => [...prev, newAddress]);
setError(null); setError(null);
refreshAccount();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to create address:", err); console.error("Failed to create address:", err);
@@ -47,6 +55,7 @@ export default function Addresses() {
await deleteAddress(id); await deleteAddress(id);
setAddresses((prev) => prev.filter((a) => a.id !== id)); setAddresses((prev) => prev.filter((a) => a.id !== id));
setError(null); setError(null);
refreshAccount();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
console.error("Failed to delete address:", err); console.error("Failed to delete address:", err);
@@ -80,7 +89,13 @@ export default function Addresses() {
<div className="page"> <div className="page">
<h1>Add Addresses to Track</h1> <h1>Add Addresses to Track</h1>
<div className="mb-xl"> {atLimit && (
<UpgradeBanner
message={`Your plan allows ${maxAddresses} address${maxAddresses !== 1 ? "es" : ""}. Upgrade to track more.`}
/>
)}
<div className={`mb-xl${atLimit ? " tier-locked" : ""}`}>
<AddressForm onSubmit={handleAddressSubmit} /> <AddressForm onSubmit={handleAddressSubmit} />
</div> </div>

View File

@@ -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,68 @@
gap: 0.75rem; gap: 0.75rem;
margin-top: 1rem; margin-top: 1rem;
} }
/* Locked / disabled notification channel */
.alerts__channel-locked {
opacity: 0.55;
position: relative;
}
.alerts__channel-locked .upgrade-banner {
opacity: 1;
}
.alerts__channel-locked input {
pointer-events: none;
}
/* ── Alerts Page Responsive ──────────────────────────────── */
/* Laptop: tighten the gap */
@media (max-width: 1024px) {
.alerts-grid {
gap: 1.5rem;
}
}
/* Tablet & below: collapse to single column */
@media (max-width: 768px) {
.alerts-grid {
grid-template-columns: 1fr;
gap: 2.5rem;
}
}
/* Mobile: compact spacing */
@media (max-width: 480px) {
.alerts-grid {
gap: 2rem;
}
.alerts__address-info {
padding: 0.75rem;
margin-bottom: 1.25rem;
}
.alerts__rule {
padding: 0.75rem;
}
.alerts__rule .flex--between {
flex-direction: column;
gap: 0.75rem;
}
.alerts__save-test {
flex-direction: column;
}
.alerts__save-test .btn--lg {
width: 100%;
text-align: center;
}
.alerts__email-buttons {
flex-direction: column;
}
}

View File

@@ -1,12 +1,15 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useAuth } from "../../contexts/AuthContext";
import AlertForm from "../../components/AlertForm"; import AlertForm from "../../components/AlertForm";
import Button from "../../components/Button"; import Button from "../../components/Button";
import Input from "../../components/Input"; import Input from "../../components/Input";
import UpgradeBanner from "../../components/UpgradeBanner";
import { getAddresses } from "../../api/addresses"; import { getAddresses } from "../../api/addresses";
import { import {
getAlerts, getAlerts,
createAlert, createAlert,
updateAlertStatus, updateAlertStatus,
updateAlertThresholds,
deleteAlert, deleteAlert,
} from "../../api/alerts"; } from "../../api/alerts";
import { import {
@@ -19,6 +22,8 @@ import {
import "./Alerts.css"; import "./Alerts.css";
export default function Alerts() { export default function Alerts() {
const { tierLimits, userTier } = useAuth();
const [addresses, setAddresses] = useState([]); const [addresses, setAddresses] = useState([]);
const [selectedAddressId, setSelectedAddressId] = useState(null); const [selectedAddressId, setSelectedAddressId] = useState(null);
const [alerts, setAlerts] = useState([]); const [alerts, setAlerts] = useState([]);
@@ -39,6 +44,79 @@ 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({});
const allowedChannels = tierLimits.allowed_channels || ["email"];
const canTelegram = allowedChannels.includes("telegram");
const canDiscord = allowedChannels.includes("discord");
const canSlack = allowedChannels.includes("slack");
const maxAlertTypes = tierLimits.max_alert_types;
const isUnlimitedAlerts = maxAlertTypes === -1;
function distinctAlertTypeCount() {
const types = new Set(alerts.map((a) => a.type));
return types.size;
}
const atAlertLimit = !isUnlimitedAlerts && distinctAlertTypeCount() >= maxAlertTypes;
function toggleAccordion(alertId) {
setOpenAccordions((prev) => ({ ...prev, [alertId]: !prev[alertId] }));
}
function getThresholdEdit(alert) {
if (thresholdEdits[alert.id]) return thresholdEdits[alert.id];
return {
minimum: alert.minimum != null ? String(alert.minimum) : "",
maximum: alert.maximum != null ? String(alert.maximum) : "",
};
}
function setThresholdEdit(alertId, field, value) {
setThresholdEdits((prev) => {
const current = prev[alertId] || getThresholdEditForAlert(alertId);
return { ...prev, [alertId]: { ...current, [field]: value } };
});
}
function getThresholdEditForAlert(alertId) {
const alert = alerts.find((a) => a.id === alertId);
return {
minimum: alert?.minimum != null ? String(alert.minimum) : "",
maximum: alert?.maximum != null ? String(alert.maximum) : "",
};
}
async function handleSaveThresholds(alertId) {
const edit = thresholdEdits[alertId];
if (!edit) return;
const min = edit.minimum.trim() === "" ? null : Number(edit.minimum);
const max = edit.maximum.trim() === "" ? null : Number(edit.maximum);
if (min !== null && isNaN(min)) return;
if (max !== null && isNaN(max)) return;
if (min !== null && max !== null && min > max) return;
try {
const updated = await updateAlertThresholds(alertId, min, max);
setAlerts((prev) =>
prev.map((a) => (a.id === alertId ? updated : a)),
);
setThresholdEdits((prev) => {
const next = { ...prev };
delete next[alertId];
return next;
});
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to update thresholds:", err);
}
}
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
try { try {
@@ -150,11 +228,11 @@ export default function Alerts() {
const config = { const config = {
notification_enabled: notificationEnabled, notification_enabled: notificationEnabled,
discord_webhook_url: discordWebhookUrl || null, discord_webhook_url: canDiscord ? (discordWebhookUrl || null) : null,
telegram_bot_token: telegramBotToken || null, telegram_bot_token: canTelegram ? (telegramBotToken || null) : null,
telegram_chat_id: telegramChatId || null, telegram_chat_id: canTelegram ? (telegramChatId || null) : null,
email: email || null, email: email || null,
slack_webhook_url: slackWebhookUrl || null, slack_webhook_url: canSlack ? (slackWebhookUrl || null) : null,
}; };
await updateNotificationConfig(config); await updateNotificationConfig(config);
@@ -298,7 +376,13 @@ export default function Alerts() {
</div> </div>
</div> </div>
<div className="mb-xl"> {atAlertLimit && userTier !== "pro" && (
<UpgradeBanner
message={`Your ${userTier} plan allows ${maxAlertTypes} alert type${maxAlertTypes !== 1 ? "s" : ""} per address. Upgrade for more.`}
/>
)}
<div className={`mb-xl${atAlertLimit ? " tier-locked" : ""}`}>
<h3>Create New Alert</h3> <h3>Create New Alert</h3>
<AlertForm onSubmit={handleAlertSubmit} /> <AlertForm onSubmit={handleAlertSubmit} />
</div> </div>
@@ -314,42 +398,132 @@ export default function Alerts() {
</p> </p>
) : ( ) : (
<ul className="list-unstyled"> <ul className="list-unstyled">
{alerts.map((alert) => ( {alerts.map((alert) => {
<li const hasAccordion =
key={alert.id} alert.type === "incoming_tx" ||
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`} alert.type === "outgoing_tx";
> const isOpen = !!openAccordions[alert.id];
<div className="flex flex--between"> const edit = getThresholdEdit(alert);
<div>
<div className="text-bold mb-sm"> return (
{formatAlertType(alert.type)} <li
</div> key={alert.id}
{alert.threshold && ( className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
<div className="text-sm text-muted"> >
Threshold: {alert.threshold} ETH <div className="flex flex--between">
<div>
<div className="text-bold mb-sm">
{formatAlertType(alert.type)}
</div> </div>
)} {alert.threshold && (
<div className="text-xs text-dimmed"> <div className="text-sm text-muted">
Status: {alert.enabled ? "Enabled" : "Disabled"} Threshold: {alert.threshold} ETH
</div>
)}
{alert.minimum != null && (
<div className="text-sm text-muted">
Min: {alert.minimum} ETH
</div>
)}
{alert.maximum != null && (
<div className="text-sm text-muted">
Max: {alert.maximum} ETH
</div>
)}
<div className="text-xs text-dimmed">
Status:{" "}
{alert.enabled ? "Enabled" : "Disabled"}
</div>
</div>
<div className="flex flex--center gap-sm">
<Button
onClick={() =>
handleToggleAlert(
alert.id,
alert.enabled,
)
}
>
{alert.enabled ? "Disable" : "Enable"}
</Button>
<Button
onClick={() =>
handleDeleteAlert(alert.id)
}
>
Delete
</Button>
</div> </div>
</div> </div>
<div className="flex flex--center gap-sm">
<Button {hasAccordion && (
onClick={() => <>
handleToggleAlert(alert.id, alert.enabled) <button
} type="button"
> className="alerts__accordion-toggle"
{alert.enabled ? "Disable" : "Enable"} onClick={() =>
</Button> toggleAccordion(alert.id)
<Button }
onClick={() => handleDeleteAlert(alert.id)} >
> <span
Delete className={`alerts__accordion-chevron ${isOpen ? "alerts__accordion-chevron--open" : ""}`}
</Button> >
</div> &#9654;
</div> </span>
</li> Add optional minimum and maximum
))} threshold values
</button>
<div
className={`alerts__accordion-panel ${isOpen ? "alerts__accordion-panel--open" : ""}`}
>
<div className="alerts__threshold-inputs">
<Input
label="Minimum"
type="number"
step="0.000001"
min="0"
value={edit.minimum}
onChange={(v) =>
setThresholdEdit(
alert.id,
"minimum",
v,
)
}
placeholder="No minimum"
/>
<Input
label="Maximum"
type="number"
step="0.000001"
min="0"
value={edit.maximum}
onChange={(v) =>
setThresholdEdit(
alert.id,
"maximum",
v,
)
}
placeholder="No maximum"
/>
</div>
<div className="alerts__threshold-actions">
<Button
onClick={() =>
handleSaveThresholds(alert.id)
}
className="btn--sm"
>
Save
</Button>
</div>
</div>
</>
)}
</li>
);
})}
</ul> </ul>
)} )}
</div> </div>
@@ -391,19 +565,24 @@ export default function Alerts() {
{notificationEnabled && ( {notificationEnabled && (
<> <>
{/* Telegram */} {/* Telegram */}
<div className="section"> <div className={`section${!canTelegram ? " alerts__channel-locked" : ""}`}>
<h3 className="mt-0 mb-md">Telegram</h3> <h3 className="mt-0 mb-md">Telegram</h3>
{!canTelegram && (
<UpgradeBanner message="Upgrade to Premium to enable Telegram alerts" />
)}
<Input <Input
label="Bot Token" label="Bot Token"
value={telegramBotToken} value={telegramBotToken}
onChange={setTelegramBotToken} onChange={setTelegramBotToken}
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ" placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
disabled={!canTelegram}
/> />
<Input <Input
label="Chat ID" label="Chat ID"
value={telegramChatId} value={telegramChatId}
onChange={setTelegramChatId} onChange={setTelegramChatId}
placeholder="-1001234567890" placeholder="-1001234567890"
disabled={!canTelegram}
/> />
<a <a
href="https://core.telegram.org/bots#how-do-i-create-a-bot" href="https://core.telegram.org/bots#how-do-i-create-a-bot"
@@ -448,13 +627,17 @@ export default function Alerts() {
</div> </div>
{/* Discord */} {/* Discord */}
<div className="section"> <div className={`section${!canDiscord ? " alerts__channel-locked" : ""}`}>
<h3 className="mt-0 mb-md">Discord</h3> <h3 className="mt-0 mb-md">Discord</h3>
{!canDiscord && (
<UpgradeBanner message="Upgrade to Premium to enable Discord alerts" />
)}
<Input <Input
label="Discord Webhook URL" label="Discord Webhook URL"
value={discordWebhookUrl} value={discordWebhookUrl}
onChange={setDiscordWebhookUrl} onChange={setDiscordWebhookUrl}
placeholder="https://discord.com/api/webhooks/..." placeholder="https://discord.com/api/webhooks/..."
disabled={!canDiscord}
/> />
<a <a
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks" href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
@@ -467,13 +650,17 @@ export default function Alerts() {
</div> </div>
{/* Slack */} {/* Slack */}
<div className="section"> <div className={`section${!canSlack ? " alerts__channel-locked" : ""}`}>
<h3 className="mt-0 mb-md">Slack</h3> <h3 className="mt-0 mb-md">Slack</h3>
{!canSlack && (
<UpgradeBanner message="Upgrade to Pro to enable Slack alerts" />
)}
<Input <Input
label="Slack Webhook URL" label="Slack Webhook URL"
value={slackWebhookUrl} value={slackWebhookUrl}
onChange={setSlackWebhookUrl} onChange={setSlackWebhookUrl}
placeholder="https://hooks.slack.com/services/..." placeholder="https://hooks.slack.com/services/..."
disabled={!canSlack}
/> />
<a <a
href="https://api.slack.com/messaging/webhooks" href="https://api.slack.com/messaging/webhooks"

View File

@@ -79,8 +79,39 @@
animation-delay: 3s; animation-delay: 3s;
} }
.login-tagline {
position: relative;
z-index: 1;
max-width: 630px;
margin: 5rem auto 0;
padding-left: 2rem;
font-style: italic;
color: #FFFFFF;
font-size: 1.5rem;
line-height: 1.5;
text-align: center;
}
.login-interactive-fadein { .login-interactive-fadein {
opacity: 0; opacity: 0;
animation: fadeIn 2s ease-in-out forwards; animation: fadeIn 2s ease-in-out forwards;
animation-delay: 6s; 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;
}
} }

View File

@@ -27,7 +27,11 @@ export default function Login() {
await login(email, password); await login(email, password);
navigate("/addresses"); navigate("/addresses");
} catch (err) { } catch (err) {
setError("Failed to log in: " + err.message); if (err.message.includes("Invalid email or password")) {
setError("Invalid email or password. Check your credentials or create a new account.");
} else {
setError("Failed to log in: " + err.message);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -42,7 +46,7 @@ export default function Login() {
playsInline playsInline
className="login-bg-video" className="login-bg-video"
> >
<source src="/koin_spin.mp4" type="video/mp4" /> <source src="/koin_exp_2.mp4" type="video/mp4" />
</video> </video>
<div className="login-card login-card-fadein"> <div className="login-card login-card-fadein">
@@ -80,13 +84,18 @@ export default function Login() {
<div className="login-footer"> <div className="login-footer">
<p className="text-muted"> <p className="text-muted">
Don't have an account?{" "} Don't have an account?{" "}
<Link to="/signup" className="login-signup-link"> <Link to="/subscribe" className="login-signup-link">
Sign up here Sign up here
</Link> </Link>
</p> </p>
</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>
); );
} }

View File

@@ -15,6 +15,10 @@
padding: 0 1rem; padding: 0 1rem;
} }
.subscribe__container--wide {
max-width: 920px;
}
.subscribe__title { .subscribe__title {
text-align: center; text-align: center;
margin-bottom: 2rem; margin-bottom: 2rem;
@@ -107,52 +111,6 @@
color: #888; color: #888;
} }
/* Step 5 summary */
.subscribe__summary {
background-color: #1e2e1e;
border: 1px solid #2d5a2d;
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
}
.subscribe__summary-title {
margin: 0 0 0.5rem;
color: #90ee90;
font-weight: bold;
}
.subscribe__summary-list {
margin: 0;
padding-left: 1.25rem;
color: var(--color-text-label);
line-height: 1.8;
}
/* Checkbox rows */
.checkbox-row {
margin-bottom: 1rem;
}
.checkbox-row__label {
display: flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
color: #ddd;
}
.checkbox-row__input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.checkbox-row__nested {
margin-top: 0.5rem;
margin-left: 1.75rem;
}
/* Footer navigation */ /* Footer navigation */
.subscribe__footer { .subscribe__footer {
display: flex; display: flex;
@@ -163,20 +121,6 @@
border-top: 1px solid var(--color-border-light); border-top: 1px solid var(--color-border-light);
} }
/* Test results */
.test-result {
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.test-result--success {
color: #90ee90;
}
.test-result--failure {
color: var(--color-error);
}
/* Step subtitle */ /* Step subtitle */
.subscribe__subtitle { .subscribe__subtitle {
color: #aaa; color: #aaa;
@@ -184,7 +128,7 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Subscribe card (Step 2) */ /* Subscribe card (Step 2 tier cards) */
.subscribe-card { .subscribe-card {
background-color: var(--color-bg-card, #1a1a2e); background-color: var(--color-bg-card, #1a1a2e);
border: 1px solid var(--color-border-light); border: 1px solid var(--color-border-light);
@@ -235,4 +179,26 @@
font-size: 0.8rem; font-size: 0.8rem;
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;
}
}

View File

@@ -0,0 +1,310 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
import { createOnboardingCheckout, activateFreeTier } from "../../api/stripe";
import Input from "../../components/Input";
import Button from "../../components/Button";
import TierPicker from "../../components/TierPicker";
import "./Subscribe.css";
const STEPS = ["Create Account", "Choose Plan"];
export default function Subscribe() {
const { isAuthenticated, register } = useAuth();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const hasPaymentReturn = searchParams.get("payment") === "success";
const [step, setStep] = useState(hasPaymentReturn || isAuthenticated ? 2 : 1);
const [loading, setLoading] = useState(hasPaymentReturn);
const [error, setError] = useState("");
const [data, setData] = useState({
selectedTier: "",
email: "",
password: "",
confirmPassword: "",
});
const registerCalledRef = useRef(false);
function set(field, value) {
setData((prev) => ({ ...prev, [field]: value }));
}
// Handle return from Stripe checkout redirect.
// The ref guard prevents React StrictMode from double-firing this.
useEffect(() => {
if (registerCalledRef.current) return;
const payment = searchParams.get("payment");
const sessionId = searchParams.get("session_id");
if (payment === "cancelled") {
setSearchParams({}, { replace: true });
setStep(2);
setError("Payment was cancelled. Please try again.");
return;
}
if (payment !== "success" || !sessionId) return;
const savedEmail = sessionStorage.getItem("kp_onboard_email");
const savedPassword = sessionStorage.getItem("kp_onboard_password");
if (!savedEmail || !savedPassword) {
setSearchParams({}, { replace: true });
setError("Session expired. Please start the signup process again.");
setStep(1);
setLoading(false);
return;
}
registerCalledRef.current = true;
setSearchParams({}, { replace: true });
setLoading(true);
register(savedEmail, savedPassword, sessionId)
.then(() => {
sessionStorage.removeItem("kp_onboard_email");
sessionStorage.removeItem("kp_onboard_password");
navigate("/addresses", { replace: true });
})
.catch((err) => {
registerCalledRef.current = false;
setError("Registration failed: " + err.message);
setStep(1);
})
.finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Step handlers ─────────────────────────────────────────────────────────
function handleStep1() {
setError("");
if (!data.email || !data.password || !data.confirmPassword) {
setError("Please fill in all fields");
return;
}
if (data.password !== data.confirmPassword) {
setError("Passwords do not match");
return;
}
if (data.password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
setStep(2);
}
async function handleStep2() {
setError("");
if (!data.selectedTier) {
setError("Please select a plan to continue");
return;
}
try {
setLoading(true);
// Free tier: register immediately, then activate free tier
if (data.selectedTier === "free") {
if (!isAuthenticated) {
await register(data.email, data.password, "");
}
await activateFreeTier();
navigate("/addresses", { replace: true });
return;
}
// Paid tier: stash credentials in sessionStorage, then redirect to Stripe
sessionStorage.setItem("kp_onboard_email", data.email);
sessionStorage.setItem("kp_onboard_password", data.password);
const { url } = await createOnboardingCheckout(
data.email,
data.selectedTier,
);
window.location.href = url;
} catch (err) {
if (err.message.includes("already exists")) {
setError("An account with this email already exists. Try logging in instead.");
} else {
setError("Failed to process plan selection: " + err.message);
}
} finally {
setLoading(false);
}
}
// ── Progress bar ──────────────────────────────────────────────────────────
function ProgressBar() {
return (
<div className="progress-bar">
{STEPS.map((label, i) => {
const stepNum = i + 1;
const done = step > stepNum;
const active = step === stepNum;
const dotClass = done
? "progress-bar__dot--done"
: active
? "progress-bar__dot--active"
: "progress-bar__dot--pending";
return (
<div key={label} className="progress-bar__step">
{i > 0 && (
<div
className={`progress-bar__connector ${
done || active
? "progress-bar__connector--active"
: "progress-bar__connector--inactive"
}`}
/>
)}
<div>
<div className={`progress-bar__dot ${dotClass}`}>
{done ? "\u2713" : stepNum}
</div>
<div
className={`progress-bar__label ${
active
? "progress-bar__label--active"
: "progress-bar__label--inactive"
}`}
>
{label}
</div>
</div>
</div>
);
})}
</div>
);
}
// ── Step content ──────────────────────────────────────────────────────────
function StepCreateAccount() {
return (
<>
<h2 className="mb-lg">Create your account</h2>
<Input
label="Email"
type="email"
value={data.email}
onChange={(v) => set("email", v)}
disabled={loading}
placeholder="you@example.com"
/>
<Input
label="Password"
type="password"
value={data.password}
onChange={(v) => set("password", v)}
disabled={loading}
placeholder="At least 6 characters"
/>
<Input
label="Confirm Password"
type="password"
value={data.confirmPassword}
onChange={(v) => set("confirmPassword", v)}
disabled={loading}
placeholder="Repeat your password"
className="form-field--last"
/>
</>
);
}
function StepChoosePlan() {
return (
<>
<h2 className="mb-sm">Choose your monitoring plan</h2>
<p className="subscribe__subtitle">
Select the plan that works best for you. You can upgrade anytime.
</p>
<TierPicker
onSelect={(tier) => set("selectedTier", tier)}
selectedTier={data.selectedTier}
/>
</>
);
}
// ── Footer navigation ─────────────────────────────────────────────────────
function Footer() {
async function handleNext() {
if (step === 1) handleStep1();
else if (step === 2) await handleStep2();
}
function handleBack() {
setError("");
setStep((s) => s - 1);
}
const nextLabel =
step === 1
? "Create Account"
: !data.selectedTier
? "Continue"
: data.selectedTier === "free"
? "Start Free Trial"
: "Subscribe & Continue";
return (
<div className="subscribe__footer">
<div>
{step === 2 && !isAuthenticated && (
<Button onClick={handleBack} disabled={loading} variant="ghost">
Back
</Button>
)}
</div>
<Button
onClick={handleNext}
disabled={loading || (step === 2 && !data.selectedTier)}
className="text-bold"
>
{loading ? "Please wait..." : nextLabel}
</Button>
</div>
);
}
// ── Render ────────────────────────────────────────────────────────────────
const stepContent = {
1: StepCreateAccount(),
2: StepChoosePlan(),
};
return (
<div className="subscribe">
<div
className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}
>
<h1 className="subscribe__title">Koin Ping</h1>
{ProgressBar()}
{error && <div className="alert alert--error">{error}</div>}
<div className="subscribe__card">
{stepContent[step]}
{Footer()}
</div>
{step === 1 && (
<p className="subscribe__login-link">
Already have an account? <a href="/login">Log in here</a>
</p>
)}
</div>
</div>
);
}

View File

@@ -1,679 +0,0 @@
/**
* 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 { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
import { createAddress, getAddresses } from "../../api/addresses";
import { createAlert } from "../../api/alerts";
import {
updateNotificationConfig,
testNotificationChannels,
} from "../../api/notificationConfig";
import { createCheckoutSession, getSubscriptionStatus, verifyCheckoutSession } from "../../api/stripe";
import Input from "../../components/Input";
import Button from "../../components/Button";
import "./Subscribe.css";
const STEPS = [
"Create Account",
"Add Wallet",
"Alert Rules",
"Notifications",
"Done",
];
export default function Subscribe() {
const { currentUser, signup } = useAuth();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [skipWarning, setSkipWarning] = useState("");
const [testResults, setTestResults] = useState(null);
const [testLoading, setTestLoading] = useState(false);
const [data, setData] = useState({
email: "",
password: "",
confirmPassword: "",
walletAddress: "",
walletLabel: "",
createdAddressId: null,
alertIncomingTx: false,
alertOutgoingTx: false,
alertLargeTransfer: false,
largeTransferThreshold: "",
alertBalanceBelow: false,
balanceBelowThreshold: "",
discordWebhookUrl: "",
slackWebhookUrl: "",
notificationEmail: "",
alertsCreated: [],
notificationConfigured: false,
});
function set(field, value) {
setData((prev) => ({ ...prev, [field]: value }));
}
useEffect(() => {
if (!currentUser) return;
getAddresses()
.then((addresses) => {
if (addresses.length > 0) {
navigate("/addresses", { replace: true });
}
})
.catch(() => { });
}, [currentUser, navigate]);
// Handle Stripe redirect back from checkout
useEffect(() => {
if (!currentUser) return;
const payment = searchParams.get("payment");
const sessionId = searchParams.get("session_id");
if (payment === "success" && sessionId) {
setSearchParams({}, { replace: true });
setLoading(true);
verifyCheckoutSession(sessionId)
.then(() => {
setStep(2);
})
.catch((err) => {
setError("Payment verification failed: " + err.message);
setStep(1);
})
.finally(() => setLoading(false));
} else if (payment === "cancelled") {
setSearchParams({}, { replace: true });
setStep(1);
setError("Payment was cancelled. Please try again.");
}
}, [currentUser, searchParams, setSearchParams]);
// ── Step handlers ─────────────────────────────────────────────────────────
async function handleStep1() {
setError("");
if (!data.email || !data.password || !data.confirmPassword) {
setError("Please fill in all fields");
return;
}
if (data.password !== data.confirmPassword) {
setError("Passwords do not match");
return;
}
if (data.password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
try {
setLoading(true);
if (!currentUser) {
await signup(data.email, data.password);
}
const status = await getSubscriptionStatus();
if (status.subscription_status === "active" || status.subscription_status === "trialing") {
setStep(2);
return;
}
const { url } = await createCheckoutSession();
window.location.href = url;
} catch (err) {
if (err.code === "auth/email-already-in-use") {
setError("Email already in use. Try logging in instead.");
} else if (err.code === "auth/invalid-email") {
setError("Invalid email address");
} else if (err.code === "auth/weak-password") {
setError("Password is too weak");
} else {
setError("Failed to create account: " + err.message);
}
setLoading(false);
}
}
async function handleStep2() {
setError("");
if (!data.walletAddress) {
setError("Please enter a wallet address");
return;
}
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
return;
}
try {
setLoading(true);
const created = await createAddress({
address: data.walletAddress,
label: data.walletLabel || undefined,
});
set("createdAddressId", created.id);
setStep(3);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep3() {
setError("");
const rules = [];
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
if (data.alertLargeTransfer) {
if (!data.largeTransferThreshold) {
setError("Please enter a threshold for large transfers");
return;
}
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
}
if (data.alertBalanceBelow) {
if (!data.balanceBelowThreshold) {
setError("Please enter a threshold for balance below");
return;
}
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
}
if (rules.length === 0) {
setStep(4);
return;
}
try {
setLoading(true);
const created = [];
for (const rule of rules) {
const result = await createAlert(data.createdAddressId, rule);
created.push(result);
}
set("alertsCreated", created);
setStep(4);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep4() {
setError("");
const hasAny =
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
if (!hasAny) {
setStep(5);
return;
}
try {
setLoading(true);
await updateNotificationConfig({
notification_enabled: true,
discord_webhook_url: data.discordWebhookUrl || undefined,
slack_webhook_url: data.slackWebhookUrl || undefined,
email: data.notificationEmail || undefined,
});
set("notificationConfigured", true);
setStep(5);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleTestChannels() {
setTestLoading(true);
setTestResults(null);
try {
const results = await testNotificationChannels();
setTestResults(results);
} catch (err) {
setTestResults({ error: err.message });
} finally {
setTestLoading(false);
}
}
// ── Progress bar ──────────────────────────────────────────────────────────
function ProgressBar() {
return (
<div className="progress-bar">
{STEPS.map((label, i) => {
const stepNum = i + 1;
const done = step > stepNum;
const active = step === stepNum;
const dotClass = done
? "progress-bar__dot--done"
: active
? "progress-bar__dot--active"
: "progress-bar__dot--pending";
return (
<div key={label} className="progress-bar__step">
{i > 0 && (
<div
className={`progress-bar__connector ${done || active
? "progress-bar__connector--active"
: "progress-bar__connector--inactive"
}`}
/>
)}
<div>
<div className={`progress-bar__dot ${dotClass}`}>
{done ? "\u2713" : stepNum}
</div>
<div
className={`progress-bar__label ${active
? "progress-bar__label--active"
: "progress-bar__label--inactive"
}`}
>
{label}
</div>
</div>
</div>
);
})}
</div>
);
}
// ── Step content ──────────────────────────────────────────────────────────
function Step1() {
return (
<>
<h2 className="mb-lg">Create your account</h2>
<Input
label="Email"
type="email"
value={data.email}
onChange={(v) => set("email", v)}
disabled={loading}
placeholder="you@example.com"
/>
<Input
label="Password"
type="password"
value={data.password}
onChange={(v) => set("password", v)}
disabled={loading}
placeholder="At least 6 characters"
/>
<Input
label="Confirm Password"
type="password"
value={data.confirmPassword}
onChange={(v) => set("confirmPassword", v)}
disabled={loading}
placeholder="Repeat your password"
className="form-field--last"
/>
</>
);
}
function Step2() {
return (
<>
<h2 className="mb-sm">Add a wallet address</h2>
<p className="subscribe__subtitle">
Enter the Ethereum address you want to monitor.
</p>
<Input
label="ETH Address"
value={data.walletAddress}
onChange={(v) => set("walletAddress", v)}
disabled={loading}
placeholder="0x..."
/>
<Input
label="Label (optional)"
value={data.walletLabel}
onChange={(v) => set("walletLabel", v)}
disabled={loading}
placeholder="e.g. My main wallet"
className="form-field--last"
/>
</>
);
}
function Step3() {
return (
<>
<h2 className="mb-sm">Configure alert rules</h2>
<p className="subscribe__subtitle">
Choose which events trigger notifications. You can change these later.
</p>
<CheckboxRow
checked={data.alertIncomingTx}
onChange={(v) => set("alertIncomingTx", v)}
label="Incoming transaction"
/>
<CheckboxRow
checked={data.alertOutgoingTx}
onChange={(v) => set("alertOutgoingTx", v)}
label="Outgoing transaction"
/>
<CheckboxRow
checked={data.alertLargeTransfer}
onChange={(v) => set("alertLargeTransfer", v)}
label="Large transfer"
>
{data.alertLargeTransfer && (
<div className="checkbox-row__nested">
<Input
type="number"
label=""
value={data.largeTransferThreshold}
onChange={(v) => set("largeTransferThreshold", v)}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
<CheckboxRow
checked={data.alertBalanceBelow}
onChange={(v) => set("alertBalanceBelow", v)}
label="Balance below"
>
{data.alertBalanceBelow && (
<div className="checkbox-row__nested">
<Input
type="number"
label=""
value={data.balanceBelowThreshold}
onChange={(v) => set("balanceBelowThreshold", v)}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
</>
);
}
function Step4() {
return (
<>
<h2 className="mb-sm">Set up notifications</h2>
<p className="subscribe__subtitle">
Add at least one channel so you receive alerts. All fields are optional.
</p>
<div className="mb-md">
<label className="form-label">
Discord Webhook URL{" "}
<a
href="https://support.discord.com/hc/en-us/articles/228383668"
target="_blank"
rel="noreferrer"
className="help-link"
>
(how to get one)
</a>
</label>
<Input
label=""
type="url"
value={data.discordWebhookUrl}
onChange={(v) => set("discordWebhookUrl", v)}
disabled={loading}
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
<div className="mb-md">
<label className="form-label">
Slack Webhook URL{" "}
<a
href="https://api.slack.com/messaging/webhooks"
target="_blank"
rel="noreferrer"
className="help-link"
>
(how to get one)
</a>
</label>
<Input
label=""
type="url"
value={data.slackWebhookUrl}
onChange={(v) => set("slackWebhookUrl", v)}
disabled={loading}
placeholder="https://hooks.slack.com/services/..."
/>
</div>
<Input
label="Email address for alerts"
type="email"
value={data.notificationEmail}
onChange={(v) => set("notificationEmail", v)}
disabled={loading}
placeholder="you@example.com"
className="form-field--last"
/>
</>
);
}
function Step5() {
const alertCount = data.alertsCreated.length;
const hasNotif = data.notificationConfigured;
return (
<>
<h2 className="mb-md">You're all set!</h2>
<div className="subscribe__summary">
<p className="subscribe__summary-title">Summary</p>
<ul className="subscribe__summary-list">
<li>
Wallet address added:{" "}
<span className="text-mono text-white-sm">
{data.walletAddress}
</span>
{data.walletLabel && ` (${data.walletLabel})`}
</li>
<li>
Alert rules configured:{" "}
<span className="text-white">
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
</span>
</li>
<li>
Notification channels:{" "}
<span className="text-white">
{hasNotif ? "Configured" : "Not set up (skipped)"}
</span>
</li>
</ul>
</div>
{hasNotif && (
<div className="mb-lg">
<Button
onClick={handleTestChannels}
disabled={testLoading}
variant="ghost"
>
{testLoading ? "Testing..." : "Test All Channels"}
</Button>
{testResults && (
<div className="mt-md">
{testResults.error ? (
<p className="text-error">{testResults.error}</p>
) : (
<ul className="list-unstyled">
{Object.entries(testResults).map(([channel, result]) => (
<li
key={channel}
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
>
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
{result.message || (result.success ? "OK" : "Failed")}
</li>
))}
</ul>
)}
</div>
)}
</div>
)}
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
Go to Dashboard
</Button>
</>
);
}
// ── Shared helpers ────────────────────────────────────────────────────────
function CheckboxRow({ checked, onChange, label, children }) {
return (
<div className="checkbox-row">
<label className="checkbox-row__label">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="checkbox-row__input"
/>
{label}
</label>
{children}
</div>
);
}
// ── Footer navigation ─────────────────────────────────────────────────────
function Footer() {
if (step === 5) return null;
const canSkip = step === 3 || step === 4;
const canBack = step > 2;
async function handleNext() {
setSkipWarning("");
if (step === 1) await handleStep1();
else if (step === 2) await handleStep2();
else if (step === 3) await handleStep3();
else if (step === 4) await handleStep4();
}
function handleSkip() {
setError("");
setSkipWarning("");
setStep((s) => s + 1);
}
function handleBack() {
setError("");
setSkipWarning("");
setStep((s) => s - 1);
}
const nextLabel = step === 1
? "Create Account & Subscribe"
: step === 4
? "Finish"
: "Next →";
return (
<div className="subscribe__footer">
<div>
{canBack && (
<Button
onClick={handleBack}
disabled={loading}
variant="ghost"
>
Back
</Button>
)}
</div>
<div className="flex gap-md">
{canSkip && (
<Button
onClick={handleSkip}
disabled={loading}
variant="ghost"
>
Skip for now
</Button>
)}
<Button
onClick={handleNext}
disabled={loading}
className="text-bold"
>
{loading ? "Please wait..." : nextLabel}
</Button>
</div>
</div>
);
}
// ── Render ────────────────────────────────────────────────────────────────
const stepContent = {
1: Step1(),
2: Step2(),
3: Step3(),
4: Step4(),
5: Step5(),
};
return (
<div className="subscribe">
<div className="subscribe__container">
<h1 className="subscribe__title">Koin Ping</h1>
{ProgressBar()}
{error && (
<div className="alert alert--error">{error}</div>
)}
{skipWarning && (
<div className="alert alert--warning">{skipWarning}</div>
)}
<div className="subscribe__card">
{stepContent[step]}
{Footer()}
</div>
{step === 1 && (
<p className="subscribe__login-link">
Already have an account?{" "}
<a href="/login">Log in here</a>
</p>
)}
</div>
</div>
);
}

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

View File

@@ -0,0 +1,225 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { getAccount, createPortalSession } from "../../api/account";
import "./Account.css";
const TIER_LABELS = {
free: "Free Trial",
premium: "Premium",
pro: "Pro",
};
export default function Account() {
const navigate = useNavigate();
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);
// TODO: implement password change endpoint on backend
throw new Error("Password change not yet implemented");
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 tier = account.subscription_tier || "free";
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);
const canUpgrade = tier === "free" || tier === "premium";
const hasPaidSub = tier !== "free";
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">{TIER_LABELS[tier] || 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">
{canUpgrade && (
<button
onClick={() => navigate("/subscribe")}
className="btn btn--primary"
>
Upgrade Plan
</button>
)}
{hasPaidSub && (
<button
onClick={handleManageSubscription}
disabled={portalLoading}
className="btn btn--ghost"
>
{portalLoading ? "Redirecting..." : "Manage Subscription"}
</button>
)}
<p className="text-dimmed text-sm account__portal-hint">
{hasPaidSub
? "Cancel subscription, update payment method, or view invoices via Stripe."
: "Upgrade to unlock more addresses, alert types, and notification channels."}
</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>
);
}

View File

@@ -0,0 +1,56 @@
export const ACTION_TYPES = {
LOGIN_SUCCESS: "LOGIN_SUCCESS",
SET_USER: "SET_USER",
LOGOUT: "LOGOUT",
AUTH_ERROR: "AUTH_ERROR",
};
export const initialState = {
user: null,
token: localStorage.getItem("kp_token") || null,
isAuthenticated: !!localStorage.getItem("kp_token"),
error: null,
};
export default function authReducer(state, action) {
switch (action.type) {
case ACTION_TYPES.LOGIN_SUCCESS:
localStorage.setItem("kp_token", action.payload.token);
return {
...state,
user: {
id: action.payload.user_id,
email: action.payload.email,
subscriptionStatus: action.payload.subscription_status,
subscriptionTier: action.payload.subscription_tier,
},
token: action.payload.token,
isAuthenticated: true,
error: null,
};
case ACTION_TYPES.SET_USER:
return {
...state,
user: action.payload,
error: null,
};
case ACTION_TYPES.LOGOUT:
localStorage.removeItem("kp_token");
return {
...initialState,
token: null,
isAuthenticated: false,
};
case ACTION_TYPES.AUTH_ERROR:
return {
...state,
error: action.payload,
};
default:
return state;
}
}

BIN
memberships.pdf Normal file

Binary file not shown.