Compare commits
31 Commits
media-quer
...
refactor-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965830fcde | ||
|
|
0412ea3e99 | ||
|
|
0920985a74 | ||
|
|
9d2a0678be | ||
|
|
1418e7f97c | ||
|
|
c0aaaedaf1 | ||
|
|
3a52e7afb3 | ||
|
|
33ec729631 | ||
|
|
0d7bc65995 | ||
|
|
05453895b9 | ||
|
|
14ed0a23a6 | ||
|
|
40ed4f6afd | ||
|
|
26324150d2 | ||
|
|
b0572451d3 | ||
|
|
d9c3bd1db5 | ||
|
|
615dd1dddc | ||
|
|
288092e4b4 | ||
|
|
69a2112df9 | ||
|
|
4ba91c7d9b | ||
|
|
f9fa7def2b | ||
|
|
7b45c5b2ab | ||
|
|
0fb4dcd7d1 | ||
|
|
d3530deb46 | ||
|
|
aadcdc628e | ||
|
|
2d4fcefdcf | ||
|
|
81303573fd | ||
|
|
76fadf05d9 | ||
|
|
b9e3d8ae82 | ||
|
|
6c5ffc2340 | ||
|
|
e32aa4b6aa | ||
|
|
682c168250 |
@@ -1,9 +0,0 @@
|
|||||||
name: check
|
|
||||||
on: [push]
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
||||||
- run: docker build .
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
3
LICENSE
3
LICENSE
@@ -2,8 +2,7 @@ MIT License
|
|||||||
|
|
||||||
Copyright (c) 2026 Steven Jannette
|
Copyright (c) 2026 Steven Jannette
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
|||||||
187
REPO_POLICIES.md
187
REPO_POLICIES.md
@@ -1,187 +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](<link>)
|
|
||||||
(general, bash, Docker)
|
|
||||||
- [Go](<link>)
|
|
||||||
- [JavaScript](<link>)
|
|
||||||
- [Python](<link>)
|
|
||||||
- [Go HTTP Server Conventions](<link>)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- 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
|
|
||||||
`(<link>)`.
|
|
||||||
|
|
||||||
- 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
|
|
||||||
`(<link>) 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
|
|
||||||
`(<link>)`.
|
|
||||||
|
|
||||||
- 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 @sjdev 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**: [@sjdev](https://sjdev.co).
|
|
||||||
|
|
||||||
- First commit of a new repo should contain only `README.md`.
|
|
||||||
|
|
||||||
- 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
|
|
||||||
`(<link>)<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`
|
|
||||||
@@ -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 ./...
|
||||||
|
|||||||
@@ -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.
|
|
||||||
BIN
backend/api
BIN
backend/api
Binary file not shown.
@@ -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,16 +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, 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.
|
||||||
@@ -79,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)))
|
||||||
@@ -91,6 +94,8 @@ func main() {
|
|||||||
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
|
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
|
||||||
mux.Handle("POST "+b+"/stripe/create-portal-session",
|
mux.Handle("POST "+b+"/stripe/create-portal-session",
|
||||||
authenticate(http.HandlerFunc(stripeHandler.CreatePortalSession)))
|
authenticate(http.HandlerFunc(stripeHandler.CreatePortalSession)))
|
||||||
|
mux.Handle("POST "+b+"/stripe/activate-free",
|
||||||
|
authenticate(http.HandlerFunc(stripeHandler.ActivateFreeTier)))
|
||||||
|
|
||||||
// Account route (auth required, NO subscription required)
|
// Account route (auth required, NO subscription required)
|
||||||
mux.Handle("GET "+b+"/user/account",
|
mux.Handle("GET "+b+"/user/account",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE alert_rules ADD COLUMN minimum DECIMAL(20, 6);
|
||||||
|
ALTER TABLE alert_rules ADD COLUMN maximum DECIMAL(20, 6);
|
||||||
|
|
||||||
|
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0);
|
||||||
|
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0);
|
||||||
|
ALTER TABLE alert_rules ADD CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum);
|
||||||
8
backend/infra/migrations/009_add_subscription_tier.sql
Normal file
8
backend/infra/migrations/009_add_subscription_tier.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_tier VARCHAR(20) DEFAULT 'free';
|
||||||
|
|
||||||
|
-- Existing active/trialing subscribers were on the single paid plan,
|
||||||
|
-- which is now the "premium" tier. Backfill them so they aren't downgraded.
|
||||||
|
UPDATE users
|
||||||
|
SET subscription_tier = 'premium'
|
||||||
|
WHERE subscription_status IN ('active', 'trialing')
|
||||||
|
AND stripe_subscription_id IS NOT NULL;
|
||||||
11
backend/infra/migrations/010_add_password_auth.sql
Normal file
11
backend/infra/migrations/010_add_password_auth.sql
Normal 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);
|
||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
28
backend/internal/config/config_test.go
Normal file
28
backend/internal/config/config_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTierForPriceID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
StripePriceIDPremium: "price_premium_123",
|
||||||
|
StripePriceIDPro: "price_pro_456",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
priceID string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"price_premium_123", "premium"},
|
||||||
|
{"price_pro_456", "pro"},
|
||||||
|
{"price_unknown", ""},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := cfg.TierForPriceID(tt.priceID); got != tt.want {
|
||||||
|
t.Errorf("TierForPriceID(%q) = %q, want %q", tt.priceID, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,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
|
||||||
}
|
}
|
||||||
|
|||||||
104
backend/internal/domain/types_test.go
Normal file
104
backend/internal/domain/types_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsValidTier(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
valid := []string{"free", "premium", "pro"}
|
||||||
|
for _, tier := range valid {
|
||||||
|
if !IsValidTier(tier) {
|
||||||
|
t.Errorf("expected %q to be valid", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{"", "basic", "enterprise", "FREE", "Pro"}
|
||||||
|
for _, tier := range invalid {
|
||||||
|
if IsValidTier(tier) {
|
||||||
|
t.Errorf("expected %q to be invalid", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Free(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierFree)
|
||||||
|
if limits.MaxAddresses != 1 {
|
||||||
|
t.Errorf("free MaxAddresses = %d, want 1", limits.MaxAddresses)
|
||||||
|
}
|
||||||
|
if limits.MaxAlertTypes != 1 {
|
||||||
|
t.Errorf("free MaxAlertTypes = %d, want 1", limits.MaxAlertTypes)
|
||||||
|
}
|
||||||
|
if len(limits.AllowedChannels) != 1 || limits.AllowedChannels[0] != "email" {
|
||||||
|
t.Errorf("free AllowedChannels = %v, want [email]", limits.AllowedChannels)
|
||||||
|
}
|
||||||
|
if limits.IsUnlimitedAddresses() {
|
||||||
|
t.Error("free should not have unlimited addresses")
|
||||||
|
}
|
||||||
|
if limits.IsUnlimitedAlertTypes() {
|
||||||
|
t.Error("free should not have unlimited alert types")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Premium(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierPremium)
|
||||||
|
if limits.MaxAddresses != 3 {
|
||||||
|
t.Errorf("premium MaxAddresses = %d, want 3", limits.MaxAddresses)
|
||||||
|
}
|
||||||
|
if limits.MaxAlertTypes != 2 {
|
||||||
|
t.Errorf("premium MaxAlertTypes = %d, want 2", limits.MaxAlertTypes)
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("email") {
|
||||||
|
t.Error("premium should allow email")
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("discord") {
|
||||||
|
t.Error("premium should allow discord")
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("telegram") {
|
||||||
|
t.Error("premium should allow telegram")
|
||||||
|
}
|
||||||
|
if limits.ChannelAllowed("slack") {
|
||||||
|
t.Error("premium should NOT allow slack")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Pro(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierPro)
|
||||||
|
if !limits.IsUnlimitedAddresses() {
|
||||||
|
t.Error("pro should have unlimited addresses")
|
||||||
|
}
|
||||||
|
if !limits.IsUnlimitedAlertTypes() {
|
||||||
|
t.Error("pro should have unlimited alert types")
|
||||||
|
}
|
||||||
|
for _, ch := range []string{"email", "discord", "telegram", "slack"} {
|
||||||
|
if !limits.ChannelAllowed(ch) {
|
||||||
|
t.Errorf("pro should allow %s", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Unknown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(SubscriptionTier("unknown"))
|
||||||
|
if limits.MaxAddresses != 1 {
|
||||||
|
t.Errorf("unknown tier should default to free limits, got MaxAddresses=%d", limits.MaxAddresses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelAllowed_NotInList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierFree)
|
||||||
|
if limits.ChannelAllowed("discord") {
|
||||||
|
t.Error("free should not allow discord")
|
||||||
|
}
|
||||||
|
if limits.ChannelAllowed("nonexistent") {
|
||||||
|
t.Error("nonexistent channel should not be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,29 +6,40 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AccountHandler struct {
|
type AccountHandler struct {
|
||||||
users *models.UserModel
|
users *models.UserModel
|
||||||
cfg *config.Config
|
addresses *models.AddressModel
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAccountHandler(users *models.UserModel, cfg *config.Config) *AccountHandler {
|
func NewAccountHandler(users *models.UserModel, addresses *models.AddressModel, cfg *config.Config) *AccountHandler {
|
||||||
return &AccountHandler{users: users, cfg: cfg}
|
return &AccountHandler{users: users, addresses: addresses, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
type accountResponse struct {
|
type accountResponse struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
UserName string `json:"user_name"`
|
UserName string `json:"user_name"`
|
||||||
SubscriptionStatus string `json:"subscription_status"`
|
SubscriptionStatus string `json:"subscription_status"`
|
||||||
SubscriptionPlan string `json:"subscription_plan"`
|
SubscriptionTier string `json:"subscription_tier"`
|
||||||
MemberSince *string `json:"member_since,omitempty"`
|
SubscriptionPlan string `json:"subscription_plan"`
|
||||||
NextBillingDate *string `json:"next_billing_date,omitempty"`
|
TierLimits domain.TierLimits `json:"tier_limits"`
|
||||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
AddressCount int `json:"address_count"`
|
||||||
PeriodEndDate *string `json:"period_end_date,omitempty"`
|
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) {
|
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -42,12 +53,26 @@ func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
resp := accountResponse{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Email: email,
|
Email: email,
|
||||||
UserName: email,
|
UserName: email,
|
||||||
SubscriptionStatus: user.SubscriptionStatus,
|
SubscriptionStatus: user.SubscriptionStatus,
|
||||||
SubscriptionPlan: "monthly/$1.99",
|
SubscriptionTier: string(user.SubscriptionTier),
|
||||||
|
SubscriptionPlan: planLabel,
|
||||||
|
TierLimits: domain.GetTierLimits(user.SubscriptionTier),
|
||||||
|
AddressCount: addrCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.SubscriptionCreatedAt != nil {
|
if user.SubscriptionCreatedAt != nil {
|
||||||
@@ -55,9 +80,6 @@ func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
resp.MemberSince = &t
|
resp.MemberSince = &t
|
||||||
}
|
}
|
||||||
|
|
||||||
// MOCKED: NextBillingDate, CancelAtPeriodEnd, PeriodEndDate.
|
|
||||||
// TODO: stripe-go v82 removed Subscription.CurrentPeriodEnd / CancelAtPeriodEnd.
|
|
||||||
// Research SubscriptionItem.CurrentPeriodEnd or use Stripe REST API directly.
|
|
||||||
resp.NextBillingDate = nil
|
resp.NextBillingDate = nil
|
||||||
resp.CancelAtPeriodEnd = false
|
resp.CancelAtPeriodEnd = false
|
||||||
resp.PeriodEndDate = nil
|
resp.PeriodEndDate = nil
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
230
backend/internal/handlers/auth.go
Normal file
230
backend/internal/handlers/auth.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"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"
|
||||||
)
|
)
|
||||||
@@ -28,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)
|
||||||
@@ -43,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),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -53,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
|
||||||
@@ -80,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())
|
||||||
|
|
||||||
@@ -106,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
|
||||||
}
|
}
|
||||||
@@ -116,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
|
||||||
@@ -131,13 +179,96 @@ 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
|
// CreatePortalSession creates a Stripe Billing Portal session so the user can
|
||||||
@@ -173,7 +304,6 @@ func (h *StripeHandler) CreatePortalSession(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -205,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)
|
||||||
@@ -217,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
|
||||||
@@ -233,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) {
|
||||||
@@ -257,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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
60
backend/internal/middleware/auth_test.go
Normal file
60
backend/internal/middleware/auth_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetUserID_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if id := GetUserID(ctx); id != "" {
|
||||||
|
t.Errorf("expected empty user ID, got %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserID_Set(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), UserIDKey, "abc-123")
|
||||||
|
if id := GetUserID(ctx); id != "abc-123" {
|
||||||
|
t.Errorf("expected abc-123, got %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserEmail_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if email := GetUserEmail(ctx); email != "" {
|
||||||
|
t.Errorf("expected empty email, got %q", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserEmail_Set(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), UserEmailKey, "test@example.com")
|
||||||
|
if email := GetUserEmail(ctx); email != "test@example.com" {
|
||||||
|
t.Errorf("expected test@example.com, got %q", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserTier_Default(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if tier := GetUserTier(ctx); tier != "free" {
|
||||||
|
t.Errorf("expected default tier 'free', got %q", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserTier_Set(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), UserTierKey, "pro")
|
||||||
|
if tier := GetUserTier(ctx); tier != "pro" {
|
||||||
|
t.Errorf("expected 'pro', got %q", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
if limits.ChannelAllowed("telegram") &&
|
||||||
|
cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||||
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||||
notifiers = append(notifiers, ¬ifications.TelegramNotifier{
|
notifiers = append(notifiers, ¬ifications.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, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
|
notifiers = append(notifiers, ¬ifications.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, ¬ifications.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 {
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ func LessThan(weiA, weiB string) (bool, error) {
|
|||||||
return cmp < 0, nil
|
return cmp < 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LessThanOrEqual(weiA, weiB string) (bool, error) {
|
||||||
|
cmp, err := Compare(weiA, weiB)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return cmp <= 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FormatAsEth formats a Wei string as "X.XXXX ETH".
|
// FormatAsEth formats a Wei string as "X.XXXX ETH".
|
||||||
func FormatAsEth(weiString string, decimals int) (string, error) {
|
func FormatAsEth(weiString string, decimals int) (string, error) {
|
||||||
eth, err := ToEth(weiString)
|
eth, err := ToEth(weiString)
|
||||||
|
|||||||
BIN
backend/poller → frontend/public/koin_exp_2.mp4
Executable file → Normal file
BIN
backend/poller → frontend/public/koin_exp_2.mp4
Executable file → Normal file
Binary file not shown.
@@ -2,27 +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";
|
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 />
|
||||||
|
|||||||
@@ -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
27
frontend/src/api/auth.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +51,14 @@
|
|||||||
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;
|
||||||
@@ -53,6 +74,10 @@
|
|||||||
color: var(--color-primary-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;
|
||||||
@@ -65,59 +90,78 @@
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Navbar Responsive ───────────────────────────────────── */
|
/* ── 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) {
|
@media (max-width: 768px) {
|
||||||
.navbar {
|
.navbar {
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar__brand {
|
.navbar__brand-group {
|
||||||
margin-right: 1rem;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar__links {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar__link {
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar__user {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar__logout {
|
|
||||||
padding: 0.3rem 0.75rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.navbar {
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 0.75rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar__links {
|
|
||||||
width: 100%;
|
|
||||||
gap: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar__link {
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar__brand {
|
.navbar__brand {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link--mobile {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link--desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__logout {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__hamburger {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive: Mobile (≤ 480px) ────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.navbar {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__brand {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar__user-link--mobile {
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import NavPanel from "./navPanel/NavPanel";
|
||||||
import "./Navbar.css";
|
import "./Navbar.css";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
@@ -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">
|
||||||
<Link to="/account" className="navbar__user navbar__user-link">{currentUser.email}</Link>
|
<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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
120
frontend/src/components/TierPicker.css
Normal file
120
frontend/src/components/TierPicker.css
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
.tier-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem 1.25rem;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__card--highlighted {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__card--selected {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__name {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__price {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__amount {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__period {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__features {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
text-align: left;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__feature {
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__feature--disabled {
|
||||||
|
color: var(--color-text-dimmed);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__check {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__dash {
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__btn:hover {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-picker__btn--selected {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tier-picker {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
frontend/src/components/TierPicker.jsx
Normal file
89
frontend/src/components/TierPicker.jsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import "./TierPicker.css";
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{
|
||||||
|
id: "free",
|
||||||
|
name: "Trial Monitoring",
|
||||||
|
price: "$0",
|
||||||
|
period: "",
|
||||||
|
features: [
|
||||||
|
"Monitor 1 blockchain address 24/7",
|
||||||
|
"Configure alerts to fire on trigger events",
|
||||||
|
"1 transaction alert type per trigger (email digest)",
|
||||||
|
],
|
||||||
|
disabledFeatures: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "premium",
|
||||||
|
name: "Premium Monitoring",
|
||||||
|
price: "$1.99",
|
||||||
|
period: "/month",
|
||||||
|
features: [
|
||||||
|
"Monitor 3 blockchain addresses",
|
||||||
|
"Configure two types of rule-based alerts to fire on trigger events for each of the three addresses",
|
||||||
|
"Daily email digest alert",
|
||||||
|
"Real-time Discord alerts",
|
||||||
|
"Real-time Telegram alerts",
|
||||||
|
],
|
||||||
|
disabledFeatures: [],
|
||||||
|
highlighted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: "Professional Monitoring",
|
||||||
|
price: "$11.99",
|
||||||
|
period: "/month",
|
||||||
|
features: [
|
||||||
|
"Monitor unlimited blockchain addresses",
|
||||||
|
"Configure unlimited alert rules to fire on unlimited events on any address",
|
||||||
|
"Daily email digest alert",
|
||||||
|
"Real-time Discord alerts",
|
||||||
|
"Real-time Telegram alerts",
|
||||||
|
"Real-time Slack alerts configurable for multiple Slack groups or channels",
|
||||||
|
"Unlimited transaction alert types per monitored address",
|
||||||
|
],
|
||||||
|
disabledFeatures: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TierPicker({ onSelect, selectedTier }) {
|
||||||
|
return (
|
||||||
|
<div className="tier-picker">
|
||||||
|
{TIERS.map((tier) => (
|
||||||
|
<div
|
||||||
|
key={tier.id}
|
||||||
|
className={`tier-picker__card${tier.highlighted ? " tier-picker__card--highlighted" : ""}${selectedTier === tier.id ? " tier-picker__card--selected" : ""}`}
|
||||||
|
>
|
||||||
|
{tier.highlighted && (
|
||||||
|
<div className="tier-picker__badge">Most Popular</div>
|
||||||
|
)}
|
||||||
|
<h3 className="tier-picker__name">{tier.name}</h3>
|
||||||
|
<div className="tier-picker__price">
|
||||||
|
<span className="tier-picker__amount">{tier.price}</span>
|
||||||
|
{tier.period && (
|
||||||
|
<span className="tier-picker__period">{tier.period}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="tier-picker__features">
|
||||||
|
{tier.features.map((f) => (
|
||||||
|
<li key={f} className="tier-picker__feature">
|
||||||
|
<span className="tier-picker__check">✓</span> {f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{tier.disabledFeatures.map((f) => (
|
||||||
|
<li key={f} className="tier-picker__feature tier-picker__feature--disabled">
|
||||||
|
<span className="tier-picker__dash">—</span> {f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
className={`btn tier-picker__btn${selectedTier === tier.id ? " tier-picker__btn--selected" : ""}`}
|
||||||
|
onClick={() => onSelect(tier.id)}
|
||||||
|
>
|
||||||
|
{selectedTier === tier.id ? "Selected" : "Select"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/components/UpgradeBanner.css
Normal file
32
frontend/src/components/UpgradeBanner.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
.upgrade-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: #3a2e00;
|
||||||
|
border: 1px solid #aa7700;
|
||||||
|
color: #ffcc44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-banner__message {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-banner__link {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #aa7700;
|
||||||
|
color: #ffcc44;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-banner__link:hover {
|
||||||
|
background-color: #4a3800;
|
||||||
|
}
|
||||||
18
frontend/src/components/UpgradeBanner.jsx
Normal file
18
frontend/src/components/UpgradeBanner.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import "./UpgradeBanner.css";
|
||||||
|
|
||||||
|
export default function UpgradeBanner({ message, linkTo = "/subscribe?upgrade=true" }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="upgrade-banner">
|
||||||
|
<span className="upgrade-banner__message">{message}</span>
|
||||||
|
<button
|
||||||
|
className="upgrade-banner__link"
|
||||||
|
onClick={() => navigate(linkTo)}
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
frontend/src/components/navPanel/NavPanel.css
Normal file
78
frontend/src/components/navPanel/NavPanel.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/* ── Overlay Backdrop ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav-panel__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.45);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel__overlay--visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Slide-out Panel ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 260px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
z-index: 950;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nav Links ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav-panel__nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel__link {
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 200;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel__link--active {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border-left-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Logout Button ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav-panel__logout {
|
||||||
|
margin: 1rem 1.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 400;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
52
frontend/src/components/navPanel/NavPanel.jsx
Normal file
52
frontend/src/components/navPanel/NavPanel.jsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import "./NavPanel.css";
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ to: "/addresses", label: "Addresses" },
|
||||||
|
{ to: "/alerts", label: "Configure Alerts" },
|
||||||
|
{ to: "/alertevents", label: "Alert Event History" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NavPanel({ isOpen, onClose }) {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const handleNavClick = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
onClose();
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`nav-panel__overlay${isOpen ? " nav-panel__overlay--visible" : ""}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<aside className={`nav-panel${isOpen ? " nav-panel--open" : ""}`}>
|
||||||
|
<nav className="nav-panel__nav">
|
||||||
|
{navLinks.map(({ to, label }) => {
|
||||||
|
const isActive = location.pathname === to;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={`nav-panel__link${isActive ? " nav-panel__link--active" : ""}`}
|
||||||
|
onClick={handleNavClick}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<button onClick={handleLogout} className="nav-panel__logout">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,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;
|
||||||
|
|||||||
@@ -397,6 +397,14 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 */
|
/* Mobile */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
html {
|
html {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -62,6 +125,20 @@
|
|||||||
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 ──────────────────────────────── */
|
/* ── Alerts Page Responsive ──────────────────────────────── */
|
||||||
|
|
||||||
/* Laptop: tighten the gap */
|
/* Laptop: tighten the gap */
|
||||||
|
|||||||
@@ -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>
|
▶
|
||||||
</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"
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-interactive-fadein {
|
.login-interactive-fadein {
|
||||||
|
|||||||
@@ -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,7 +84,7 @@ 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>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -257,4 +201,4 @@
|
|||||||
height: 26px;
|
height: 26px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
310
frontend/src/pages/subscribe/Subscribe.jsx
Normal file
310
frontend/src/pages/subscribe/Subscribe.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { updatePassword } from "firebase/auth";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { auth } from "../../firebase/config";
|
|
||||||
import { getAccount, createPortalSession } from "../../api/account";
|
import { getAccount, createPortalSession } from "../../api/account";
|
||||||
import "./Account.css";
|
import "./Account.css";
|
||||||
|
|
||||||
|
const TIER_LABELS = {
|
||||||
|
free: "Free Trial",
|
||||||
|
premium: "Premium",
|
||||||
|
pro: "Pro",
|
||||||
|
};
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [account, setAccount] = useState(null);
|
const [account, setAccount] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -47,7 +53,8 @@ export default function Account() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setChangingPassword(true);
|
setChangingPassword(true);
|
||||||
await updatePassword(auth.currentUser, newPassword);
|
// TODO: implement password change endpoint on backend
|
||||||
|
throw new Error("Password change not yet implemented");
|
||||||
setPasswordMsg("Password updated successfully");
|
setPasswordMsg("Password updated successfully");
|
||||||
setNewPassword("");
|
setNewPassword("");
|
||||||
setConfirmPassword("");
|
setConfirmPassword("");
|
||||||
@@ -78,6 +85,7 @@ export default function Account() {
|
|||||||
return <div className="page text-error">Error: {error}</div>;
|
return <div className="page text-error">Error: {error}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tier = account.subscription_tier || "free";
|
||||||
const isCanceling = account.cancel_at_period_end;
|
const isCanceling = account.cancel_at_period_end;
|
||||||
const statusLabel = isCanceling
|
const statusLabel = isCanceling
|
||||||
? "Canceling"
|
? "Canceling"
|
||||||
@@ -86,6 +94,9 @@ export default function Account() {
|
|||||||
: account.subscription_status.charAt(0).toUpperCase() +
|
: account.subscription_status.charAt(0).toUpperCase() +
|
||||||
account.subscription_status.slice(1);
|
account.subscription_status.slice(1);
|
||||||
|
|
||||||
|
const canUpgrade = tier === "free" || tier === "premium";
|
||||||
|
const hasPaidSub = tier !== "free";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page account-page">
|
<div className="page account-page">
|
||||||
<h1 className="mb-lg">Account</h1>
|
<h1 className="mb-lg">Account</h1>
|
||||||
@@ -114,7 +125,7 @@ export default function Account() {
|
|||||||
<h2 className="account__section-title">Subscription</h2>
|
<h2 className="account__section-title">Subscription</h2>
|
||||||
<div className="account__row">
|
<div className="account__row">
|
||||||
<span className="account__label">Plan</span>
|
<span className="account__label">Plan</span>
|
||||||
<span className="account__value">{account.subscription_plan}</span>
|
<span className="account__value">{TIER_LABELS[tier] || account.subscription_plan}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="account__row">
|
<div className="account__row">
|
||||||
<span className="account__label">Status</span>
|
<span className="account__label">Status</span>
|
||||||
@@ -149,15 +160,27 @@ export default function Account() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="account__portal-section">
|
<div className="account__portal-section">
|
||||||
<button
|
{canUpgrade && (
|
||||||
onClick={handleManageSubscription}
|
<button
|
||||||
disabled={portalLoading}
|
onClick={() => navigate("/subscribe")}
|
||||||
className="btn btn--ghost"
|
className="btn btn--primary"
|
||||||
>
|
>
|
||||||
{portalLoading ? "Redirecting..." : "Manage Subscription"}
|
Upgrade Plan
|
||||||
</button>
|
</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">
|
<p className="text-dimmed text-sm account__portal-hint">
|
||||||
Cancel subscription, update payment method, or view invoices via Stripe.
|
{hasPaidSub
|
||||||
|
? "Cancel subscription, update payment method, or view invoices via Stripe."
|
||||||
|
: "Upgrade to unlock more addresses, alert types, and notification channels."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
56
frontend/src/reducers/authReducer.jsx
Normal file
56
frontend/src/reducers/authReducer.jsx
Normal 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
BIN
memberships.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user