Compare commits

...

42 Commits

Author SHA1 Message Date
KS Jannette
2c86bba235 general cleanup - removed old comments, enforced naming conventions etc
Some checks are pending
check / check (push) Waiting to run
2026-03-04 23:29:58 -05:00
S Jannette
44dad43f1d Merge pull request #17 from kjannette/poller-tweaks
updated evaluator service -
2026-03-04 23:16:56 -05:00
KS Jannette
2fe0e5b8e9 updated evaluator service - added semaphore.Weighted(5) and sync.WaitGroup etc to cap conncurrent requests; also WaitForNotifications() so caller blocks until notifications finish. jsonrpc.go -- finally, bumped rpcRetryBaseMS from 1000 to 2000 - RPC retries at 2s/4s/8s backoff rate
Some checks are pending
check / check (push) Waiting to run
2026-03-04 23:14:55 -05:00
S Jannette
8f08105246 Merge pull request #16 from kjannette/stripe-2
Stripe 2
2026-03-04 22:43:49 -05:00
KS Jannette
2bc06669e9 config
Some checks are pending
check / check (push) Waiting to run
2026-03-04 22:40:36 -05:00
KS Jannette
a2d1ff9d76 restructure pages dir 2026-03-04 22:07:05 -05:00
S Jannette
dcbf0756d2 Merge pull request #15 from kjannette/stripe
Stripe
2026-03-04 21:39:48 -05:00
KS Jannette
239ea7db85 addition Stripe payment integration work
Some checks are pending
check / check (push) Waiting to run
2026-03-04 21:38:36 -05:00
KS Jannette
9ad414313b Stripe integration 2026-03-04 15:08:37 -05:00
S Jannette
3ee1598478 Merge pull request #14 from kjannette/feat-discrete-accounts
Feat discrete accounts
2026-03-03 19:14:00 -05:00
KS Jannette
91336130b9 discrete user accounts
Some checks are pending
check / check (push) Waiting to run
2026-03-03 19:10:24 -05:00
KS Jannette
e4e859d051 style 2026-03-03 18:39:20 -05:00
S Jannette
6f6a2c6d18 Merge pull request #13 from kjannette/refactor-css
Refactor css
2026-03-02 03:43:34 -05:00
KS Jannette
63eecaaf72 m
Some checks are pending
check / check (push) Waiting to run
2026-03-02 03:42:58 -05:00
KS Jannette
8a5f5db903 refact css sytem 2026-03-02 03:36:21 -05:00
KS Jannette
c2515472b2 more css hottie 2026-03-02 00:34:34 -05:00
S Jannette
5ad51271af Merge pull request #12 from kjannette/migrate-inline-css
Migrate inline css
2026-03-02 00:28:46 -05:00
KS Jannette
b3a08e8f25 css trick
Some checks are pending
check / check (push) Waiting to run
2026-03-02 00:28:13 -05:00
KS Jannette
8716be0201 fudge 2026-03-02 00:09:24 -05:00
KS Jannette
647be874e5 m 2026-03-02 00:09:01 -05:00
KS Jannette
c12c4fe742 add image 2026-03-01 23:14:25 -05:00
S Jannette
5f5253d495 Revise README for clarity and feature description
Updated the README to clarify features and improve readability.
2026-03-01 21:48:53 -05:00
S Jannette
bbe2374703 Merge pull request #11 from kjannette/history-reconfig-possible-bugfix
History reconfig possible bugfix
2026-03-01 20:38:56 -05:00
KS Jannette
205317c920 update docs
Some checks are pending
check / check (push) Waiting to run
2026-03-01 20:35:08 -05:00
KS Jannette
25850825b4 updated types and poller 2026-03-01 20:32:21 -05:00
S Jannette
f01115de86 Merge pull request #10 from kjannette/webhook-configs
webhook configs for users, changes to system
2026-03-01 19:08:42 -05:00
KS Jannette
6735000050 webhook configs for users, changes to system
Some checks are pending
check / check (push) Waiting to run
2026-03-01 19:06:32 -05:00
KS Jannette
42a64fc043 Add multi-step onboarding wizard to replace bare signup form
Introduces a 5-step guided flow (Create Account → Add Wallet → Alert
Rules → Notifications → Done) that takes new users from zero to fully
configured alerts in one linear session. /signup now redirects to
/onboarding; already-onboarded users who revisit /onboarding are
redirected to /addresses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 12:54:55 -05:00
S Jannette
9935817fa8 Merge pull request #9 from kjannette/setupTelegramonfig
Setup telegramonfig
2026-03-01 09:49:16 -05:00
KS Jannette
8d5716bdb7 Add guide for setup of Telegram
Some checks are pending
check / check (push) Waiting to run
2026-03-01 09:48:52 -05:00
KS Jannette
aafe96d9a1 Finish config 2026-03-01 09:40:54 -05:00
S Jannette
93168209b5 Merge pull request #8 from kjannette/audit1
Audit1
2026-03-01 07:55:57 -05:00
S Jannette
6840af1a47 Merge branch 'master' into audit1 2026-03-01 07:55:49 -05:00
KS Jannette
7c36f3c214 readme change
Some checks are pending
check / check (push) Waiting to run
2026-03-01 07:55:00 -05:00
S Jannette
c707b82f36 Revise README with new project overview
Updated project description and added overview section.
2026-03-01 07:51:57 -05:00
KS Jannette
37ff644aa7 audit: fix issues #1-#9 (mock data, notifier interface, retry/timeout, digest scheduling, real status, RPC retry, dedup, address edit UI)
- #1: remove mock alert events that poisoned production responses
- #2: add Notifier interface + Send implementations for all channels
- #3/#4: notification goroutines now carry a 30s context timeout and retry up to 3x with exponential backoff
- #5: schedule email digest in poller via DIGEST_INTERVAL_HOURS (default 24h)
- #6: SystemStatus queries real checkpoint data; returns starting/active/idle with latestBlock and lag
- #7: Ethereum RPC client retries on network errors, 429, and 5xx with 1s/2s backoff
- #8: alert_events dedup index + ON CONFLICT DO NOTHING to prevent duplicate events on poller restart
- #9: PATCH /v1/addresses/{id} for label editing; frontend address list gains inline edit and remove buttons
2026-03-01 07:49:08 -05:00
S Jannette
c80ed89c0a Merge pull request #7 from kjannette/resend-sdk
add sdk
2026-03-01 04:37:58 -05:00
KS Jannette
6590cbf4be add sdk
Some checks are pending
check / check (push) Waiting to run
2026-03-01 04:31:32 -05:00
KS Jannette
61a8c5fe8b hottie
Some checks are pending
check / check (push) Waiting to run
2026-03-01 03:54:06 -05:00
S Jannette
c79136a06e Merge pull request #6 from kjannette/db-updates-to-keep-up-withe-alerts-functionality
notifcation dongif setttings - save to DB
2026-03-01 03:37:04 -05:00
KS Jannette
a5590a438f notifcation dongif setttings - save to DB
Some checks are pending
check / check (push) Waiting to run
2026-03-01 03:36:32 -05:00
S Jannette
1c196cae9f Merge pull request #5 from kjannette/notifcations-logic-upgrade
Building out notification update channels
2026-03-01 03:23:31 -05:00
89 changed files with 4196 additions and 2051 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,7 +1,7 @@
node_modules/
frontend/dist/
frontend/build/
backend-go/bin/
backend/bin/
*.lock
Prompts/
.claude/

View File

@@ -2,7 +2,7 @@
test-go test-js lint-go lint-js fmt-go fmt-js fmt-check-go fmt-check-js \
build-go build-js
GODIR := backend-go
GODIR := backend
JSDIR := frontend
PRETTIER := $(JSDIR)/node_modules/.bin/prettier

View File

@@ -1,9 +1,9 @@
# Koin Ping
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about.
Koin Ping is an MIT-licensed blockchain monitoring system by Steven Jannette
that polls Ethereum addresses for on-chain activity and delivers real-time
alerts to users via a Go REST API backend and a React single-page application
frontend.
# Overview
Koin Ping observes on-chain activity and notifies users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
## Getting Started
@@ -28,8 +28,8 @@ make hooks
cd frontend && npm install && cd ..
# Copy and fill in environment variables
cp backend-go/.env.example backend-go/.env
# edit backend-go/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
cp backend/.env.example backend/.env
# edit backend/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
# Run checks (requires golangci-lint)
make check
@@ -38,23 +38,23 @@ make check
make run
# Start the poller (separate terminal)
cd backend-go && go run ./cmd/poller
cd backend && go run ./cmd/poller
# Start the frontend dev server (separate terminal)
cd frontend && npm run dev
```
The API listens on `http://localhost:3001` and the frontend on
`http://localhost:3000` by default.
`http://localhost:3000` by default for development.
## Rationale
Crypto users who hold or actively monitor addresses need a lightweight, reliable
way to know when on-chain activity occurs without polling block explorers
manually. Koin Ping fills that gap: it watches a set of Ethereum addresses,
evaluates configurable alert rules (incoming transactions, outgoing
transactions, large transfers, balance thresholds), and notifies the user
through Discord webhooks.
Crypto users may actively monitor addresses with webhooks integrating popular
messaging platforms: Discord, Slack, Telegram.
This lightweight, reliable framework makes instant awareness of on-chain
activity trivial, without polling block explorers manually. Koin Ping watches
addresses, evaluates configurable alert rules (incoming transactions, outgoing
transactions, "large" transfers, balance thresholds), and sends notifications.
## Design
@@ -63,7 +63,7 @@ frontend:
```
koin_ping_0.2.0/
├── backend-go/ # Go monorepo root
├── backend/ # Go monorepo root
│ ├── cmd/api/ # HTTP REST API server
│ ├── cmd/poller/ # Blockchain polling daemon
│ └── internal/
@@ -99,19 +99,51 @@ to the database, and dispatches Discord notifications.
Firebase, communicates with the API via fetch, and renders the address/alert
management UI.
## TODO
## Setting Up Your Alert Platforms
- [ ] Switch frontend package manager from npm to yarn (per repo policy)
- [ ] Rename `go.mod` module path from `github.com/kjannette/koin-ping` to
`sneak.berlin/go/koin-ping` (per repo policy)
- [ ] Move database migrations to `internal/db/migrations/` and embed them in
the binary
- [ ] Pin all Docker base images by `@sha256` in Dockerfile
- [ ] Install `golangci-lint` locally and pass `make lint`
- [ ] Add vitest unit tests for the React frontend
- [ ] Add a `make db-reset` / `make migrate` target for schema management
- [ ] Set HTTP server read/write timeouts (currently unbounded)
- [ ] Replace `log.Printf` calls with structured `log/slog`
Koin Ping can send real-time alerts to **Telegram**, **Discord**, **Slack**, and **Email**. Each channel is configured per-user through the **Notification Settings** panel on the Alerts page.
Below are step-by-step guides for setting up each platform.
---
### Telegram
To receive alerts via Telegram, you need to create a bot and get your chat ID.
#### 1. Create a Telegram Bot
1. Open Telegram and search for **@BotFather** (look for the blue verified checkmark).
2. Open the conversation with BotFather and send: `/newbot`
3. BotFather will ask for a **display name** — enter something like `Koin Ping Alerts`.
4. BotFather will ask for a **username** — it must end in `bot`, e.g. `MyKoinPingBot`.
5. BotFather will reply with your **Bot Token** — a string that looks like `123456789:ABCdefGHIjklMNOpqrSTUvwxYZ`. Copy it.
#### 2. Get Your Chat ID
1. In Telegram, search for the bot username you just created and open the chat.
2. Tap **Start** or send any message (e.g. `hello`).
3. Open the following URL in your browser, replacing `YOUR_BOT_TOKEN` with the token from step 1:
```
https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates
```
4. In the JSON response, find the `"chat"` object — the `"id"` field is your **Chat ID** (a numeric value).
> **Tip:** If the `'result"` array is empty, make sure you sent a message to your bot first, then refresh the page.
#### 3. Save in Koin Ping
1. Go to the **Alerts** page in Koin Ping.
2. In the **Notification Settings** panel, find the **Telegram** section.
3. Paste your **Bot Token** and **Chat ID** into the corresponding fields.
4. Click **Save Settings**.
5. Click **Test All Channels** to verify — you should receive a test message from your bot in Telegram.
---
*Guides for Discord, Slack, and Email coming soon.*
## License
@@ -120,3 +152,5 @@ MIT. See [LICENSE](LICENSE).
## Author
Steven Jannette

View File

@@ -1,20 +0,0 @@
# Server
PORT=3001
API_BASE_PATH=/v1
NODE_ENV=development
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/koin_ping
# Ethereum JSON-RPC
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR-PROJECT-ID
# Polling interval (ms, minimum 1000)
POLL_INTERVAL_MS=60000
# Firebase
FIREBASE_PROJECT_ID=koin-ping
# Email notifications (Resend — https://resend.com)
# RESEND_API_KEY=re_xxxxxxxxxxxx
# EMAIL_FROM=Koin Ping <alerts@yourdomain.com>

View File

@@ -1,7 +0,0 @@
Start DB:
brew services start postgresql@15
Run Backend:
cd /Users/kjannette/workspace/koin_ping/backend-go go run ./cmd/api

View File

@@ -1,23 +0,0 @@
package handlers
import (
"net/http"
"time"
)
func HealthCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"service": "koin-ping-backend",
})
}
func SystemStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"latestBlock": 0,
"lag": 0,
"status": "healthy",
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}

View File

@@ -1,99 +0,0 @@
// Package middleware provides HTTP middleware for authentication and context injection.
package middleware
import (
"context"
"encoding/json"
"log"
"net/http"
"strings"
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
)
type contextKey string
const (
UserIDKey contextKey = "user_id"
UserEmailKey contextKey = "user_email"
)
type errorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Println("No Authorization header or invalid format")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "No authentication token provided",
})
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if token == "" {
log.Println("Empty token")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Invalid token format",
})
return
}
log.Println("Verifying Firebase token...")
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
if err != nil {
log.Printf("Token verification failed: %v", err)
errMsg := err.Error()
if strings.Contains(errMsg, "expired") {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "TOKEN_EXPIRED",
Message: "Authentication token has expired",
})
return
}
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Failed to verify authentication token",
})
return
}
userID := decoded.UID
email, _ := decoded.Claims["email"].(string)
log.Printf("Token verified! User ID: %s, Email: %s", userID, email)
ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx = context.WithValue(ctx, UserEmailKey, email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func GetUserID(ctx context.Context) string {
if v, ok := ctx.Value(UserIDKey).(string); ok {
return v
}
return ""
}
func GetUserEmail(ctx context.Context) string {
if v, ok := ctx.Value(UserEmailKey).(string); ok {
return v
}
return ""
}

View File

@@ -1,211 +0,0 @@
package ethereum
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math/big"
"net/http"
"strings"
"time"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
)
const rpcTimeoutMS = 30000
type JsonRpcEthereum struct {
rpcURL string
client *http.Client
}
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
if rpcURL == "" {
return nil, fmt.Errorf("JsonRpcEthereum requires a valid RPC URL")
}
return &JsonRpcEthereum{
rpcURL: rpcURL,
client: &http.Client{
Timeout: time.Duration(rpcTimeoutMS) * time.Millisecond,
},
}, nil
}
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params []interface{} `json:"params"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
if params == nil {
params = []interface{}{}
}
body, err := json.Marshal(rpcRequest{
JSONRPC: "2.0",
ID: time.Now().UnixMilli(),
Method: method,
Params: params,
})
if err != nil {
return nil, fmt.Errorf("marshal RPC request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create RPC request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := j.client.Do(req)
if err != nil {
return nil, fmt.Errorf("RPC call failed [%s]: %w", method, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
}
var rpcResp rpcResponse
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
return nil, fmt.Errorf("decode RPC response: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code)
}
return rpcResp.Result, nil
}
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
result, err := j.callRPC(ctx, "eth_blockNumber")
if err != nil {
return 0, err
}
var hexBlock string
if err := json.Unmarshal(result, &hexBlock); err != nil {
return 0, fmt.Errorf("unmarshal block number: %w", err)
}
return hexToInt(hexBlock)
}
type rpcBlock struct {
Timestamp string `json:"timestamp"`
Transactions []rpcTx `json:"transactions"`
}
type rpcTx struct {
Hash string `json:"hash"`
From string `json:"from"`
To *string `json:"to"`
Value string `json:"value"`
BlockNumber string `json:"blockNumber"`
}
func (j *JsonRpcEthereum) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) {
hexBlock := fmt.Sprintf("0x%x", blockNumber)
result, err := j.callRPC(ctx, "eth_getBlockByNumber", hexBlock, true)
if err != nil {
return nil, err
}
if string(result) == "null" {
return nil, nil
}
var block rpcBlock
if err := json.Unmarshal(result, &block); err != nil {
return nil, fmt.Errorf("unmarshal block: %w", err)
}
blockTimestamp, err := hexToInt64(block.Timestamp)
if err != nil {
return nil, fmt.Errorf("parse block timestamp: %w", err)
}
txs := make([]domain.NormalizedTx, 0, len(block.Transactions))
for _, tx := range block.Transactions {
bn, _ := hexToInt(tx.BlockNumber)
var toLower *string
if tx.To != nil {
s := strings.ToLower(*tx.To)
toLower = &s
}
txs = append(txs, domain.NormalizedTx{
Hash: tx.Hash,
From: strings.ToLower(tx.From),
To: toLower,
Value: hexToDecimalString(tx.Value),
BlockNumber: bn,
BlockTimestamp: blockTimestamp,
})
}
return txs, nil
}
func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (string, error) {
result, err := j.callRPC(ctx, "eth_getBalance", address, "latest")
if err != nil {
return "", err
}
var hexBalance string
if err := json.Unmarshal(result, &hexBalance); err != nil {
return "", fmt.Errorf("unmarshal balance: %w", err)
}
return hexToDecimalString(hexBalance), nil
}
func hexToInt(hex string) (int, error) {
hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16)
if !ok {
return 0, fmt.Errorf("invalid hex: %s", hex)
}
return int(n.Int64()), nil
}
func hexToInt64(hex string) (int64, error) {
hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16)
if !ok {
return 0, fmt.Errorf("invalid hex: %s", hex)
}
return n.Int64(), nil
}
func hexToDecimalString(hex string) string {
if hex == "" || hex == "0x" || hex == "0x0" {
return "0"
}
clean := strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(clean, 16)
if !ok {
return "0"
}
return n.String()
}

20
backend/README.md Normal file
View File

@@ -0,0 +1,20 @@
Start DB:
brew services start postgresql@15
From the backend directory, you have a few options:
Option 1: Single command (both API + poller)
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
Option 2: Two separate terminals
Terminal 1 (API server):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
Terminal 2 (Poller):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/poller
make run — Builds and runs the API server.
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
make poller — Builds and runs the poller.
make poller-dev — Runs the poller with auto-reload.
make dev-all — Runs both the API and poller concurrently.

BIN
backend/api Executable file

Binary file not shown.

View File

@@ -8,19 +8,19 @@ import (
"time"
"github.com/joho/godotenv"
"github.com/kjannette/koin-ping/backend-go/internal/config"
"github.com/kjannette/koin-ping/backend-go/internal/database"
"github.com/kjannette/koin-ping/backend-go/internal/firebase"
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/services"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/database"
"github.com/kjannette/koin-ping/backend/internal/firebase"
"github.com/kjannette/koin-ping/backend/internal/handlers"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend/internal/services"
)
const (
// serverReadTimeoutSeconds is the maximum duration to read a request.
// max duration to read a request.
serverReadTimeoutSeconds = 5
// serverWriteTimeoutSeconds is the maximum duration to write a response.
// maxiduration to write a response.
serverWriteTimeoutSeconds = 10
)
@@ -44,9 +44,11 @@ func main() {
defer database.Close()
userModel := models.NewUserModel(pool)
addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool)
checkpointModel := models.NewCheckpointModel(pool)
notifConfigModel := models.NewNotificationConfigModel(pool)
emailDigestSvc := services.NewEmailDigestService(
@@ -58,51 +60,74 @@ func main() {
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel)
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
authenticate := middleware.Authenticate(userModel)
requireSub := middleware.RequireSubscription(userModel)
// authAndSub chains authentication + subscription check for protected routes.
authAndSub := func(h http.Handler) http.Handler {
return authenticate(requireSub(h))
}
mux := http.NewServeMux()
b := cfg.APIBasePath // e.g. "/v1"
// Public routes
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus)
mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
// Authenticated routes — addresses
// Stripe webhook (public — called by Stripe, not authenticated)
mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook)
// Stripe routes (auth required, NO subscription required)
mux.Handle("POST "+b+"/stripe/create-checkout-session",
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
mux.Handle("GET "+b+"/stripe/subscription-status",
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
mux.Handle("POST "+b+"/stripe/verify-checkout",
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
// Authenticated + subscribed routes — addresses
mux.Handle("POST "+b+"/addresses",
middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
authAndSub(http.HandlerFunc(addressHandler.Create)))
mux.Handle("GET "+b+"/addresses",
middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
authAndSub(http.HandlerFunc(addressHandler.List)))
mux.Handle("DELETE "+b+"/addresses/{addressId}",
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
authAndSub(http.HandlerFunc(addressHandler.Remove)))
mux.Handle("PATCH "+b+"/addresses/{addressId}",
authAndSub(http.HandlerFunc(addressHandler.UpdateLabel)))
// Authenticated routes — alert rules
// Authenticated + subscribed routes — alert rules
mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
authAndSub(http.HandlerFunc(alertRuleHandler.Create)))
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
authAndSub(http.HandlerFunc(alertRuleHandler.ListByAddress)))
mux.Handle("PATCH "+b+"/alerts/{alertId}",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
authAndSub(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
mux.Handle("DELETE "+b+"/alerts/{alertId}",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
authAndSub(http.HandlerFunc(alertRuleHandler.Remove)))
// Authenticated routes — alert events
// Authenticated + subscribed routes — alert events
mux.Handle("GET "+b+"/alert-events",
middleware.Authenticate(http.HandlerFunc(alertEventHandler.List)))
authAndSub(http.HandlerFunc(alertEventHandler.List)))
// Authenticated routes — notification config
// Authenticated + subscribed routes — notification config
mux.Handle("GET "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
authAndSub(http.HandlerFunc(notifConfigHandler.GetConfig)))
mux.Handle("PUT "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
authAndSub(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
mux.Handle("DELETE "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
authAndSub(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
mux.Handle("POST "+b+"/notification-config/test",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestChannels)))
authAndSub(http.HandlerFunc(notifConfigHandler.TestChannels)))
// Authenticated routes — email digest
// Authenticated + subscribed routes — email digest
mux.Handle("POST "+b+"/email/setup",
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail)))
authAndSub(http.HandlerFunc(emailDigestHandler.SetupEmail)))
mux.Handle("POST "+b+"/email/digest",
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SendDigest)))
authAndSub(http.HandlerFunc(emailDigestHandler.SendDigest)))
handler := corsMiddleware(mux)

View File

@@ -12,11 +12,11 @@ import (
"time"
"github.com/joho/godotenv"
"github.com/kjannette/koin-ping/backend-go/internal/config"
"github.com/kjannette/koin-ping/backend-go/internal/database"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
"github.com/kjannette/koin-ping/backend-go/internal/services"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/database"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
"github.com/kjannette/koin-ping/backend/internal/services"
)
const (
@@ -62,6 +62,7 @@ func main() {
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
cfg.ResendAPIKey, cfg.EmailFrom,
)
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -78,12 +79,14 @@ func main() {
}()
interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond
digestInterval := time.Duration(cfg.DigestIntervalHours) * time.Hour
log.Println(strings.Repeat("=", separatorWidth))
log.Println("Koin Ping Observer Poller Starting")
log.Println(strings.Repeat("=", separatorWidth))
log.Printf("RPC URL: %s", cfg.EthRPCURL)
log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/msPerSecond)
log.Printf("Digest Interval: %dh", cfg.DigestIntervalHours)
log.Println(strings.Repeat("=", separatorWidth))
runCycle(ctx, observer, evaluator)
@@ -91,6 +94,9 @@ func main() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
digestTicker := time.NewTicker(digestInterval)
defer digestTicker.Stop()
for {
select {
case <-ctx.Done():
@@ -99,6 +105,13 @@ func main() {
return
case <-ticker.C:
runCycle(ctx, observer, evaluator)
case <-digestTicker.C:
sent, digestErr := digestSvc.SendDigestsForAllUsers(ctx)
if digestErr != nil {
log.Printf("Email digest failed: %v", digestErr)
} else {
log.Printf("Sent %d email digests", sent)
}
}
}
}
@@ -125,6 +138,8 @@ func runCycle(
return
}
evaluator.WaitForNotifications()
duration := time.Since(startTime)
log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s",
time.Now().UTC().Format(time.RFC3339),

View File

@@ -1,4 +1,4 @@
module github.com/kjannette/koin-ping/backend-go
module github.com/kjannette/koin-ping/backend
go 1.25.0
@@ -42,7 +42,9 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/resend/resend-go/v3 v3.1.1 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/stripe/stripe-go/v82 v82.5.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect

View File

@@ -92,6 +92,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v3 v3.1.1 h1:Uwpf/tZU+O/r/3nMWE6zUAMIG9dX/vTBS3wlQzYJKSw=
github.com/resend/resend-go/v3 v3.1.1/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -99,6 +101,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=

View File

@@ -0,0 +1,3 @@
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_events_dedup
ON alert_events (alert_rule_id, tx_hash)
WHERE tx_hash IS NOT NULL;

View File

@@ -0,0 +1,13 @@
-- Migration 005: Create users table with UUID primary key
-- This establishes a local user record for each Firebase-authenticated user.
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_firebase_uid ON users(firebase_uid);

View File

@@ -0,0 +1,73 @@
-- Migration 006: Migrate user_id columns from Firebase UID strings to user UUIDs
--
-- Prerequisites: migration 005 (users table) must be applied first.
-- This migration backfills the users table from existing data, then swaps the
-- VARCHAR user_id columns for UUID foreign keys referencing users(id).
BEGIN;
-- 1. Backfill users table from existing Firebase UIDs in addresses
INSERT INTO users (firebase_uid, email)
SELECT DISTINCT user_id, ''
FROM addresses
WHERE user_id IS NOT NULL
ON CONFLICT (firebase_uid) DO NOTHING;
-- 2. Backfill from notification configs (catches users with configs but no addresses)
INSERT INTO users (firebase_uid, email)
SELECT DISTINCT user_id, ''
FROM user_notification_configs
WHERE user_id IS NOT NULL
ON CONFLICT (firebase_uid) DO NOTHING;
-- ============================================================
-- 3. Migrate addresses.user_id from VARCHAR to UUID
-- ============================================================
ALTER TABLE addresses ADD COLUMN user_uuid UUID;
UPDATE addresses a
SET user_uuid = u.id
FROM users u
WHERE u.firebase_uid = a.user_id;
-- Drop old constraints and column
ALTER TABLE addresses DROP CONSTRAINT IF EXISTS addresses_user_id_address_key;
DROP INDEX IF EXISTS idx_addresses_user_id;
ALTER TABLE addresses DROP COLUMN user_id;
-- Rename and constrain
ALTER TABLE addresses RENAME COLUMN user_uuid TO user_id;
ALTER TABLE addresses ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE addresses ADD CONSTRAINT fk_addresses_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE addresses ADD CONSTRAINT addresses_user_id_address_key
UNIQUE (user_id, address);
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
-- ============================================================
-- 4. Migrate user_notification_configs.user_id from VARCHAR to UUID
-- ============================================================
-- Drop the PK first (it's on user_id)
ALTER TABLE user_notification_configs DROP CONSTRAINT IF EXISTS user_notification_configs_pkey;
DROP INDEX IF EXISTS idx_notification_configs_enabled;
ALTER TABLE user_notification_configs ADD COLUMN user_uuid UUID;
UPDATE user_notification_configs nc
SET user_uuid = u.id
FROM users u
WHERE u.firebase_uid = nc.user_id;
ALTER TABLE user_notification_configs DROP COLUMN user_id;
ALTER TABLE user_notification_configs RENAME COLUMN user_uuid TO user_id;
ALTER TABLE user_notification_configs ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE user_notification_configs ADD CONSTRAINT user_notification_configs_pkey
PRIMARY KEY (user_id);
ALTER TABLE user_notification_configs ADD CONSTRAINT fk_notification_configs_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX idx_notification_configs_enabled
ON user_notification_configs(notification_enabled);
COMMIT;

View File

@@ -0,0 +1,8 @@
-- Migration 007: Add Stripe subscription fields to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(50) DEFAULT 'none';
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_created_at TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer_id ON users(stripe_customer_id);

View File

@@ -4,15 +4,28 @@ DROP TABLE IF EXISTS alert_rules CASCADE;
DROP TABLE IF EXISTS address_checkpoints CASCADE;
DROP TABLE IF EXISTS user_notification_configs CASCADE;
DROP TABLE IF EXISTS addresses CASCADE;
DROP TABLE IF EXISTS users CASCADE;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
stripe_customer_id VARCHAR(255),
stripe_subscription_id VARCHAR(255),
subscription_status VARCHAR(50) DEFAULT 'none',
subscription_created_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE addresses (
id SERIAL PRIMARY KEY,
user_id VARCHAR(128) NOT NULL, -- Firebase user ID (multi-user support)
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars)
label VARCHAR(255), -- Optional human-readable label
created_at TIMESTAMP DEFAULT NOW(),
-- Unique users can track the same address independently
UNIQUE(user_id, address)
);
@@ -51,7 +64,7 @@ CREATE TABLE address_checkpoints (
);
CREATE TABLE user_notification_configs (
user_id VARCHAR(128) PRIMARY KEY,
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
@@ -63,6 +76,8 @@ CREATE TABLE user_notification_configs (
);
-- Create indexes for common queries
CREATE INDEX idx_users_firebase_uid ON users(firebase_uid);
CREATE INDEX idx_users_stripe_customer_id ON users(stripe_customer_id);
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id);
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);

View File

@@ -13,6 +13,7 @@ const (
defaultDBPort = 5432
defaultPollIntervalMS = 60000
minPollIntervalMS = 1000
defaultDigestIntervalHours = 24
)
type Config struct {
@@ -28,8 +29,14 @@ type Config struct {
EthRPCURL string
PollIntervalMS int
NodeEnv string
ResendAPIKey string
EmailFrom string
ResendAPIKey string
EmailFrom string
DigestIntervalHours int
StripeSecretKey string
StripeWebhookSecret string
StripePriceID string
StripePublishableKey string
FrontendURL string
}
// Load reads configuration from environment variables and returns a Config.
@@ -47,8 +54,14 @@ func Load() (*Config, error) {
EthRPCURL: os.Getenv("ETH_RPC_URL"),
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
NodeEnv: getEnv("NODE_ENV", "development"),
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
StripePriceID: os.Getenv("STRIPE_PRICE_ID"),
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
}
if cfg.PollIntervalMS < minPollIntervalMS {

View File

@@ -1,4 +1,3 @@
// Package database manages PostgreSQL connection pools.
package database
import (
@@ -11,21 +10,21 @@ import (
)
const (
// maxConnIdleSeconds is the maximum idle time for a connection.
maxConnIdleSeconds = 30
// maxConnLifetimeMinutes is the maximum lifetime for a connection.
maxConnLifetimeMinutes = 5
// connectTimeoutSeconds is the timeout for initial connection.
connectTimeoutSeconds = 10
// maxConns is the maximum number of connections in the pool.
maxConns = 20
// minConns is the minimum number of connections in the pool.
minConns = 2
)
var pool *pgxpool.Pool //nolint:gochecknoglobals
// Connect establishes a PostgreSQL connection pool using the given DSN.
// establishPostgreSQL connection pool using the given DSN.
func Connect(dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
@@ -57,12 +56,10 @@ func Connect(dsn string) (*pgxpool.Pool, error) {
return p, nil
}
// Pool returns the global connection pool.
func Pool() *pgxpool.Pool {
return pool
}
// Close closes the global connection pool.
func Close() {
if pool != nil {
pool.Close()

View File

@@ -1,9 +1,20 @@
// Package domain defines core domain types shared across the application.
package domain
import "time"
// Address represents a tracked Ethereum address.
type User struct {
ID string `json:"id"`
FirebaseUID string `json:"-"`
Email string `json:"email"`
DisplayName *string `json:"display_name"` //nolint:tagliatelle
StripeCustomerID *string `json:"-"`
StripeSubscriptionID *string `json:"-"`
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
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 {
ID int `json:"id"`
UserID string `json:"user_id"` //nolint:tagliatelle
@@ -12,13 +23,10 @@ type Address struct {
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
}
// AlertType identifies the kind of alert rule.
type AlertType string
// String implements fmt.Stringer.
func (a AlertType) String() string { return string(a) }
// Alert type constants define the supported alert triggers.
const (
AlertIncomingTx AlertType = "incoming_tx"
AlertOutgoingTx AlertType = "outgoing_tx"
@@ -26,7 +34,6 @@ const (
AlertBalanceBelow AlertType = "balance_below"
)
// ValidAlertTypes lists all alert types accepted by the API.
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
AlertIncomingTx,
AlertOutgoingTx,
@@ -40,7 +47,7 @@ var ThresholdRequiredTypes = []AlertType{ //nolint:gochecknoglobals
AlertBalanceBelow,
}
// IsValidAlertType returns true if the given string matches a known AlertType.
// returns true if the given string matches a known AlertType.
func IsValidAlertType(t string) bool {
for _, v := range ValidAlertTypes {
if string(v) == t {
@@ -51,7 +58,6 @@ func IsValidAlertType(t string) bool {
return false
}
// IsThresholdRequired returns true if the given AlertType requires a threshold.
func IsThresholdRequired(t AlertType) bool {
for _, v := range ThresholdRequiredTypes {
if v == t {
@@ -62,7 +68,6 @@ func IsThresholdRequired(t AlertType) bool {
return false
}
// AlertRule represents a user-defined alert rule for an address.
type AlertRule struct {
ID int `json:"id"`
AddressID int `json:"address_id"` //nolint:tagliatelle
@@ -72,7 +77,6 @@ type AlertRule struct {
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
}
// AlertEvent represents a fired alert event stored for history.
type AlertEvent struct {
ID int `json:"id"`
AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
@@ -82,14 +86,12 @@ type AlertEvent struct {
Timestamp time.Time `json:"timestamp"`
}
// AddressCheckpoint tracks the last block checked for an address.
type AddressCheckpoint struct {
AddressID int `json:"address_id"` //nolint:tagliatelle
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
}
// CheckpointDetail combines checkpoint and address info for reporting.
type CheckpointDetail struct {
AddressID int `json:"address_id"` //nolint:tagliatelle
Address string `json:"address"`
@@ -98,7 +100,7 @@ type CheckpointDetail struct {
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
}
// NotificationConfig holds a user's notification preferences.
// holds a user's notification preferences.
type NotificationConfig struct {
UserID string `json:"user_id"` //nolint:tagliatelle
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
@@ -119,21 +121,27 @@ type NormalizedTx struct {
Value string `json:"value"` // Wei as string for precision
BlockNumber int `json:"block_number"` //nolint:tagliatelle
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
// ERC-20 token transfer fields (nil for native ETH transfers)
TokenContract *string `json:"token_contract,omitempty"` //nolint:tagliatelle
TokenSymbol *string `json:"token_symbol,omitempty"` //nolint:tagliatelle
TokenDecimals *int `json:"token_decimals,omitempty"` //nolint:tagliatelle
TokenValue *string `json:"token_value,omitempty"` //nolint:tagliatelle
}
func (tx NormalizedTx) IsTokenTransfer() bool {
return tx.TokenContract != nil
}
// Direction indicates whether a transaction is incoming or outgoing.
type Direction string
// String implements fmt.Stringer.
func (d Direction) String() string { return string(d) }
// Direction constants indicate the flow of a transaction relative to a watched address.
const (
DirectionIncoming Direction = "incoming"
DirectionOutgoing Direction = "outgoing"
)
// ObservedTx is a NormalizedTx enriched with address and direction context.
type ObservedTx struct {
NormalizedTx
AddressID int `json:"address_id"` //nolint:tagliatelle

View File

@@ -1,4 +1,3 @@
// Package firebase provides Firebase authentication integration.
package firebase
import (
@@ -17,7 +16,6 @@ var ( //nolint:gochecknoglobals
errInit error //nolint:gochecknoglobals
)
// Init initializes the Firebase app and auth client using the given project ID.
func Init(projectID string) error {
once.Do(func() {
ctx := context.Background()
@@ -48,7 +46,6 @@ func Init(projectID string) error {
return errInit
}
// Auth returns the initialized Firebase auth client.
func Auth() *auth.Client {
return authClient
}

View File

@@ -8,24 +8,21 @@ import (
"regexp"
"strings"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
)
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
// AddressHandler handles HTTP requests for address management.
type AddressHandler struct {
addresses *models.AddressModel
}
// NewAddressHandler creates a new AddressHandler.
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
return &AddressHandler{addresses: addresses}
}
// Create handles POST requests to add a new tracked address.
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
@@ -92,6 +89,44 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, addresses)
}
// handles PATCH requests to update an address label.
func (h *AddressHandler) UpdateLabel(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId"))
if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
return
}
var body struct {
Label *string `json:"label"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return
}
log.Printf("User %s updating label for address ID: %d", userID, addressID)
addr, err := h.addresses.UpdateLabel(r.Context(), addressID, userID, body.Label)
if err != nil {
log.Printf("Error updating address label: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update address")
return
}
if addr == nil {
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
return
}
writeJSON(w, http.StatusOK, addr)
}
// Remove handles DELETE requests to remove a tracked address.
func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())

View File

@@ -4,11 +4,10 @@ import (
"log"
"net/http"
"strconv"
"time"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
)
// AlertEventHandler handles HTTP requests for alert event history.
@@ -51,45 +50,9 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
log.Printf("Found %d alert events for user", len(events))
// MVP scaffolding: return mock data if DB is empty
if len(events) == 0 {
events = mockEvents(limit)
if events == nil {
events = []domain.AlertEvent{}
}
writeJSON(w, http.StatusOK, events)
}
func mockEvents(limit int) []domain.AlertEvent {
label1 := "Treasury Wallet"
label2 := "Cold Storage"
mocks := []domain.AlertEvent{
{
ID: 1,
AlertRuleID: 1,
Message: "Incoming transaction detected: 5.5 ETH received",
AddressLabel: &label1,
Timestamp: time.Now().Add(-2 * time.Hour),
},
{
ID: 2, //nolint:mnd
AlertRuleID: 2, //nolint:mnd
Message: "Balance dropped below threshold: Current balance 8.2 ETH",
AddressLabel: &label1,
Timestamp: time.Now().Add(-5 * time.Hour),
},
{
ID: 3, //nolint:mnd
AlertRuleID: 3, //nolint:mnd
Message: "Outgoing transaction detected: 2.0 ETH sent",
AddressLabel: &label2,
Timestamp: time.Now().Add(-24 * time.Hour),
},
}
if limit < len(mocks) {
return mocks[:limit]
}
return mocks
}

View File

@@ -9,26 +9,22 @@ import (
"strconv"
"strings"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
)
// errThresholdFormat is returned when the threshold JSON cannot be decoded.
var errThresholdFormat = errors.New("unsupported threshold format")
// AlertRuleHandler handles HTTP requests for alert rule management.
type AlertRuleHandler struct {
alertRules *models.AlertRuleModel
addresses *models.AddressModel
}
// NewAlertRuleHandler creates a new AlertRuleHandler.
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses}
}
// Create handles POST requests to create a new alert rule for an address.
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId"))

View File

@@ -4,9 +4,9 @@ import (
"log"
"net/http"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/services"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend/internal/services"
)
type EmailDigestHandler struct {

View File

@@ -7,11 +7,11 @@ import (
"regexp"
"strings"
"github.com/kjannette/koin-ping/backend-go/internal/config"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend/internal/notifications"
)
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)

View File

@@ -0,0 +1,71 @@
package handlers
import (
"log"
"net/http"
"time"
"github.com/kjannette/koin-ping/backend/internal/models"
)
// StatusHandler handles the system status endpoint.
type StatusHandler struct {
checkpoints *models.CheckpointModel
}
// NewStatusHandler creates a new StatusHandler.
func NewStatusHandler(checkpoints *models.CheckpointModel) *StatusHandler {
return &StatusHandler{checkpoints: checkpoints}
}
// GetStatus returns real-time system status derived from checkpoint data.
func (h *StatusHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
block, checkedAt, err := h.checkpoints.GetLatestBlock(r.Context())
if err != nil {
log.Printf("Error querying latest block: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get system status")
return
}
latestBlock := 0
lag := 0
status := "starting"
if checkedAt != nil {
lag = int(time.Since(*checkedAt).Seconds())
if lag > 600 { //nolint:mnd
status = "idle"
} else {
status = "active"
}
}
if block != nil {
latestBlock = *block
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": status,
"latestBlock": latestBlock,
"lag": lag,
"lastCheckedAt": checkedAtStr(checkedAt),
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
func checkedAtStr(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format(time.RFC3339)
}
func HealthCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"service": "koin-ping-backend",
})
}

View File

@@ -0,0 +1,254 @@
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"github.com/stripe/stripe-go/v82"
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
"github.com/stripe/stripe-go/v82/webhook"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
)
const webhookMaxBodyBytes = 65536
type StripeHandler struct {
users *models.UserModel
cfg *config.Config
}
func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler {
stripe.Key = cfg.StripeSecretKey
return &StripeHandler{users: users, cfg: cfg}
}
// CreateCheckoutSession creates a Stripe Checkout session for the monthly subscription.
func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
user, err := h.users.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("Failed to get user %s: %v", userID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
return
}
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(h.cfg.StripePriceID),
Quantity: stripe.Int64(1),
},
},
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
ClientReferenceID: stripe.String(userID),
CustomerEmail: stripe.String(user.Email),
}
if user.StripeCustomerID != nil && *user.StripeCustomerID != "" {
params.Customer = user.StripeCustomerID
params.CustomerEmail = nil
}
s, err := checkoutsession.New(params)
if err != nil {
log.Printf("Failed to create Stripe checkout session: %v", err)
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
return
}
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
}
// GetSubscriptionStatus returns the current user's subscription state.
func (h *StripeHandler) GetSubscriptionStatus(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
user, err := h.users.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("Failed to get user %s: %v", userID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"subscription_status": user.SubscriptionStatus,
"subscription_created_at": user.SubscriptionCreatedAt,
})
}
// VerifyCheckoutSession retrieves a completed checkout session from Stripe,
// 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) {
userID := middleware.GetUserID(r.Context())
var body struct {
SessionID string `json:"session_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.SessionID == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Missing session_id")
return
}
s, err := checkoutsession.Get(body.SessionID, nil)
if err != nil {
log.Printf("Failed to retrieve checkout session %s: %v", body.SessionID, err)
writeError(w, http.StatusBadRequest, "STRIPE_ERROR", "Invalid checkout session")
return
}
if s.ClientReferenceID != userID {
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
return
}
if s.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid {
writeError(w, http.StatusBadRequest, "PAYMENT_INCOMPLETE", "Payment has not been completed")
return
}
customerID := ""
if s.Customer != nil {
customerID = s.Customer.ID
}
subscriptionID := ""
if s.Subscription != nil {
subscriptionID = s.Subscription.ID
}
if customerID != "" {
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
log.Printf("VerifyCheckout: failed to save customer ID: %v", err)
}
}
if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil {
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
}
}
log.Printf("Checkout verified for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
writeJSON(w, http.StatusOK, map[string]string{"subscription_status": "active"})
}
// 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) {
payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes))
if err != nil {
log.Printf("Error reading webhook body: %v", err)
w.WriteHeader(http.StatusServiceUnavailable)
return
}
sig := r.Header.Get("Stripe-Signature")
event, err := webhook.ConstructEvent(payload, sig, h.cfg.StripeWebhookSecret)
if err != nil {
log.Printf("Webhook signature verification failed: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
switch event.Type {
case "checkout.session.completed":
h.handleCheckoutCompleted(r, event)
case "customer.subscription.updated":
h.handleSubscriptionUpdated(r, event)
case "customer.subscription.deleted":
h.handleSubscriptionDeleted(r, event)
default:
log.Printf("Unhandled Stripe event type: %s", event.Type)
}
w.WriteHeader(http.StatusOK)
}
func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Event) {
var session stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
log.Printf("Error parsing checkout session: %v", err)
return
}
userID := session.ClientReferenceID
if userID == "" {
log.Println("Checkout session missing client_reference_id")
return
}
customerID := ""
if session.Customer != nil {
customerID = session.Customer.ID
}
subscriptionID := ""
if session.Subscription != nil {
subscriptionID = session.Subscription.ID
}
if customerID != "" {
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
log.Printf("Failed to save Stripe customer ID: %v", err)
}
}
if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil {
log.Printf("Failed to activate subscription: %v", err)
}
}
log.Printf("Checkout completed for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
}
func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.Event) {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
log.Printf("Error parsing subscription update: %v", err)
return
}
customerID := ""
if sub.Customer != nil {
customerID = sub.Customer.ID
}
if customerID == "" {
return
}
status := string(sub.Status)
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status); err != nil {
log.Printf("Failed to update subscription status: %v", err)
}
log.Printf("Subscription %s updated to %s for customer %s", sub.ID, status, customerID)
}
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
log.Printf("Error parsing subscription deletion: %v", err)
return
}
customerID := ""
if sub.Customer != nil {
customerID = sub.Customer.ID
}
if customerID == "" {
return
}
if err := h.users.UpdateSubscriptionStatus(r.Context(), customerID, "canceled"); err != nil {
log.Printf("Failed to mark subscription canceled: %v", err)
}
log.Printf("Subscription canceled for customer %s", customerID)
}

View File

@@ -0,0 +1,152 @@
// Package middleware provides HTTP middleware for authentication and context injection.
package middleware
import (
"context"
"encoding/json"
"log"
"net/http"
"strings"
fbauth "github.com/kjannette/koin-ping/backend/internal/firebase"
"github.com/kjannette/koin-ping/backend/internal/models"
)
type contextKey string
const (
UserIDKey contextKey = "user_id"
UserEmailKey contextKey = "user_email"
)
type errorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v) //nolint:errcheck
}
// Authenticate verifies the Firebase ID token and auto-provisions a local user
// record. The local user UUID (not the Firebase UID) is placed into context so
// all downstream handlers use it as the canonical user identifier.
func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Println("No Authorization header or invalid format")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "No authentication token provided",
})
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if token == "" {
log.Println("Empty token")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Invalid token format",
})
return
}
log.Println("Verifying Firebase token...")
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
if err != nil {
log.Printf("Token verification failed: %v", err)
errMsg := err.Error()
if strings.Contains(errMsg, "expired") {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "TOKEN_EXPIRED",
Message: "Authentication token has expired",
})
return
}
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Failed to verify authentication token",
})
return
}
firebaseUID := decoded.UID
email, _ := decoded.Claims["email"].(string)
user, err := userModel.FindOrCreateByFirebaseUID(r.Context(), firebaseUID, email)
if err != nil {
log.Printf("Failed to provision local user for Firebase UID %s: %v", firebaseUID, err)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "Failed to initialize user account",
})
return
}
log.Printf("Token verified! User UUID: %s, Email: %s", user.ID, email)
ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
ctx = context.WithValue(ctx, UserEmailKey, email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireSubscription blocks requests from users without an active subscription.
// Must be applied after Authenticate.
func RequireSubscription(userModel *models.UserModel) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := GetUserID(r.Context())
if userID == "" {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Authentication required",
})
return
}
user, err := userModel.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("RequireSubscription: failed to load user %s: %v", userID, err)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "Failed to verify subscription",
})
return
}
if user.SubscriptionStatus != "active" && user.SubscriptionStatus != "trialing" {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "SUBSCRIPTION_REQUIRED",
Message: "An active subscription is required to use this feature",
})
return
}
next.ServeHTTP(w, r)
})
}
}
func GetUserID(ctx context.Context) string {
if v, ok := ctx.Value(UserIDKey).(string); ok {
return v
}
return ""
}
func GetUserEmail(ctx context.Context) string {
if v, ok := ctx.Value(UserEmailKey).(string); ok {
return v
}
return ""
}

View File

@@ -6,7 +6,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
type AddressModel struct {
@@ -107,6 +107,24 @@ func (m *AddressModel) FindByID(ctx context.Context, id int, userID *string) (*d
return &a, nil
}
// UpdateLabel updates the label for an address owned by userID.
// Returns nil, nil if no row matched (address not found or not owned by user).
func (m *AddressModel) UpdateLabel(ctx context.Context, id int, userID string, label *string) (*domain.Address, error) {
var a domain.Address
err := m.pool.QueryRow(ctx,
`UPDATE addresses SET label = $3 WHERE id = $1 AND user_id = $2
RETURNING id, user_id, address, label, created_at`,
id, userID, label,
).Scan(&a.ID, &a.UserID, &a.Address, &a.Label, &a.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &a, nil
}
func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) {
tag, err := m.pool.Exec(ctx,
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`,

View File

@@ -2,9 +2,11 @@ package models
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
type AlertEventModel struct {
@@ -71,10 +73,15 @@ func (m *AlertEventModel) Create(ctx context.Context, alertRuleID int, message s
err := m.pool.QueryRow(ctx,
`INSERT INTO alert_events (alert_rule_id, message, address_label, tx_hash)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING
RETURNING id, alert_rule_id, message, address_label, tx_hash, timestamp`,
alertRuleID, message, addressLabel, txHash,
).Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Duplicate silently skipped by ON CONFLICT DO NOTHING
return nil, nil
}
return nil, err
}
return &e, nil

View File

@@ -6,7 +6,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
type AlertRuleModel struct {

View File

@@ -3,10 +3,11 @@ package models
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
type CheckpointModel struct {
@@ -17,6 +18,20 @@ func NewCheckpointModel(pool *pgxpool.Pool) *CheckpointModel {
return &CheckpointModel{pool: pool}
}
// GetLatestBlock returns the highest last_checked_block and its timestamp across all addresses.
// Returns nil, nil, nil when no checkpoints exist yet.
func (m *CheckpointModel) GetLatestBlock(ctx context.Context) (*int, *time.Time, error) {
var block *int
var checkedAt *time.Time
err := m.pool.QueryRow(ctx,
`SELECT MAX(last_checked_block), MAX(last_checked_at) FROM address_checkpoints`,
).Scan(&block, &checkedAt)
if err != nil {
return nil, nil, err
}
return block, checkedAt, nil
}
// GetLastCheckedBlock returns the last checked block for an address, or -1 if never checked.
func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int) (int, bool, error) {
var block int

View File

@@ -6,7 +6,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
type NotificationConfigModel struct {
@@ -45,11 +45,11 @@ func (m *NotificationConfigModel) UpsertConfig(ctx context.Context, userID strin
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (user_id)
DO UPDATE SET
discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url),
telegram_chat_id = COALESCE($3, user_notification_configs.telegram_chat_id),
telegram_bot_token = COALESCE($4, user_notification_configs.telegram_bot_token),
email = COALESCE($5, user_notification_configs.email),
slack_webhook_url = COALESCE($6, user_notification_configs.slack_webhook_url),
discord_webhook_url = $2,
telegram_chat_id = $3,
telegram_bot_token = $4,
email = $5,
slack_webhook_url = $6,
notification_enabled = $7,
updated_at = NOW()
RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,

View File

@@ -0,0 +1,89 @@
package models
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
type UserModel struct {
pool *pgxpool.Pool
}
func NewUserModel(pool *pgxpool.Pool) *UserModel {
return &UserModel{pool: pool}
}
const userColumns = `id, firebase_uid, email, display_name,
stripe_customer_id, stripe_subscription_id, subscription_status,
subscription_created_at, created_at, updated_at`
func scanUser(row pgx.Row) (*domain.User, error) {
var u domain.User
err := row.Scan(
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
&u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil //nolint:nilnil
}
return nil, err
}
return &u, nil
}
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
// creating one if it doesn't exist yet. On conflict (returning user) the
// updated_at timestamp is refreshed.
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
row := m.pool.QueryRow(ctx,
`INSERT INTO users (firebase_uid, email)
VALUES ($1, $2)
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
RETURNING `+userColumns,
firebaseUID, email,
)
return scanUser(row)
}
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
row := m.pool.QueryRow(ctx,
`SELECT `+userColumns+` FROM users WHERE id = $1`, id,
)
return scanUser(row)
}
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
_, err := m.pool.Exec(ctx,
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,
userID, stripeCustomerID,
)
return err
}
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string) error {
_, err := m.pool.Exec(ctx,
`UPDATE users
SET stripe_subscription_id = $2,
subscription_status = $3,
subscription_created_at = COALESCE(subscription_created_at, NOW()),
updated_at = NOW()
WHERE stripe_customer_id = $1`,
stripeCustomerID, subscriptionID, status,
)
return err
}
func (m *UserModel) UpdateSubscriptionStatus(ctx context.Context, stripeCustomerID, status string) error {
_, err := m.pool.Exec(ctx,
`UPDATE users SET subscription_status = $2, updated_at = NOW()
WHERE stripe_customer_id = $1`,
stripeCustomerID, status,
)
return err
}

View File

@@ -2,6 +2,7 @@ package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
@@ -20,16 +21,17 @@ const (
colorBlue = 0x0099ff
)
// discordHTTPClient is a shared HTTP client with a timeout for Discord requests.
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
Timeout: discordHTTPTimeoutSeconds * time.Second,
}
type AlertMetadata struct {
TxHash string
AddressLabel string
AlertType string
Address string
// sends alert notifications via a Discord webhook.
type DiscordNotifier struct {
WebhookURL string
}
func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendDiscordNotification(d.WebhookURL, message, meta)
return err
}
type discordEmbed struct {

View File

@@ -2,6 +2,7 @@ package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
@@ -9,6 +10,17 @@ import (
"time"
)
type EmailNotifier struct {
APIKey string
From string
To string
}
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
return err
}
const emailHTTPTimeoutSeconds = 10
var emailHTTPClient = &http.Client{ //nolint:gochecknoglobals

View File

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

View File

@@ -2,6 +2,7 @@ package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
@@ -9,6 +10,17 @@ import (
"time"
)
// SlackNotifier sends alert notifications via a Slack webhook.
type SlackNotifier struct {
WebhookURL string
}
// Send implements Notifier for Slack.
func (s *SlackNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendSlackNotification(s.WebhookURL, message, meta)
return err
}
const slackHTTPTimeoutSeconds = 10
var slackHTTPClient = &http.Client{ //nolint:gochecknoglobals

View File

@@ -2,6 +2,7 @@ package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
@@ -9,6 +10,18 @@ import (
"time"
)
// TelegramNotifier sends alert notifications via Telegram.
type TelegramNotifier struct {
BotToken string
ChatID string
}
// Send implements Notifier for Telegram.
func (t *TelegramNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
_, err := SendTelegramNotification(t.BotToken, t.ChatID, message, meta)
return err
}
const telegramHTTPTimeoutSeconds = 10
var telegramHTTPClient = &http.Client{ //nolint:gochecknoglobals

View File

@@ -0,0 +1,399 @@
package ethereum
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"math/big"
"net/http"
"strings"
"time"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
const (
rpcTimeoutMS = 30000
rpcMaxRetries = 3
rpcRetryBaseMS = 2000
)
type JsonRpcEthereum struct {
rpcURL string
client *http.Client
}
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
if rpcURL == "" {
return nil, fmt.Errorf("JsonRpcEthereum requires a valid RPC URL")
}
return &JsonRpcEthereum{
rpcURL: rpcURL,
client: &http.Client{
Timeout: time.Duration(rpcTimeoutMS) * time.Millisecond,
},
}, nil
}
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params []interface{} `json:"params"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
if params == nil {
params = []interface{}{}
}
body, err := json.Marshal(rpcRequest{
JSONRPC: "2.0",
ID: time.Now().UnixMilli(),
Method: method,
Params: params,
})
if err != nil {
return nil, fmt.Errorf("marshal RPC request: %w", err)
}
return j.callWithRetry(ctx, method, body)
}
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
// It retries on network errors, HTTP 429, and HTTP 5xx. It does NOT retry on RPC-level
// errors or other 4xx responses (those are permanent failures).
func (j *JsonRpcEthereum) callWithRetry(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
var lastErr error
for attempt := range rpcMaxRetries {
if attempt > 0 {
wait := time.Duration(rpcRetryBaseMS*(1<<(attempt-1))) * time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
log.Printf("Retrying RPC call [%s] (attempt %d/%d)", method, attempt+1, rpcMaxRetries)
}
result, err := j.doRPCCall(ctx, method, body)
if err == nil {
return result, nil
}
lastErr = err
// Permanent errors: do not retry
if isPermanentRPCError(err) {
return nil, err
}
log.Printf("Transient RPC error [%s] (attempt %d/%d): %v", method, attempt+1, rpcMaxRetries, err)
}
return nil, lastErr
}
func (j *JsonRpcEthereum) doRPCCall(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create RPC request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := j.client.Do(req)
if err != nil {
return nil, fmt.Errorf("RPC call failed [%s]: %w", method, err)
}
defer resp.Body.Close()
// 429 and 5xx are transient; other non-200 are permanent.
if resp.StatusCode != http.StatusOK {
err := fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { //nolint:mnd
return nil, err // transient — will be retried
}
return nil, &permanentRPCError{err}
}
var rpcResp rpcResponse
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
return nil, fmt.Errorf("decode RPC response: %w", err)
}
if rpcResp.Error != nil {
// RPC-level errors are permanent (bad params, unsupported method, etc.)
return nil, &permanentRPCError{
fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code),
}
}
return rpcResp.Result, nil
}
type permanentRPCError struct{ cause error }
func (e *permanentRPCError) Error() string { return e.cause.Error() }
func (e *permanentRPCError) Unwrap() error { return e.cause }
func isPermanentRPCError(err error) bool {
var p *permanentRPCError
return errors.As(err, &p)
}
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
result, err := j.callRPC(ctx, "eth_blockNumber")
if err != nil {
return 0, err
}
var hexBlock string
if err := json.Unmarshal(result, &hexBlock); err != nil {
return 0, fmt.Errorf("unmarshal block number: %w", err)
}
return hexToInt(hexBlock)
}
type rpcBlock struct {
Timestamp string `json:"timestamp"`
Transactions []rpcTx `json:"transactions"`
}
type rpcTx struct {
Hash string `json:"hash"`
From string `json:"from"`
To *string `json:"to"`
Value string `json:"value"`
BlockNumber string `json:"blockNumber"`
}
func (j *JsonRpcEthereum) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) {
hexBlock := fmt.Sprintf("0x%x", blockNumber)
result, err := j.callRPC(ctx, "eth_getBlockByNumber", hexBlock, true)
if err != nil {
return nil, err
}
if string(result) == "null" {
return nil, nil
}
var block rpcBlock
if err := json.Unmarshal(result, &block); err != nil {
return nil, fmt.Errorf("unmarshal block: %w", err)
}
blockTimestamp, err := hexToInt64(block.Timestamp)
if err != nil {
return nil, fmt.Errorf("parse block timestamp: %w", err)
}
txs := make([]domain.NormalizedTx, 0, len(block.Transactions))
for _, tx := range block.Transactions {
bn, _ := hexToInt(tx.BlockNumber)
var toLower *string
if tx.To != nil {
s := strings.ToLower(*tx.To)
toLower = &s
}
txs = append(txs, domain.NormalizedTx{
Hash: tx.Hash,
From: strings.ToLower(tx.From),
To: toLower,
Value: hexToDecimalString(tx.Value),
BlockNumber: bn,
BlockTimestamp: blockTimestamp,
})
}
return txs, nil
}
func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (string, error) {
result, err := j.callRPC(ctx, "eth_getBalance", address, "latest")
if err != nil {
return "", err
}
var hexBalance string
if err := json.Unmarshal(result, &hexBalance); err != nil {
return "", fmt.Errorf("unmarshal balance: %w", err)
}
return hexToDecimalString(hexBalance), nil
}
const erc20TransferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
const minTransferTopics = 3
type rpcLog struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
BlockNumber string `json:"blockNumber"`
TxHash string `json:"transactionHash"`
}
// GetTokenTransfers fetches ERC-20 Transfer events for a given address
// across a block range, covering both incoming and outgoing transfers.
func (j *JsonRpcEthereum) GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error) {
paddedAddr := padAddress(address)
hexFrom := fmt.Sprintf("0x%x", fromBlock)
hexTo := fmt.Sprintf("0x%x", toBlock)
incomingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, "", paddedAddr)
if err != nil {
return nil, fmt.Errorf("fetch incoming token transfers: %w", err)
}
outgoingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, paddedAddr, "")
if err != nil {
return nil, fmt.Errorf("fetch outgoing token transfers: %w", err)
}
seen := make(map[string]bool)
var txs []domain.NormalizedTx
for _, entry := range append(incomingLogs, outgoingLogs...) {
key := entry.TxHash + "|" + entry.Address + "|" + entry.Data
if seen[key] {
continue
}
seen[key] = true
tx, parseErr := parseTransferLog(entry)
if parseErr != nil {
log.Printf("Skipping unparseable transfer log in tx %s: %v", entry.TxHash, parseErr)
continue
}
txs = append(txs, tx)
}
return txs, nil
}
func (j *JsonRpcEthereum) getTransferLogs(ctx context.Context, fromBlock, toBlock, fromAddr, toAddr string) ([]rpcLog, error) {
topics := make([]interface{}, minTransferTopics)
topics[0] = erc20TransferTopic
if fromAddr != "" {
topics[1] = fromAddr
}
if toAddr != "" {
topics[2] = toAddr
}
filter := map[string]interface{}{
"fromBlock": fromBlock,
"toBlock": toBlock,
"topics": topics,
}
result, err := j.callRPC(ctx, "eth_getLogs", filter)
if err != nil {
return nil, err
}
var logs []rpcLog
if err := json.Unmarshal(result, &logs); err != nil {
return nil, fmt.Errorf("unmarshal logs: %w", err)
}
return logs, nil
}
func parseTransferLog(entry rpcLog) (domain.NormalizedTx, error) {
if len(entry.Topics) < minTransferTopics {
return domain.NormalizedTx{}, fmt.Errorf("transfer log has %d topics, expected >= 3", len(entry.Topics))
}
from := topicToAddress(entry.Topics[1])
to := topicToAddress(entry.Topics[2])
tokenValue := hexToDecimalString(entry.Data)
blockNumber, _ := hexToInt(entry.BlockNumber)
contractAddr := strings.ToLower(entry.Address)
tokenInfo, known := LookupToken(contractAddr)
tx := domain.NormalizedTx{
Hash: entry.TxHash,
From: from,
To: &to,
Value: "0",
BlockNumber: blockNumber,
TokenContract: &contractAddr,
TokenValue: &tokenValue,
}
if known {
tx.TokenSymbol = &tokenInfo.Symbol
tx.TokenDecimals = &tokenInfo.Decimals
}
return tx, nil
}
func padAddress(addr string) string {
clean := strings.TrimPrefix(strings.ToLower(addr), "0x")
const addressHexLen = 64
return "0x" + strings.Repeat("0", addressHexLen-len(clean)) + clean
}
func topicToAddress(topic string) string {
clean := strings.TrimPrefix(topic, "0x")
const ethAddrLen = 40
if len(clean) > ethAddrLen {
clean = clean[len(clean)-ethAddrLen:]
}
return "0x" + strings.ToLower(clean)
}
func hexToInt(hex string) (int, error) {
hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16)
if !ok {
return 0, fmt.Errorf("invalid hex: %s", hex)
}
return int(n.Int64()), nil
}
func hexToInt64(hex string) (int64, error) {
hex = strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(hex, 16)
if !ok {
return 0, fmt.Errorf("invalid hex: %s", hex)
}
return n.Int64(), nil
}
func hexToDecimalString(hex string) string {
if hex == "" || hex == "0x" || hex == "0x0" {
return "0"
}
clean := strings.TrimPrefix(hex, "0x")
n, ok := new(big.Int).SetString(clean, 16)
if !ok {
return "0"
}
return n.String()
}

View File

@@ -3,7 +3,7 @@ package ethereum
import (
"context"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
// EthereumObserver defines the interface for blockchain interaction.
@@ -12,4 +12,5 @@ type EthereumObserver interface {
GetLatestBlockNumber(ctx context.Context) (int, error)
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
GetBalance(ctx context.Context, address string) (string, error)
GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error)
}

View File

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

View File

@@ -9,7 +9,7 @@ import (
"net/http"
"time"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend/internal/models"
)
const (
@@ -20,7 +20,7 @@ const (
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
// EmailDigestService handles email setup and digest sending via Resend.
// handles email setup and digest sending via Resend.
type EmailDigestService struct {
apiKey string
fromAddress string
@@ -66,7 +66,6 @@ func (s *EmailDigestService) SetupEmail(toAddress string) error {
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
}
// SendDigest compiles recent alert events for a user and sends a digest email.
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
if !s.Configured() {
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
@@ -131,8 +130,6 @@ func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress s
return s.send(toAddress, subject, html)
}
// SendDigestsForAllUsers sends a digest email to every user that has
// notifications enabled and an email configured.
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
if !s.Configured() {
return 0, nil

View File

@@ -4,12 +4,23 @@ import (
"context"
"fmt"
"log"
"sync"
"time"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
"github.com/kjannette/koin-ping/backend-go/internal/wei"
"golang.org/x/sync/semaphore"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend/internal/notifications"
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
"github.com/kjannette/koin-ping/backend/internal/wei"
)
const (
notificationTimeout = 30 * time.Second
notificationMaxRetries = 3
notificationRetryBase = time.Second
maxConcurrentNotifications = 5
)
type EvaluatorService struct {
@@ -20,6 +31,8 @@ type EvaluatorService struct {
notifConfigs *models.NotificationConfigModel
resendAPIKey string
emailFrom string
notifSem *semaphore.Weighted
notifWg sync.WaitGroup
}
func NewEvaluatorService(
@@ -39,6 +52,7 @@ func NewEvaluatorService(
notifConfigs: notifConfigs,
resendAPIKey: resendAPIKey,
emailFrom: emailFrom,
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
}
}
@@ -167,25 +181,97 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
message := s.buildMessage(rule, obs)
txHash := &obs.Hash
_, err = s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
event, err := s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
if err != nil {
return err
}
if event == nil {
log.Printf("[ALERT DEDUP] Rule %d (%s) - duplicate event skipped for TX: %s", rule.ID, rule.Type, obs.Hash)
return nil
}
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
// Send Discord notification (non-fatal on failure)
if addr != nil {
userID := addr.UserID
address := addr.Address
if err := s.notifSem.Acquire(ctx, 1); err != nil {
log.Printf("Failed to acquire notification semaphore for rule %d: %v", rule.ID, err)
return nil
}
s.notifWg.Add(1)
go func() {
s.sendNotification(
ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address,
)
defer s.notifSem.Release(1)
defer s.notifWg.Done()
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
defer cancel()
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
}()
}
return nil
}
// WaitForNotifications blocks until all in-flight notification goroutines finish.
func (s *EvaluatorService) WaitForNotifications() {
s.notifWg.Wait()
}
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
var notifiers []notifications.Notifier
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
notifiers = append(notifiers, &notifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
}
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
notifiers = append(notifiers, &notifications.TelegramNotifier{
BotToken: *cfg.TelegramBotToken,
ChatID: *cfg.TelegramChatID,
})
}
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
notifiers = append(notifiers, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
}
if cfg.Email != nil && *cfg.Email != "" {
notifiers = append(notifiers, &notifications.EmailNotifier{
APIKey: s.resendAPIKey,
From: s.emailFrom,
To: *cfg.Email,
})
}
return notifiers
}
func sendWithRetry(ctx context.Context, n notifications.Notifier, message string, meta notifications.AlertMetadata) error {
var lastErr error
for attempt := range notificationMaxRetries {
if attempt > 0 {
wait := notificationRetryBase * time.Duration(1<<(attempt-1))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
if err := n.Send(ctx, message, meta); err != nil {
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
lastErr = err
continue
}
return nil
}
return lastErr
}
func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
if err != nil {
@@ -204,49 +290,20 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
Address: address,
}
if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" {
sent, sendErr := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta)
if sendErr != nil || !sent {
log.Printf("Discord notification failed for user %s: %v", userID, sendErr)
for _, n := range s.buildNotifiers(notifConfig) {
if err := sendWithRetry(ctx, n, message, meta); err != nil {
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
} else {
log.Printf("Discord notification sent to user %s", userID)
}
}
if notifConfig.TelegramBotToken != nil && *notifConfig.TelegramBotToken != "" &&
notifConfig.TelegramChatID != nil && *notifConfig.TelegramChatID != "" {
sent, sendErr := notifications.SendTelegramNotification(
*notifConfig.TelegramBotToken, *notifConfig.TelegramChatID, message, meta,
)
if sendErr != nil || !sent {
log.Printf("Telegram notification failed for user %s: %v", userID, sendErr)
} else {
log.Printf("Telegram notification sent to user %s", userID)
}
}
if notifConfig.SlackWebhookURL != nil && *notifConfig.SlackWebhookURL != "" {
sent, sendErr := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta)
if sendErr != nil || !sent {
log.Printf("Slack notification failed for user %s: %v", userID, sendErr)
} else {
log.Printf("Slack notification sent to user %s", userID)
}
}
if notifConfig.Email != nil && *notifConfig.Email != "" {
sent, sendErr := notifications.SendEmailNotification(
s.resendAPIKey, s.emailFrom, *notifConfig.Email, message, meta,
)
if sendErr != nil || !sent {
log.Printf("Email notification failed for user %s: %v", userID, sendErr)
} else {
log.Printf("Email notification sent to user %s", userID)
log.Printf("Notification sent to user %s via %T", userID, n)
}
}
}
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
if obs.IsTokenTransfer() {
return s.buildTokenMessage(rule, obs)
}
switch rule.Type {
case domain.AlertIncomingTx:
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
@@ -271,3 +328,36 @@ func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.Observ
return "Alert triggered"
}
}
const defaultTokenDecimals = 18
func (s *EvaluatorService) buildTokenMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
symbol := "tokens"
if obs.TokenSymbol != nil {
symbol = *obs.TokenSymbol
}
amount := "unknown"
if obs.TokenValue != nil {
decimals := defaultTokenDecimals
if obs.TokenDecimals != nil {
decimals = *obs.TokenDecimals
}
amount = wei.FormatTokenAmount(*obs.TokenValue, decimals)
}
switch rule.Type {
case domain.AlertIncomingTx:
return fmt.Sprintf("Incoming transfer: %s %s received", amount, symbol)
case domain.AlertOutgoingTx:
return fmt.Sprintf("Outgoing transfer: %s %s sent", amount, symbol)
case domain.AlertLargeTransfer:
threshold := float64(0)
if rule.Threshold != nil {
threshold = *rule.Threshold
}
return fmt.Sprintf("Large token transfer: %s %s (threshold: %g)", amount, symbol, threshold)
default:
return fmt.Sprintf("Token transfer: %s %s", amount, symbol)
}
}

View File

@@ -5,9 +5,9 @@ import (
"log"
"strings"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
"github.com/kjannette/koin-ping/backend-go/internal/models"
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/models"
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
)
const maxBlocksPerRun = 100
@@ -80,6 +80,15 @@ func (s *ObserverService) observeAddress(ctx context.Context, addr domain.Addres
}
}
tokenTxs, err := s.eth.GetTokenTransfers(ctx, startBlock, endBlock, addr.Address)
if err != nil {
log.Printf("Error fetching token transfers for %s: %v", addr.Address, err)
} else {
for _, tx := range tokenTxs {
observations = append(observations, createObservedTx(tx, addr))
}
}
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
return nil, err
}

View File

@@ -98,3 +98,54 @@ func FormatAsEth(weiString string, decimals int) (string, error) {
}
return fmt.Sprintf("%.*f ETH", decimals, eth), nil
}
// FormatTokenAmount formats a raw token amount using the token's decimal places.
// For example, 1000000 USDT (6 decimals) becomes "1".
func FormatTokenAmount(rawValue string, tokenDecimals int) string {
if rawValue == "" || rawValue == "0" {
return "0"
}
n, ok := new(big.Int).SetString(rawValue, 10)
if !ok {
return "0"
}
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(tokenDecimals)), nil) //nolint:mnd
whole := new(big.Int).Div(n, divisor)
remainder := new(big.Int).Mod(n, divisor)
if remainder.Sign() == 0 {
return addThousandsSeparators(whole.String())
}
fracStr := fmt.Sprintf("%0*s", tokenDecimals, remainder.String())
fracStr = strings.TrimRight(fracStr, "0")
const maxDisplayDecimals = 4
if len(fracStr) > maxDisplayDecimals {
fracStr = fracStr[:maxDisplayDecimals]
}
return addThousandsSeparators(whole.String()) + "." + fracStr
}
func addThousandsSeparators(s string) string {
if len(s) <= 3 { //nolint:mnd
return s
}
var result strings.Builder
offset := len(s) % 3 //nolint:mnd
if offset > 0 {
result.WriteString(s[:offset])
}
for i := offset; i < len(s); i += 3 { //nolint:mnd
if result.Len() > 0 {
result.WriteByte(',')
}
result.WriteString(s[i : i+3]) //nolint:mnd
}
return result.String()
}

BIN
frontend/public/ping.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,35 +1,38 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./contexts/AuthContext";
import Navbar from "./components/Navbar";
import Login from "./pages/Login";
import Login from "./pages/login/Login";
import Signup from "./pages/Signup";
import Addresses from "./pages/Addresses";
import Alerts from "./pages/Alerts";
import AlertHistory from "./pages/AlertHistory";
import Subscribe from "./pages/subscribe/subscribe";
import Addresses from "./pages/addresses/Addresses";
import Alerts from "./pages/alerts/Alerts";
import AlertHistory from "./pages/alertHistory/AlertHistory";
export default function App() {
const { currentUser } = useAuth();
if (!currentUser) {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
);
}
const { currentUser } = useAuth();
if (!currentUser) {
return (
<div>
<Navbar />
<Routes>
<Route path="/" element={<Addresses />} />
<Route path="/addresses" element={<Addresses />} />
<Route path="/alerts" element={<Alerts />} />
<Route path="/history" element={<AlertHistory />} />
<Route path="*" element={<Navigate to="/addresses" />} />
</Routes>
</div>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
);
}
return (
<div>
<Navbar />
<Routes>
<Route path="/" element={<Addresses />} />
<Route path="/addresses" element={<Addresses />} />
<Route path="/alerts" element={<Alerts />} />
<Route path="/alertevents" element={<AlertHistory />} />
<Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/addresses" />} />
</Routes>
</div>
);
}

View File

@@ -80,6 +80,41 @@ export async function getAddresses() {
}
}
/**
* Update an address (e.g. change its label)
* @param {number} addressId - Address ID to update
* @param {Object} data - Fields to update (e.g. { label: "New Label" })
* @returns {Promise<Object>} Updated address
*/
export async function updateAddress(addressId, data) {
try {
const headers = await getAuthHeaders();
const response = await fetch(`${API_BASE}/addresses/${addressId}`, {
method: "PATCH",
headers: headers,
body: JSON.stringify(data),
});
if (!response.ok) {
let errorMessage = "Failed to update address";
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 a tracked address
* @param {number} addressId - Address ID to delete

View File

@@ -0,0 +1,41 @@
import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from "./config";
export async function createCheckoutSession() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
method: "POST",
headers,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to create checkout session");
}
return res.json();
}
export async function verifyCheckoutSession(sessionId) {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/verify-checkout`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to verify checkout session");
}
return res.json();
}
export async function getSubscriptionStatus() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/subscription-status`, {
headers,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to get subscription status");
}
return res.json();
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import Input from "./Input";
import Button from "./Button";
import "./AlertForm.css";
const ALERT_TYPES = [
{ value: "incoming_tx", label: "Incoming transaction" },
@@ -36,9 +37,9 @@ export default function AlertForm({ onSubmit }) {
return (
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<div className="alert-form__options">
{ALERT_TYPES.map((opt) => (
<label key={opt.value} style={{ display: "block" }}>
<label key={opt.value} className="alert-form__radio">
<input
type="radio"
name="alertType"

View File

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

View File

@@ -1,20 +1,19 @@
import "./Button.css";
export default function Button({
children,
onClick,
disabled = false,
type = "button",
variant = "primary",
className = "",
}) {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
style={{
padding: "0.5rem 1rem",
fontSize: "1rem",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
}}
className={`btn btn--${variant} ${className}`}
>
{children}
</button>

View File

@@ -0,0 +1,21 @@
.input__label {
display: block;
margin: 0.8rem 0rem 0.4rem 0rem;
font-size: 0.9rem;
}
.input__control {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
}
.input__control:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
}

View File

@@ -1,3 +1,5 @@
import "./Input.css";
export default function Input({
label,
type = "text",
@@ -7,12 +9,12 @@ export default function Input({
step,
min,
disabled = false,
required = false,
className = "",
}) {
return (
<label style={{ display: "block", marginBottom: "1rem" }}>
<div style={{ marginBottom: "0.25rem", fontSize: "0.9rem" }}>
{label}
</div>
<label className={`form-field ${className}`}>
<div className="input__label">{label}</div>
<input
type={type}
value={value}
@@ -20,12 +22,9 @@ export default function Input({
step={step}
min={min}
disabled={disabled}
required={required}
onChange={(e) => onChange(e.target.value)}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
}}
className="input__control"
/>
</label>
);

View File

@@ -0,0 +1,56 @@
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
margin-bottom: 1.5rem;
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border-light);
}
.navbar__brand {
font-weight: 700;
font-size: 1.4rem;
color: var(--color-brand);
margin-right: 2rem;
letter-spacing: 0.5px;
}
.navbar__links {
display: flex;
gap: 0.25rem;
}
.navbar__link {
padding: 0.5rem 1.25rem;
border-radius: var(--radius-lg);
text-decoration: none;
font-size: 1.425rem;
font-weight: 200;
color: var(--color-text-subtle);
background-color: transparent;
transition: all 0.15s ease;
}
.navbar__link--active {
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg-card);
}
.navbar__user {
font-size: 1.275rem;
color: #777;
}
.navbar__logout {
padding: 0.4rem 1rem;
font-size: 1.275rem;
font-weight: 400;
background-color: transparent;
color: var(--color-text-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.15s ease;
}

View File

@@ -1,10 +1,11 @@
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import "./Navbar.css";
const navLinks = [
{ to: "/addresses", label: "Addresses" },
{ to: "/alerts", label: "Configure Alerts" },
{ to: "/history", label: "Alert History" },
{ to: "/alertevents", label: "Alert Event History" },
];
export default function Navbar() {
@@ -14,43 +15,17 @@ export default function Navbar() {
if (!currentUser) return null;
return (
<nav style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "1rem 2rem",
marginBottom: "1.5rem",
backgroundColor: "#1a1a1a",
borderBottom: "1px solid #333",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{
fontWeight: 700,
fontSize: "1.1rem",
color: "#fff",
marginRight: "2rem",
letterSpacing: "0.5px",
}}>
Koin Ping
</span>
<div style={{ display: "flex", gap: "0.25rem" }}>
<nav className="navbar">
<div className="flex flex--center gap-sm">
<span className="navbar__brand">Koin Ping</span>
<div className="navbar__links">
{navLinks.map(({ to, label }) => {
const isActive = location.pathname === to;
return (
<Link
key={to}
to={to}
style={{
padding: "0.5rem 1.25rem",
borderRadius: "6px",
textDecoration: "none",
fontSize: "1.425rem",
fontWeight: isActive ? 600 : 200,
color: isActive ? "#fff" : "#999",
backgroundColor: isActive ? "#333" : "transparent",
transition: "all 0.15s ease",
}}
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
>
{label}
</Link>
@@ -59,24 +34,9 @@ export default function Navbar() {
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "1.25rem" }}>
<span style={{ fontSize: "1.275rem", color: "#777" }}>
{currentUser.email}
</span>
<button
onClick={logout}
style={{
padding: "0.4rem 1rem",
fontSize: "1.275rem",
fontWeight: 400,
backgroundColor: "transparent",
color: "#999",
border: "1px solid #444",
borderRadius: "6px",
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
<div className="flex flex--center gap-lg">
<span className="navbar__user">{currentUser.email}</span>
<button onClick={logout} className="navbar__logout">
Logout
</button>
</div>

View File

@@ -1,5 +1,43 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap');
:root {
--color-primary: #0066cc;
--color-primary-hover: #0077ee;
--color-brand: #e62525;
--color-success: #28a745;
--color-success-text: #00ff00;
--color-success-bg: #00ff0020;
--color-success-border: #00ff00;
--color-error: #ff6666;
--color-error-bg: #ff000020;
--color-error-border: #ff0000;
--color-warning: #ffcc44;
--color-warning-bg: #3a2e00;
--color-warning-border: #aa7700;
--color-danger: #cc6666;
--color-danger-border: #884444;
--color-bg: #1a1a1a;
--color-bg-elevated: #242424;
--color-bg-card: #333;
--color-bg-section: #2a2a2a;
--color-border: #444;
--color-border-light: #333;
--color-border-section: #3a3a3a;
--color-text: white;
--color-text-muted: #b3b3b3;
--color-text-dimmed: #808080;
--color-text-subtle: #999;
--color-text-label: #ccc;
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
--radius-xl: 8px;
}
*,
*::before,
*::after {
@@ -21,13 +59,300 @@ body {
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Nunito', sans-serif;
font-weight: 200;
}
input, select, textarea, button {
input,
select,
textarea,
button {
font-family: 'Nunito', sans-serif;
font-size: 1rem;
font-weight: 200;
}
/* ── Page Layouts ─────────────────────────────────────────── */
.page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.page--wide {
max-width: 1400px;
}
/* ── Cards & Sections ─────────────────────────────────────── */
.card {
padding: 1rem;
background-color: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.section {
margin-bottom: 1.5rem;
padding: 1rem;
background-color: var(--color-bg-section);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-section);
}
/* ── Form Controls ────────────────────────────────────────── */
.form-field {
margin-bottom: 1.25rem;
}
.form-field--last {
margin-bottom: 1.75rem;
}
.form-label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.9rem;
color: var(--color-text-label);
}
.form-control {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
}
.form-control:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
}
.form-control--mono {
font-family: monospace;
}
.form-select {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
}
/* ── Buttons ──────────────────────────────────────────────── */
.btn {
padding: 0.5rem 1rem;
font-size: 1rem;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text);
transition: opacity 0.15s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn--primary {
background-color: var(--color-primary);
}
.btn--success {
background-color: var(--color-success);
}
.btn--secondary {
background-color: #6c757d;
}
.btn--danger {
background-color: transparent;
border: 1px solid var(--color-danger-border);
color: var(--color-danger);
}
.btn--ghost {
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-subtle);
}
.btn--sm {
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
.btn--lg {
padding: 0.75rem 1.5rem;
}
/* ── Feedback Alerts ──────────────────────────────────────── */
.alert {
padding: 0.75rem;
margin-bottom: 1rem;
border-radius: var(--radius-md);
}
.alert--error {
background-color: var(--color-error-bg);
border: 1px solid var(--color-error-border);
color: var(--color-error);
}
.alert--success {
background-color: var(--color-success-bg);
border: 1px solid var(--color-success-border);
color: var(--color-success-text);
}
.alert--warning {
background-color: var(--color-warning-bg);
border: 1px solid var(--color-warning-border);
color: var(--color-warning);
}
/* ── Lists ────────────────────────────────────────────────── */
.list-unstyled {
list-style: none;
padding: 0;
}
.list-item--card {
padding: 1rem;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-card);
}
/* ── Typography Helpers ───────────────────────────────────── */
.text-muted {
color: var(--color-text-muted);
}
.text-dimmed {
color: var(--color-text-dimmed);
}
.text-mono {
font-family: monospace;
}
.text-bold {
font-weight: bold;
}
.text-error {
color: red;
}
.text-sm {
font-size: 1.035rem;
}
.text-xs {
font-size: 0.978rem;
}
.help-link {
color: var(--color-primary);
font-size: 0.85rem;
}
/* ── Flex Utilities ───────────────────────────────────────── */
.flex {
display: flex;
}
.flex--between {
justify-content: space-between;
}
.flex--center {
align-items: center;
}
.flex--column {
flex-direction: column;
}
.flex--wrap {
flex-wrap: wrap;
}
.gap-xs {
gap: 0.25rem;
}
.gap-sm {
gap: 0.5rem;
}
.gap-md {
gap: 0.75rem;
}
.gap-lg {
gap: 1rem;
}
/* ── Spacing Helpers ──────────────────────────────────────── */
.mb-0 {
margin-bottom: 0;
}
.mb-sm {
margin-bottom: 0.5rem;
}
.mb-md {
margin-bottom: 1rem;
}
.mb-lg {
margin-bottom: 1.5rem;
}
.mb-xl {
margin-bottom: 2rem;
}
.mt-0 {
margin-top: 0;
}
.mt-md {
margin-top: 1rem;
}
.text-white {
color: white;
}
.text-white-sm {
color: white;
font-size: 0.85rem;
}

View File

@@ -1,95 +0,0 @@
import { useState, useEffect } from "react";
import AddressForm from "../components/AddressForm";
import { getAddresses, createAddress } from "../api/addresses";
export default function Addresses() {
const [addresses, setAddresses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Load addresses on mount
useEffect(() => {
async function fetchAddresses() {
try {
setLoading(true);
const data = await getAddresses();
setAddresses(data);
} catch (err) {
setError(err.message);
console.error("Failed to fetch addresses:", err);
} finally {
setLoading(false);
}
}
fetchAddresses();
}, []);
// Handle new address submission
async function handleAddressSubmit(data) {
try {
const newAddress = await createAddress(data);
// Append new address to state
setAddresses((prev) => [...prev, newAddress]);
setError(null); // Clear any previous errors
} catch (err) {
setError(err.message);
console.error("Failed to create address:", err);
}
}
return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Tracked Addresses</h1>
<div style={{ marginBottom: "2rem" }}>
<AddressForm onSubmit={handleAddressSubmit} />
</div>
<div>
<h2>Existing Addresses</h2>
{loading && <p>Loading addresses...</p>}
{error && <p style={{ color: "red" }}>Error: {error}</p>}
{!loading && !error && addresses.length === 0 && (
<p style={{ color: "#808080" }}>
No addresses tracked yet. Add one above to get started.
</p>
)}
{addresses.length > 0 && (
<ul style={{ listStyle: "none", padding: 0 }}>
{addresses.map((addr, index) => (
<li
key={addr.id || index}
style={{
padding: "1rem",
marginBottom: "0.5rem",
border: "1px solid #444",
borderRadius: "4px",
backgroundColor: "#333",
}}
>
<div
style={{
fontWeight: "bold",
marginBottom: "0.25rem",
}}
>
{addr.label || "Unlabeled"}
</div>
<div
style={{
fontFamily: "monospace",
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
{addr.address}
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -1,85 +0,0 @@
import { useState, useEffect } from "react";
import { getAlertEvents } from "../api/alertEvents";
export default function AlertHistory() {
const [alertEvents, setAlertEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Fetch alert events on mount
useEffect(() => {
async function fetchAlertEvents() {
try {
setLoading(true);
const data = await getAlertEvents();
setAlertEvents(data);
} catch (err) {
setError(err.message);
console.error("Failed to fetch alert events:", err);
} finally {
setLoading(false);
}
}
fetchAlertEvents();
}, []);
if (loading) {
return <div style={{ padding: "2rem" }}>Loading...</div>;
}
if (error) {
return (
<div style={{ padding: "2rem", color: "red" }}>Error: {error}</div>
);
}
return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Recent Alerts</h1>
{alertEvents.length === 0 ? (
<p style={{ color: "#808080" }}>No alerts yet</p>
) : (
<ul style={{ listStyle: "none", padding: 0 }}>
{alertEvents.map((event) => (
<li
key={event.id}
style={{
padding: "1rem",
marginBottom: "0.75rem",
border: "1px solid #444",
borderRadius: "4px",
backgroundColor: "#333",
}}
>
<div style={{ marginBottom: "0.5rem" }}>
{event.message}
</div>
{event.address_label && (
<div
style={{
fontSize: "1.035rem",
color: "#808080",
marginBottom: "0.25rem",
}}
>
Address: {event.address_label}
</div>
)}
<small style={{ color: "#b3b3b3" }}>
{formatTimestamp(event.timestamp)}
</small>
</li>
))}
</ul>
)}
</div>
);
}
// Helper to format timestamp for display
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString();
}

View File

@@ -1,825 +0,0 @@
import { useState, useEffect } from "react";
import AlertForm from "../components/AlertForm";
import Button from "../components/Button";
import { getAddresses } from "../api/addresses";
import {
getAlerts,
createAlert,
updateAlertStatus,
deleteAlert,
} from "../api/alerts";
import {
getNotificationConfig,
updateNotificationConfig,
testNotificationChannels,
setupEmail,
sendEmailDigest,
} from "../api/notificationConfig";
const inputStyle = {
width: "100%",
padding: "0.5rem",
fontSize: "0.9rem",
backgroundColor: "#1a1a1a",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
fontFamily: "monospace",
boxSizing: "border-box",
};
const labelStyle = {
display: "block",
marginBottom: "0.25rem",
fontSize: "0.9rem",
color: "#ccc",
};
const helpLinkStyle = { color: "#0066cc", fontSize: "0.85rem" };
const sectionStyle = {
marginBottom: "1.5rem",
padding: "1rem",
backgroundColor: "#2a2a2a",
borderRadius: "6px",
border: "1px solid #3a3a3a",
};
export default function Alerts() {
const [addresses, setAddresses] = useState([]);
const [selectedAddressId, setSelectedAddressId] = useState(null);
const [alerts, setAlerts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Notification config state
const [notificationEnabled, setNotificationEnabled] = useState(false);
const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
const [telegramBotToken, setTelegramBotToken] = useState("");
const [telegramChatId, setTelegramChatId] = useState("");
const [email, setEmail] = useState("");
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
const [notificationLoading, setNotificationLoading] = useState(false);
const [notificationError, setNotificationError] = useState(null);
const [notificationSuccess, setNotificationSuccess] = useState(null);
const [testingChannels, setTestingChannels] = useState(false);
const [settingUpEmail, setSettingUpEmail] = useState(false);
const [sendingDigest, setSendingDigest] = useState(false);
useEffect(() => {
async function fetchData() {
try {
setLoading(true);
const addressData = await getAddresses();
setAddresses(addressData);
if (addressData.length > 0) {
setSelectedAddressId(addressData[0].id);
}
const configData = await getNotificationConfig();
setNotificationEnabled(
configData.notification_enabled !== false,
);
setDiscordWebhookUrl(configData.discord_webhook_url || "");
setTelegramBotToken(configData.telegram_bot_token || "");
setTelegramChatId(configData.telegram_chat_id || "");
setEmail(configData.email || "");
setSlackWebhookUrl(configData.slack_webhook_url || "");
} catch (err) {
setError(err.message);
console.error("Failed to fetch data:", err);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
useEffect(() => {
if (!selectedAddressId) {
setAlerts([]);
return;
}
async function fetchAlerts() {
try {
const data = await getAlerts(selectedAddressId);
setAlerts(data);
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to fetch alerts:", err);
}
}
fetchAlerts();
}, [selectedAddressId]);
async function handleAlertSubmit(data) {
if (!selectedAddressId) return;
try {
const newAlert = await createAlert(selectedAddressId, data);
setAlerts((prev) => [...prev, newAlert]);
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to create alert:", err);
}
}
async function handleToggleAlert(alertId, currentStatus) {
try {
const updated = await updateAlertStatus(alertId, !currentStatus);
setAlerts((prev) =>
prev.map((alert) => (alert.id === alertId ? updated : alert)),
);
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to update alert:", err);
}
}
async function handleDeleteAlert(alertId) {
try {
await deleteAlert(alertId);
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to delete alert:", err);
}
}
async function handleSaveNotificationConfig() {
try {
setNotificationLoading(true);
setNotificationError(null);
setNotificationSuccess(null);
const config = {
notification_enabled: notificationEnabled,
discord_webhook_url: discordWebhookUrl || null,
telegram_bot_token: telegramBotToken || null,
telegram_chat_id: telegramChatId || null,
email: email || null,
slack_webhook_url: slackWebhookUrl || null,
};
await updateNotificationConfig(config);
setNotificationSuccess("Notification settings saved!");
setTimeout(() => setNotificationSuccess(null), 3000);
} catch (err) {
setNotificationError(err.message);
console.error("Failed to save notification config:", err);
} finally {
setNotificationLoading(false);
}
}
async function handleTestChannels() {
try {
setTestingChannels(true);
setNotificationError(null);
setNotificationSuccess(null);
const data = await testNotificationChannels();
const results = data.results || [];
const failed = results.filter((r) => !r.success);
const succeeded = results.filter((r) => r.success);
if (failed.length === 0 && succeeded.length > 0) {
setNotificationSuccess(
`Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
);
} else if (failed.length > 0 && succeeded.length > 0) {
setNotificationSuccess(
`Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
);
} else if (failed.length > 0) {
setNotificationError(
`Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
);
}
setTimeout(() => {
setNotificationSuccess(null);
setNotificationError(null);
}, 6000);
} catch (err) {
setNotificationError(err.message);
} finally {
setTestingChannels(false);
}
}
async function handleSetupEmail() {
try {
setSettingUpEmail(true);
setNotificationError(null);
setNotificationSuccess(null);
await handleSaveNotificationConfig();
const result = await setupEmail();
setNotificationSuccess(
result.message || "Confirmation email sent!",
);
setTimeout(() => setNotificationSuccess(null), 5000);
} catch (err) {
setNotificationError(err.message);
} finally {
setSettingUpEmail(false);
}
}
async function handleSendDigest() {
try {
setSendingDigest(true);
setNotificationError(null);
setNotificationSuccess(null);
const result = await sendEmailDigest();
setNotificationSuccess(result.message || "Digest email sent!");
setTimeout(() => setNotificationSuccess(null), 5000);
} catch (err) {
setNotificationError(err.message);
} finally {
setSendingDigest(false);
}
}
const selectedAddress = addresses.find((a) => a.id === selectedAddressId);
if (loading) {
return <div style={{ padding: "2rem" }}>Loading addresses...</div>;
}
if (addresses.length === 0) {
return (
<div style={{ padding: "2rem" }}>
<p>
No addresses tracked yet. Add an address first to create
alerts.
</p>
</div>
);
}
return (
<div style={{ maxWidth: "1400px", margin: "0 auto", padding: "2rem" }}>
<h1 style={{ marginBottom: "2rem" }}>
Alert Rules & Notifications
</h1>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "2rem",
alignItems: "start",
}}
>
{/* LEFT COLUMN: Alert Rules */}
<div>
<h2 style={{ marginTop: 0 }}>Alert Rules</h2>
<div style={{ marginBottom: "2rem" }}>
<label
style={{ display: "block", marginBottom: "0.5rem" }}
>
<strong>Select Address:</strong>
</label>
<select
value={selectedAddressId || ""}
onChange={(e) =>
setSelectedAddressId(Number(e.target.value))
}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#1a1a1a",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
>
{addresses.map((addr) => (
<option key={addr.id} value={addr.id}>
{addr.label || "Unlabeled"} - {addr.address}
</option>
))}
</select>
</div>
{selectedAddress && (
<>
<div
style={{
padding: "1rem",
marginBottom: "2rem",
backgroundColor: "#333",
borderRadius: "4px",
border: "1px solid #444",
}}
>
<div
style={{
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
Managing alerts for:
</div>
<div
style={{
fontWeight: "bold",
marginTop: "0.25rem",
}}
>
{selectedAddress.label || "Unlabeled"}
</div>
<div
style={{
fontFamily: "monospace",
fontSize: "1.035rem",
color: "#b3b3b3",
}}
>
{selectedAddress.address}
</div>
</div>
<div style={{ marginBottom: "2rem" }}>
<h3>Create New Alert</h3>
<AlertForm onSubmit={handleAlertSubmit} />
</div>
<div>
<h3>Active Alert Rules</h3>
{error && (
<p style={{ color: "red" }}>
Error: {error}
</p>
)}
{alerts.length === 0 ? (
<p style={{ color: "#666" }}>
No alert rules defined yet. Create one
above.
</p>
) : (
<ul
style={{
listStyle: "none",
padding: 0,
}}
>
{alerts.map((alert) => (
<li
key={alert.id}
style={{
padding: "1rem",
marginBottom: "0.5rem",
border: "1px solid #444",
borderRadius: "4px",
backgroundColor: "#333",
opacity: alert.enabled
? 1
: 0.6,
}}
>
<div
style={{
display: "flex",
justifyContent:
"space-between",
alignItems:
"flex-start",
}}
>
<div style={{ flex: 1 }}>
<div
style={{
fontWeight:
"bold",
marginBottom:
"0.25rem",
}}
>
{formatAlertType(
alert.type,
)}
</div>
{alert.threshold && (
<div
style={{
fontSize:
"1.035rem",
color: "#b3b3b3",
}}
>
Threshold:{" "}
{
alert.threshold
}{" "}
ETH
</div>
)}
<div
style={{
fontSize:
"0.978rem",
color: "#808080",
marginTop:
"0.25rem",
}}
>
Status:{" "}
{alert.enabled
? "Enabled"
: "Disabled"}
</div>
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
}}
>
<Button
onClick={() =>
handleToggleAlert(
alert.id,
alert.enabled,
)
}
>
{alert.enabled
? "Disable"
: "Enable"}
</Button>
<Button
onClick={() =>
handleDeleteAlert(
alert.id,
)
}
>
Delete
</Button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</>
)}
</div>
{/* RIGHT COLUMN: Notification Settings */}
<div>
<h2 style={{ marginTop: 0 }}>Notification Settings</h2>
{notificationSuccess && (
<div
style={{
padding: "0.75rem",
marginBottom: "1rem",
backgroundColor: "#00ff0020",
border: "1px solid #00ff00",
borderRadius: "4px",
color: "#00ff00",
}}
>
{notificationSuccess}
</div>
)}
{notificationError && (
<div
style={{
padding: "0.75rem",
marginBottom: "1rem",
backgroundColor: "#ff000020",
border: "1px solid #ff0000",
borderRadius: "4px",
color: "#ff6666",
}}
>
{notificationError}
</div>
)}
{/* Master toggle */}
<div
style={{
marginBottom: "1.5rem",
padding: "1rem",
backgroundColor: "#333",
borderRadius: "4px",
}}
>
<label
style={{
display: "flex",
alignItems: "center",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={notificationEnabled}
onChange={(e) =>
setNotificationEnabled(e.target.checked)
}
style={{
marginRight: "0.5rem",
width: "18px",
height: "18px",
}}
/>
<span style={{ fontWeight: "bold" }}>
Enable Notifications
</span>
</label>
<div
style={{
fontSize: "0.978rem",
color: "#808080",
marginTop: "0.5rem",
marginLeft: "26px",
}}
>
Master switch for all notification channels
</div>
</div>
{/* All channel settings — hidden when master toggle is off */}
<div
style={{
display: notificationEnabled ? "block" : "none",
}}
>
{/* Telegram */}
<div style={sectionStyle}>
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
Telegram
</h3>
<div style={{ marginBottom: "0.75rem" }}>
<label style={labelStyle}>Bot Token</label>
<input
type="text"
value={telegramBotToken}
onChange={(e) =>
setTelegramBotToken(e.target.value)
}
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
style={inputStyle}
/>
</div>
<div style={{ marginBottom: "0.5rem" }}>
<label style={labelStyle}>Chat ID</label>
<input
type="text"
value={telegramChatId}
onChange={(e) =>
setTelegramChatId(e.target.value)
}
placeholder="-1001234567890"
style={inputStyle}
/>
</div>
<a
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
target="_blank"
rel="noopener noreferrer"
style={helpLinkStyle}
>
How to create a Telegram bot & get your Chat ID
</a>
</div>
{/* Email */}
<div style={sectionStyle}>
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
Email
</h3>
<div style={{ marginBottom: "0.75rem" }}>
<label style={labelStyle}>Email Address</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
style={inputStyle}
/>
</div>
<div
style={{
fontSize: "0.85rem",
color: "#808080",
marginBottom: "0.75rem",
}}
>
Alert notifications and digests will be sent to
this address
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
}}
>
<button
onClick={handleSetupEmail}
disabled={settingUpEmail || !email}
style={{
padding: "0.5rem 1rem",
fontSize: "0.85rem",
backgroundColor:
settingUpEmail || !email
? "#333"
: "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor:
settingUpEmail || !email
? "not-allowed"
: "pointer",
}}
>
{settingUpEmail
? "Setting up..."
: "Verify Email"}
</button>
<button
onClick={handleSendDigest}
disabled={sendingDigest || !email}
style={{
padding: "0.5rem 1rem",
fontSize: "0.85rem",
backgroundColor:
sendingDigest || !email
? "#333"
: "#6c757d",
color: "white",
border: "none",
borderRadius: "4px",
cursor:
sendingDigest || !email
? "not-allowed"
: "pointer",
}}
>
{sendingDigest
? "Sending..."
: "Send Digest Now"}
</button>
</div>
</div>
{/* Discord */}
<div style={sectionStyle}>
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
Discord
</h3>
<div style={{ marginBottom: "0.5rem" }}>
<label style={labelStyle}>
Discord Webhook URL
</label>
<input
type="text"
value={discordWebhookUrl}
onChange={(e) =>
setDiscordWebhookUrl(e.target.value)
}
placeholder="https://discord.com/api/webhooks/..."
style={inputStyle}
/>
</div>
<a
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
target="_blank"
rel="noopener noreferrer"
style={helpLinkStyle}
>
How to get a Discord webhook URL
</a>
</div>
{/* Slack */}
<div style={sectionStyle}>
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
Slack
</h3>
<div style={{ marginBottom: "0.5rem" }}>
<label style={labelStyle}>
Slack Webhook URL
</label>
<input
type="text"
value={slackWebhookUrl}
onChange={(e) =>
setSlackWebhookUrl(e.target.value)
}
placeholder="https://hooks.slack.com/services/..."
style={inputStyle}
/>
</div>
<a
href="https://api.slack.com/messaging/webhooks"
target="_blank"
rel="noopener noreferrer"
style={helpLinkStyle}
>
How to set up Slack Incoming Webhooks
</a>
</div>
{/* Save & Test buttons at the bottom */}
<div
style={{
display: "flex",
gap: "0.75rem",
marginTop: "1rem",
}}
>
<button
onClick={handleSaveNotificationConfig}
disabled={notificationLoading}
style={{
padding: "0.75rem 1.5rem",
fontSize: "1rem",
backgroundColor: notificationLoading
? "#333"
: "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: notificationLoading
? "not-allowed"
: "pointer",
}}
>
{notificationLoading
? "Saving..."
: "Save Settings"}
</button>
<button
onClick={handleTestChannels}
disabled={testingChannels}
style={{
padding: "0.75rem 1.5rem",
fontSize: "1rem",
backgroundColor: testingChannels
? "#333"
: "#28a745",
color: "white",
border: "none",
borderRadius: "4px",
cursor: testingChannels
? "not-allowed"
: "pointer",
}}
>
{testingChannels
? "Testing..."
: "Test All Channels"}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
function formatAlertType(type) {
const labels = {
incoming_tx: "Incoming transaction",
outgoing_tx: "Outgoing transaction",
large_transfer: "Large transfer",
balance_below: "Balance below threshold",
};
return labels[type] || type;
}

View File

@@ -1,146 +0,0 @@
/**
* Login Page
*
* Allows existing users to sign in with email and password
*/
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
// Validation
if (!email || !password) {
setError("Please fill in all fields");
return;
}
try {
setError("");
setLoading(true);
await login(email, password);
navigate("/addresses"); // Redirect to main app
} catch (err) {
setError("Failed to log in: " + err.message);
} finally {
setLoading(false);
}
}
return (
<div
style={{
maxWidth: "400px",
margin: "4rem auto",
padding: "2rem",
border: "1px solid #333",
borderRadius: "8px",
}}
>
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
Koin Ping - Login
</h1>
{error && (
<div
style={{
padding: "0.75rem",
marginBottom: "1rem",
backgroundColor: "#ff000020",
border: "1px solid #ff0000",
borderRadius: "4px",
color: "#ff6666",
}}
>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem",
fontSize: "1rem",
backgroundColor: loading ? "#333" : "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
{loading ? "Logging in..." : "Log In"}
</button>
</form>
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
<p style={{ color: "#b3b3b3" }}>
Don't have an account?{" "}
<Link
to="/signup"
style={{ color: "#0066cc", textDecoration: "none" }}
>
Sign up here
</Link>
</p>
</div>
</div>
);
}

View File

@@ -1,188 +1,5 @@
/**
* Signup Page
*
* Allows new users to create an account with email and password
*/
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { Navigate } from "react-router-dom";
export default function Signup() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { signup } = useAuth();
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
// Validation
if (!email || !password || !confirmPassword) {
setError("Please fill in all fields");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
try {
setError("");
setLoading(true);
await signup(email, password);
navigate("/addresses"); // Auto-login and redirect
} catch (err) {
// Firebase-specific error messages
if (err.code === "auth/email-already-in-use") {
setError("Email already in use. Try logging in instead.");
} else if (err.code === "auth/invalid-email") {
setError("Invalid email address");
} else if (err.code === "auth/weak-password") {
setError("Password is too weak");
} else {
setError("Failed to create account: " + err.message);
}
} finally {
setLoading(false);
}
}
return (
<div
style={{
maxWidth: "400px",
margin: "4rem auto",
padding: "2rem",
border: "1px solid #333",
borderRadius: "8px",
}}
>
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
Koin Ping - Sign Up
</h1>
{error && (
<div
style={{
padding: "0.75rem",
marginBottom: "1rem",
backgroundColor: "#ff000020",
border: "1px solid #ff0000",
borderRadius: "4px",
color: "#ff6666",
}}
>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem" }}>
Confirm Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
style={{
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#242424",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
}}
required
/>
</div>
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem",
fontSize: "1rem",
backgroundColor: loading ? "#333" : "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
{loading ? "Creating account..." : "Sign Up"}
</button>
</form>
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
<p style={{ color: "#b3b3b3" }}>
Already have an account?{" "}
<Link
to="/login"
style={{ color: "#0066cc", textDecoration: "none" }}
>
Log in here
</Link>
</p>
</div>
</div>
);
return <Navigate to="/subscribe" replace />;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
.alerts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: start;
}
.alerts__address-info {
padding: 1rem;
margin-bottom: 2rem;
background-color: var(--color-bg-card);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.alerts__toggle-panel {
margin-bottom: 1.5rem;
padding: 1rem;
background-color: var(--color-bg-card);
border-radius: var(--radius-md);
}
.alerts__toggle-label {
display: flex;
align-items: center;
cursor: pointer;
}
.alerts__toggle-checkbox {
margin-right: 0.5rem;
width: 18px;
height: 18px;
}
.alerts__toggle-hint {
font-size: 0.978rem;
color: var(--color-text-dimmed);
margin-top: 0.5rem;
margin-left: 26px;
}
.alerts__rule {
padding: 1rem;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-card);
}
.alerts__rule--disabled {
opacity: 0.6;
}
.alerts__email-buttons {
display: flex;
gap: 0.5rem;
}
.alerts__save-test {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}

View File

@@ -0,0 +1,522 @@
import { useState, useEffect } from "react";
import AlertForm from "../../components/AlertForm";
import Button from "../../components/Button";
import Input from "../../components/Input";
import { getAddresses } from "../../api/addresses";
import {
getAlerts,
createAlert,
updateAlertStatus,
deleteAlert,
} from "../../api/alerts";
import {
getNotificationConfig,
updateNotificationConfig,
testNotificationChannels,
setupEmail,
sendEmailDigest,
} from "../../api/notificationConfig";
import "./Alerts.css";
export default function Alerts() {
const [addresses, setAddresses] = useState([]);
const [selectedAddressId, setSelectedAddressId] = useState(null);
const [alerts, setAlerts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [notificationEnabled, setNotificationEnabled] = useState(false);
const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
const [telegramBotToken, setTelegramBotToken] = useState("");
const [telegramChatId, setTelegramChatId] = useState("");
const [email, setEmail] = useState("");
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
const [notificationLoading, setNotificationLoading] = useState(false);
const [notificationError, setNotificationError] = useState(null);
const [notificationSuccess, setNotificationSuccess] = useState(null);
const [testingChannels, setTestingChannels] = useState(false);
const [settingUpEmail, setSettingUpEmail] = useState(false);
const [sendingDigest, setSendingDigest] = useState(false);
const [hasExistingConfig, setHasExistingConfig] = useState(false);
useEffect(() => {
async function fetchData() {
try {
setLoading(true);
const addressData = await getAddresses();
setAddresses(addressData);
if (addressData.length > 0) {
setSelectedAddressId(addressData[0].id);
}
const configData = await getNotificationConfig();
setNotificationEnabled(
configData.notification_enabled !== false,
);
setDiscordWebhookUrl(configData.discord_webhook_url || "");
setTelegramBotToken(configData.telegram_bot_token || "");
setTelegramChatId(configData.telegram_chat_id || "");
setEmail(configData.email || "");
setSlackWebhookUrl(configData.slack_webhook_url || "");
const hasSaved =
!!configData.discord_webhook_url ||
!!configData.telegram_bot_token ||
!!configData.telegram_chat_id ||
!!configData.email ||
!!configData.slack_webhook_url;
setHasExistingConfig(hasSaved);
} catch (err) {
setError(err.message);
console.error("Failed to fetch data:", err);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
useEffect(() => {
if (!selectedAddressId) {
setAlerts([]);
return;
}
async function fetchAlerts() {
try {
const data = await getAlerts(selectedAddressId);
setAlerts(data);
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to fetch alerts:", err);
}
}
fetchAlerts();
}, [selectedAddressId]);
async function handleAlertSubmit(data) {
if (!selectedAddressId) return;
try {
const newAlert = await createAlert(selectedAddressId, data);
setAlerts((prev) => [...prev, newAlert]);
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to create alert:", err);
}
}
async function handleToggleAlert(alertId, currentStatus) {
try {
const updated = await updateAlertStatus(alertId, !currentStatus);
setAlerts((prev) =>
prev.map((alert) => (alert.id === alertId ? updated : alert)),
);
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to update alert:", err);
}
}
async function handleDeleteAlert(alertId) {
try {
await deleteAlert(alertId);
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
setError(null);
} catch (err) {
setError(err.message);
console.error("Failed to delete alert:", err);
}
}
async function handleSaveNotificationConfig() {
if (hasExistingConfig) {
const confirmed = window.confirm(
"This will overwrite your previously saved notification settings. Continue?",
);
if (!confirmed) return;
}
try {
setNotificationLoading(true);
setNotificationError(null);
setNotificationSuccess(null);
const config = {
notification_enabled: notificationEnabled,
discord_webhook_url: discordWebhookUrl || null,
telegram_bot_token: telegramBotToken || null,
telegram_chat_id: telegramChatId || null,
email: email || null,
slack_webhook_url: slackWebhookUrl || null,
};
await updateNotificationConfig(config);
setHasExistingConfig(true);
setNotificationSuccess("Notification settings saved!");
setTimeout(() => setNotificationSuccess(null), 3000);
} catch (err) {
setNotificationError(err.message);
console.error("Failed to save notification config:", err);
} finally {
setNotificationLoading(false);
}
}
async function handleTestChannels() {
try {
setTestingChannels(true);
setNotificationError(null);
setNotificationSuccess(null);
const data = await testNotificationChannels();
const results = data.results || [];
const failed = results.filter((r) => !r.success);
const succeeded = results.filter((r) => r.success);
if (failed.length === 0 && succeeded.length > 0) {
setNotificationSuccess(
`Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
);
} else if (failed.length > 0 && succeeded.length > 0) {
setNotificationSuccess(
`Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
);
} else if (failed.length > 0) {
setNotificationError(
`Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
);
}
setTimeout(() => {
setNotificationSuccess(null);
setNotificationError(null);
}, 6000);
} catch (err) {
setNotificationError(err.message);
} finally {
setTestingChannels(false);
}
}
async function handleSetupEmail() {
try {
setSettingUpEmail(true);
setNotificationError(null);
setNotificationSuccess(null);
await handleSaveNotificationConfig();
const result = await setupEmail();
setNotificationSuccess(
result.message || "Confirmation email sent!",
);
setTimeout(() => setNotificationSuccess(null), 5000);
} catch (err) {
setNotificationError(err.message);
} finally {
setSettingUpEmail(false);
}
}
async function handleSendDigest() {
try {
setSendingDigest(true);
setNotificationError(null);
setNotificationSuccess(null);
const result = await sendEmailDigest();
setNotificationSuccess(result.message || "Digest email sent!");
setTimeout(() => setNotificationSuccess(null), 5000);
} catch (err) {
setNotificationError(err.message);
} finally {
setSendingDigest(false);
}
}
const selectedAddress = addresses.find((a) => a.id === selectedAddressId);
if (loading) {
return <div className="page">Loading addresses...</div>;
}
if (addresses.length === 0) {
return (
<div className="page">
<p>No addresses tracked yet. Add an address first to create alerts.</p>
</div>
);
}
return (
<div className="page page--wide">
<h1 className="mb-xl">Alert Rules & Notifications</h1>
<div className="alerts-grid">
{/* LEFT COLUMN: Alert Rules */}
<div>
<h2 className="mt-0">Alert Rules</h2>
<div className="mb-xl">
<label className="form-label mb-sm">
<strong>Select Address:</strong>
</label>
<select
value={selectedAddressId || ""}
onChange={(e) =>
setSelectedAddressId(Number(e.target.value))
}
className="form-select"
>
{addresses.map((addr) => (
<option key={addr.id} value={addr.id}>
{addr.label || "Unlabeled"} - {addr.address}
</option>
))}
</select>
</div>
{selectedAddress && (
<>
<div className="alerts__address-info">
<div className="text-sm text-muted">
Managing alerts for:
</div>
<div className="text-bold">
{selectedAddress.label || "Unlabeled"}
</div>
<div className="text-mono text-sm text-muted">
{selectedAddress.address}
</div>
</div>
<div className="mb-xl">
<h3>Create New Alert</h3>
<AlertForm onSubmit={handleAlertSubmit} />
</div>
<div>
<h3>Active Alert Rules</h3>
{error && (
<p className="text-error">Error: {error}</p>
)}
{alerts.length === 0 ? (
<p className="text-dimmed">
No alert rules defined yet. Create one above.
</p>
) : (
<ul className="list-unstyled">
{alerts.map((alert) => (
<li
key={alert.id}
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
>
<div className="flex flex--between">
<div>
<div className="text-bold mb-sm">
{formatAlertType(alert.type)}
</div>
{alert.threshold && (
<div className="text-sm text-muted">
Threshold: {alert.threshold} ETH
</div>
)}
<div className="text-xs text-dimmed">
Status: {alert.enabled ? "Enabled" : "Disabled"}
</div>
</div>
<div className="flex flex--center gap-sm">
<Button
onClick={() =>
handleToggleAlert(alert.id, alert.enabled)
}
>
{alert.enabled ? "Disable" : "Enable"}
</Button>
<Button
onClick={() => handleDeleteAlert(alert.id)}
>
Delete
</Button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</>
)}
</div>
{/* RIGHT COLUMN: Notification Settings */}
<div>
<h2 className="mt-0">Notification Settings</h2>
{notificationSuccess && (
<div className="alert alert--success">{notificationSuccess}</div>
)}
{notificationError && (
<div className="alert alert--error">{notificationError}</div>
)}
{/* Master toggle */}
<div className="alerts__toggle-panel">
<label className="alerts__toggle-label">
<input
type="checkbox"
checked={notificationEnabled}
onChange={(e) =>
setNotificationEnabled(e.target.checked)
}
className="alerts__toggle-checkbox"
/>
<span className="text-bold">Enable Notifications</span>
</label>
<div className="alerts__toggle-hint">
Master switch for all notification channels
</div>
</div>
{/* All channel settings -- hidden when master toggle is off */}
{notificationEnabled && (
<>
{/* Telegram */}
<div className="section">
<h3 className="mt-0 mb-md">Telegram</h3>
<Input
label="Bot Token"
value={telegramBotToken}
onChange={setTelegramBotToken}
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
/>
<Input
label="Chat ID"
value={telegramChatId}
onChange={setTelegramChatId}
placeholder="-1001234567890"
/>
<a
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
target="_blank"
rel="noopener noreferrer"
className="help-link"
>
How to create a Telegram bot & get your Chat ID
</a>
</div>
{/* Email */}
<div className="section">
<h3 className="mt-0 mb-md">Email</h3>
<Input
label="Email Address"
type="email"
value={email}
onChange={setEmail}
placeholder="you@example.com"
/>
<div className="help-link text-dimmed mb-md">
Alert notifications and digests will be sent to this address
</div>
<div className="alerts__email-buttons">
<Button
onClick={handleSetupEmail}
disabled={settingUpEmail || !email}
className="btn--sm"
>
{settingUpEmail ? "Setting up..." : "Verify Email"}
</Button>
<Button
onClick={handleSendDigest}
disabled={sendingDigest || !email}
variant="secondary"
className="btn--sm"
>
{sendingDigest ? "Sending..." : "Send Digest Now"}
</Button>
</div>
</div>
{/* Discord */}
<div className="section">
<h3 className="mt-0 mb-md">Discord</h3>
<Input
label="Discord Webhook URL"
value={discordWebhookUrl}
onChange={setDiscordWebhookUrl}
placeholder="https://discord.com/api/webhooks/..."
/>
<a
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
target="_blank"
rel="noopener noreferrer"
className="help-link"
>
How to get a Discord webhook URL
</a>
</div>
{/* Slack */}
<div className="section">
<h3 className="mt-0 mb-md">Slack</h3>
<Input
label="Slack Webhook URL"
value={slackWebhookUrl}
onChange={setSlackWebhookUrl}
placeholder="https://hooks.slack.com/services/..."
/>
<a
href="https://api.slack.com/messaging/webhooks"
target="_blank"
rel="noopener noreferrer"
className="help-link"
>
How to set up Slack Incoming Webhooks
</a>
</div>
{/* Save & Test buttons */}
<div className="alerts__save-test">
<Button
onClick={handleSaveNotificationConfig}
disabled={notificationLoading}
className="btn--lg"
>
{notificationLoading ? "Saving..." : "Save Settings"}
</Button>
<Button
onClick={handleTestChannels}
disabled={testingChannels}
variant="success"
className="btn--lg"
>
{testingChannels ? "Testing..." : "Test All Channels"}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
function formatAlertType(type) {
const labels = {
incoming_tx: "Incoming transaction",
outgoing_tx: "Outgoing transaction",
large_transfer: "Large transfer",
balance_below: "Balance below threshold",
};
return labels[type] || type;
}

View File

@@ -0,0 +1,64 @@
.login-page {
min-height: 100vh;
background-image: url(/ping.png);
background-size: 67%;
background-position: center;
background-repeat: no-repeat;
}
.login-card {
max-width: 400px;
margin: 20px auto;
padding: 2rem;
padding-top: 8rem;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
background-color: rgba(0, 0, 0, 0.75);
}
.login-heading {
margin-bottom: 2rem;
text-align: center;
position: relative;
top: -20px;
}
.login-brand {
color: var(--color-brand);
}
.login-button {
width: 100%;
padding: 0.75rem;
font-size: 1.2rem;
margin-top: 16px;
;
}
.login-footer {
margin-top: 1.5rem;
text-align: center;
}
.login-signup-link {
color: var(--color-primary);
text-decoration: none;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.login-form-hidden {
opacity: 0;
}
.login-form-visible {
animation: fadeIn 2s ease-in-out forwards;
}

View File

@@ -0,0 +1,83 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
import Input from "../../components/Input";
import "./Login.css";
export default function Login() {
const [isVisible, setIsVisible] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
if (!email || !password) {
setError("Please fill in all fields");
return;
}
try {
setError("");
setLoading(true);
await login(email, password);
navigate("/addresses");
} catch (err) {
setError("Failed to log in: " + err.message);
} finally {
setLoading(false);
}
}
return (
<div className="login-page">
<div className="login-card" onMouseEnter={() => setIsVisible(true)}>
<h1 className="login-heading">
<span className="login-brand">Koin Ping</span> - Login
</h1>
<div className={isVisible ? "login-form-visible" : "login-form-hidden"}>
{error && <div className="alert alert--error">{error}</div>}
<form onSubmit={handleSubmit}>
<Input
label="Email"
type="email"
value={email}
onChange={setEmail}
disabled={loading}
required
/>
<Input
label="Password"
type="password"
value={password}
onChange={setPassword}
disabled={loading}
required
className="form-field--last"
/>
<button type="submit" disabled={loading} className="btn btn--primary login-button">
{loading ? "Logging in..." : "Log In"}
</button>
</form>
<div className="login-footer">
<p className="text-muted">
Don't have an account?{" "}
<Link to="/signup" className="login-signup-link">
Sign up here
</Link>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,238 @@
.subscribe {
min-height: 100vh;
background-color: var(--color-bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding-top: 3rem;
padding-bottom: 3rem;
}
.subscribe__container {
width: 100%;
max-width: 540px;
padding: 0 1rem;
}
.subscribe__title {
text-align: center;
margin-bottom: 2rem;
color: var(--color-primary);
letter-spacing: 0.5px;
}
.subscribe__card {
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
padding: 2rem;
}
.subscribe__login-link {
text-align: center;
margin-top: 1.25rem;
color: #888;
font-size: 0.9rem;
}
.subscribe__login-link a {
color: var(--color-primary);
}
/* Progress bar */
.progress-bar {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
}
.progress-bar__step {
display: flex;
align-items: center;
}
.progress-bar__connector {
width: 40px;
height: 2px;
margin: 0 4px;
}
.progress-bar__connector--active {
background-color: var(--color-primary);
}
.progress-bar__connector--inactive {
background-color: var(--color-border);
}
.progress-bar__dot {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.85rem;
color: white;
margin: 0 auto 4px;
}
.progress-bar__dot--done,
.progress-bar__dot--active {
background-color: var(--color-primary);
}
.progress-bar__dot--active {
border: 2px solid #4499ff;
}
.progress-bar__dot--pending {
background-color: var(--color-bg-card);
border: 2px solid transparent;
}
.progress-bar__label {
font-size: 0.7rem;
white-space: nowrap;
}
.progress-bar__label--active {
color: white;
}
.progress-bar__label--inactive {
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 */
.subscribe__footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
padding-top: 1rem;
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 */
.subscribe__subtitle {
color: #aaa;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
/* Subscribe card (Step 2) */
.subscribe-card {
background-color: var(--color-bg-card, #1a1a2e);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
padding: 1.5rem;
text-align: center;
margin-bottom: 1rem;
}
.subscribe-card__price {
margin-bottom: 1.25rem;
}
.subscribe-card__amount {
font-size: 2.5rem;
font-weight: 700;
color: white;
}
.subscribe-card__period {
font-size: 1rem;
color: #888;
margin-left: 2px;
}
.subscribe-card__features {
list-style: none;
padding: 0;
margin: 0 0 1.25rem;
text-align: left;
}
.subscribe-card__features li {
padding: 0.35rem 0;
color: #ccc;
font-size: 0.9rem;
}
.subscribe-card__features li::before {
content: "\2713";
color: var(--color-primary);
margin-right: 0.6rem;
font-weight: bold;
}
.subscribe-card__commitment {
color: #999;
font-size: 0.8rem;
margin: 0;
font-style: italic;
}

View File

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

View File

@@ -1,24 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
]
}