Compare commits
12 Commits
account-in
...
add-alert-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb4dcd7d1 | ||
|
|
d3530deb46 | ||
|
|
aadcdc628e | ||
|
|
2d4fcefdcf | ||
|
|
81303573fd | ||
|
|
76fadf05d9 | ||
|
|
b9e3d8ae82 | ||
|
|
6c5ffc2340 | ||
|
|
e32aa4b6aa | ||
|
|
682c168250 | ||
|
|
e5c8f91e3f | ||
|
|
f6255ec1f1 |
@@ -1,9 +0,0 @@
|
||||
name: check
|
||||
on: [push]
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-22
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- run: docker build .
|
||||
3
LICENSE
3
LICENSE
@@ -2,8 +2,7 @@ MIT License
|
||||
|
||||
Copyright (c) 2026 Steven Jannette
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
|
||||
187
REPO_POLICIES.md
187
REPO_POLICIES.md
@@ -1,187 +0,0 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-02-22
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
style conventions are in separate documents:
|
||||
|
||||
- [Code Styleguide](<link>)
|
||||
(general, bash, Docker)
|
||||
- [Go](<link>)
|
||||
- [JavaScript](<link>)
|
||||
- [Python](<link>)
|
||||
- [Go HTTP Server Conventions](<link>)
|
||||
|
||||
---
|
||||
|
||||
- Cross-project documentation (such as this file) must include
|
||||
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
||||
with the authoritative source as policies evolve.
|
||||
|
||||
- **ALL external references must be pinned by cryptographic hash.** This
|
||||
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
||||
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
||||
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
||||
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
||||
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
||||
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
||||
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
||||
etc. Instead, download a specific release archive from GitHub, verify its hash
|
||||
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
||||
install scripts are arbitrary remote code execution. This is the single most
|
||||
important rule in this document. Double-check every external reference in
|
||||
every file before committing. There are zero exceptions to this rule.
|
||||
|
||||
- Every repo with software must have a root `Makefile` with these targets:
|
||||
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
||||
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
||||
`make hooks` (installs pre-commit hook). A model Makefile is at
|
||||
`(<link>)`.
|
||||
|
||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||
instead of invoking the underlying tools directly. The Makefile is the single
|
||||
source of truth for how these operations are run.
|
||||
|
||||
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
||||
the required targets above, it should have targets for every common operation:
|
||||
running a local development server (`make run`, `make dev`), re-initializing
|
||||
or migrating the database (`make db-reset`, `make migrate`), building
|
||||
artifacts (`make build`), generating code, seeding data, or anything else a
|
||||
developer would do regularly. If someone checks out the repo and types
|
||||
`make<tab>`, they should see every meaningful operation available. A new
|
||||
contributor should be able to understand the entire development workflow by
|
||||
reading the Makefile.
|
||||
|
||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||
as a build step so the build fails if the branch is not green. For non-server
|
||||
repos, the Dockerfile should bring up a development environment and run
|
||||
`make check`. For server repos, `make check` should run as an early build
|
||||
stage before the final image is assembled.
|
||||
|
||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
|
||||
a successful build implies all checks pass.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||
|
||||
- Pre-commit hook: `make check` if local testing is possible, otherwise
|
||||
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
|
||||
target to install the pre-commit hook.
|
||||
|
||||
- All repos with software must have tests that run via the platform-standard
|
||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||
tests exist yet, add the most minimal test possible — e.g. importing the
|
||||
module under test to verify it compiles/parses. There is no excuse for
|
||||
`make test` to be a no-op.
|
||||
|
||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||
Makefile.
|
||||
|
||||
- Docker builds must complete in under 5 minutes.
|
||||
|
||||
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||
directories.
|
||||
|
||||
- `main` must always pass `make check`, no exceptions.
|
||||
|
||||
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||
must be in `.gitignore`. No exceptions.
|
||||
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||
Fetch the standard `.gitignore` from
|
||||
`(<link>) when setting up
|
||||
a new repo.
|
||||
|
||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||
|
||||
- Never force-push to `main`.
|
||||
|
||||
- Make all changes on a feature branch. You can do whatever you want on a
|
||||
feature branch.
|
||||
|
||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||
manually by the user. Fetch from
|
||||
`(<link>)`.
|
||||
|
||||
- When pinning images or packages by hash, add a comment above the reference
|
||||
with the version and date (YYYY-MM-DD).
|
||||
|
||||
- Use `yarn`, not `npm`.
|
||||
|
||||
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||
|
||||
- Simple projects should be configured with environment variables.
|
||||
|
||||
- Dockerized web services listen on port 8080 by default, overridable with
|
||||
`PORT`.
|
||||
|
||||
- `README.md` is the primary documentation. Required sections:
|
||||
- **Description**: First line must include the project name, purpose,
|
||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||
"µPaaS is an MIT-licensed Go web application by @sjdev that receives
|
||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||
- **Rationale**: Why does this exist?
|
||||
- **Design**: How is the program structured?
|
||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||
the todo list in the README so a new agent can pick up where the last one
|
||||
left off.
|
||||
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||
`LICENSE` file in the repo root and a License section in the README.
|
||||
- **Author**: [@sjdev](https://sjdev.co).
|
||||
|
||||
- First commit of a new repo should contain only `README.md`.
|
||||
|
||||
- Always run `go mod tidy` before committing.
|
||||
|
||||
- Use SemVer.
|
||||
|
||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||
the binary.
|
||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||
tracking table itself. Nothing else.
|
||||
- `001_schema.sql` — the full application schema.
|
||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||
Never edit existing migrations after release.
|
||||
|
||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||
subdirectory names:
|
||||
- `bin/` — executable scripts and tools
|
||||
- `cmd/` — Go command entrypoints
|
||||
- `configs/` — configuration templates and examples
|
||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||
- `docs/` — documentation and markdown (README.md stays in root)
|
||||
- `internal/` — Go internal packages
|
||||
- `internal/db/migrations/` — database migrations
|
||||
- `pkg/` — Go library packages
|
||||
- `share/` — systemd units, data files
|
||||
- `static/` — static assets (images, fonts, etc.)
|
||||
- `web/` — web frontend source
|
||||
|
||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||
templates. Fetch them from
|
||||
`(<link>)<path>`.
|
||||
|
||||
- New repos must contain at minimum:
|
||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||
- `Makefile`
|
||||
- `Dockerfile`, `.dockerignore`
|
||||
- `.gitea/workflows/check.yml`
|
||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||
- Python: `pyproject.toml`
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE alert_rules ADD COLUMN minimum DECIMAL(20, 6);
|
||||
ALTER TABLE alert_rules ADD COLUMN maximum DECIMAL(20, 6);
|
||||
|
||||
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0);
|
||||
ALTER TABLE alert_rules ADD CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0);
|
||||
ALTER TABLE alert_rules ADD CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum);
|
||||
@@ -35,6 +35,8 @@ CREATE TABLE alert_rules (
|
||||
address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE,
|
||||
type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below'
|
||||
threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it)
|
||||
minimum DECIMAL(20, 6), -- Optional min amount filter for incoming/outgoing alerts
|
||||
maximum DECIMAL(20, 6), -- Optional max amount filter for incoming/outgoing alerts
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
@@ -44,7 +46,11 @@ CREATE TABLE alert_rules (
|
||||
|
||||
CONSTRAINT positive_threshold CHECK (
|
||||
threshold IS NULL OR threshold > 0
|
||||
)
|
||||
),
|
||||
|
||||
CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0),
|
||||
CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0),
|
||||
CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum)
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,8 @@ type AlertRule struct {
|
||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||
Type AlertType `json:"type"`
|
||||
Threshold *float64 `json:"threshold"`
|
||||
Minimum *float64 `json:"minimum"`
|
||||
Maximum *float64 `json:"maximum"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Type string `json:"type"`
|
||||
Threshold json.RawMessage `json:"threshold"`
|
||||
Minimum json.RawMessage `json:"minimum"`
|
||||
Maximum json.RawMessage `json:"maximum"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
log.Printf("Failed to decode alert request body: %v", err)
|
||||
@@ -53,6 +55,40 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
minimum, err := parseThreshold(body.Minimum)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse minimum: %v", err)
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
maximum, err := parseThreshold(body.Maximum)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse maximum: %v", err)
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if minimum != nil && *minimum < 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if maximum != nil && *maximum < 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if minimum != nil && maximum != nil && *minimum > *maximum {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID)
|
||||
|
||||
if body.Type == "" {
|
||||
@@ -96,7 +132,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold)
|
||||
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold, minimum, maximum)
|
||||
if err != nil {
|
||||
log.Printf("Error creating alert rule: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
|
||||
@@ -182,7 +218,7 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, alerts)
|
||||
}
|
||||
|
||||
// UpdateStatus handles PATCH requests to enable or disable an alert rule.
|
||||
// UpdateStatus handles PATCH requests to enable/disable an alert rule and/or update min/max thresholds.
|
||||
func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
alertID, ok := parseIntParam(r.PathValue("alertId"))
|
||||
@@ -193,7 +229,10 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Minimum json.RawMessage `json:"minimum"`
|
||||
Maximum json.RawMessage `json:"maximum"`
|
||||
UpdateMinMax bool `json:"update_min_max"` //nolint:tagliatelle
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
log.Printf("Failed to decode update request body: %v", err)
|
||||
@@ -204,8 +243,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
log.Printf("User %s updating alert ID: %d", userID, alertID)
|
||||
|
||||
if body.Enabled == nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value")
|
||||
if body.Enabled == nil && !body.UpdateMinMax {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "must provide enabled (boolean) or update_min_max with minimum/maximum values")
|
||||
|
||||
return
|
||||
}
|
||||
@@ -224,15 +263,64 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
|
||||
if err != nil {
|
||||
log.Printf("Error updating alert: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||
var updated *domain.AlertRule
|
||||
|
||||
return
|
||||
if body.UpdateMinMax {
|
||||
minimum, parseErr := parseThreshold(body.Minimum)
|
||||
if parseErr != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
maximum, parseErr := parseThreshold(body.Maximum)
|
||||
if parseErr != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if minimum != nil && *minimum < 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if maximum != nil && *maximum < 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if minimum != nil && maximum != nil && *minimum > *maximum {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
updated, err = h.alertRules.UpdateThresholds(r.Context(), alertID, minimum, maximum)
|
||||
if err != nil {
|
||||
log.Printf("Error updating alert thresholds: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Alert %d thresholds updated: min=%v, max=%v", alertID, minimum, maximum)
|
||||
}
|
||||
|
||||
if body.Enabled != nil {
|
||||
updated, err = h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled)
|
||||
if err != nil {
|
||||
log.Printf("Error updating alert: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
|
||||
}
|
||||
|
||||
log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled)
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,14 +17,14 @@ func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel {
|
||||
return &AlertRuleModel{pool: pool}
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold *float64) (*domain.AlertRule, error) {
|
||||
func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold, minimum, maximum *float64) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO alert_rules (address_id, type, threshold, enabled)
|
||||
VALUES ($1, $2, $3, TRUE)
|
||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
||||
addressID, alertType.String(), threshold,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
`INSERT INTO alert_rules (address_id, type, threshold, minimum, maximum, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, TRUE)
|
||||
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||
addressID, alertType.String(), threshold, minimum, maximum,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType do
|
||||
|
||||
func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) {
|
||||
rows, err := m.pool.Query(ctx,
|
||||
`SELECT id, address_id, type, threshold, enabled, created_at
|
||||
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
|
||||
FROM alert_rules
|
||||
WHERE address_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
@@ -47,7 +47,7 @@ func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]do
|
||||
var rules []domain.AlertRule
|
||||
for rows.Next() {
|
||||
var r domain.AlertRule
|
||||
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, r)
|
||||
@@ -61,19 +61,19 @@ func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (
|
||||
|
||||
if userID != nil {
|
||||
err = m.pool.QueryRow(ctx,
|
||||
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.enabled, ar.created_at
|
||||
`SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.minimum, ar.maximum, ar.enabled, ar.created_at
|
||||
FROM alert_rules ar
|
||||
JOIN addresses a ON a.id = ar.address_id
|
||||
WHERE ar.id = $1 AND a.user_id = $2`,
|
||||
id, *userID,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
} else {
|
||||
err = m.pool.QueryRow(ctx,
|
||||
`SELECT id, address_id, type, threshold, enabled, created_at
|
||||
`SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at
|
||||
FROM alert_rules
|
||||
WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -91,9 +91,27 @@ func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool
|
||||
`UPDATE alert_rules
|
||||
SET enabled = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
||||
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||
id, enabled,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *AlertRuleModel) UpdateThresholds(ctx context.Context, id int, minimum, maximum *float64) (*domain.AlertRule, error) {
|
||||
var r domain.AlertRule
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`UPDATE alert_rules
|
||||
SET minimum = $2, maximum = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`,
|
||||
id, minimum, maximum,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
|
||||
@@ -104,9 +104,9 @@ func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.O
|
||||
func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||
switch rule.Type {
|
||||
case domain.AlertIncomingTx:
|
||||
return obs.Direction == domain.DirectionIncoming, nil
|
||||
return s.matchesDirectionalTx(rule, obs, domain.DirectionIncoming)
|
||||
case domain.AlertOutgoingTx:
|
||||
return obs.Direction == domain.DirectionOutgoing, nil
|
||||
return s.matchesDirectionalTx(rule, obs, domain.DirectionOutgoing)
|
||||
case domain.AlertLargeTransfer:
|
||||
return s.matchesLargeTransfer(rule, obs)
|
||||
case domain.AlertBalanceBelow:
|
||||
@@ -117,6 +117,42 @@ func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRul
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) matchesDirectionalTx(rule domain.AlertRule, obs domain.ObservedTx, expected domain.Direction) (bool, error) {
|
||||
if obs.Direction != expected {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if rule.Minimum != nil {
|
||||
minWei, err := wei.FromEth(*rule.Minimum)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
aboveMin, err := wei.GreaterThanOrEqual(obs.Value, minWei)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !aboveMin {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
if rule.Maximum != nil {
|
||||
maxWei, err := wei.FromEth(*rule.Maximum)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
belowMax, err := wei.LessThanOrEqual(obs.Value, maxWei)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !belowMax {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) {
|
||||
if rule.Threshold == nil {
|
||||
return false, nil
|
||||
|
||||
@@ -90,6 +90,14 @@ func LessThan(weiA, weiB string) (bool, error) {
|
||||
return cmp < 0, nil
|
||||
}
|
||||
|
||||
func LessThanOrEqual(weiA, weiB string) (bool, error) {
|
||||
cmp, err := Compare(weiA, weiB)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return cmp <= 0, nil
|
||||
}
|
||||
|
||||
// FormatAsEth formats a Wei string as "X.XXXX ETH".
|
||||
func FormatAsEth(weiString string, decimals int) (string, error) {
|
||||
eth, err := ToEth(weiString)
|
||||
|
||||
BIN
frontend/public/koin_exp_2.mp4
Normal file
BIN
frontend/public/koin_exp_2.mp4
Normal file
Binary file not shown.
@@ -3,7 +3,7 @@ import { useAuth } from "./contexts/AuthContext";
|
||||
import Navbar from "./components/Navbar";
|
||||
import Login from "./pages/login/Login";
|
||||
import Signup from "./pages/Signup";
|
||||
import Subscribe from "./pages/subscribe/subscribe";
|
||||
import Subscribe from "./pages/subscribe/Subscribe";
|
||||
import Addresses from "./pages/addresses/Addresses";
|
||||
import Alerts from "./pages/alerts/Alerts";
|
||||
import AlertHistory from "./pages/alertHistory/AlertHistory";
|
||||
|
||||
@@ -129,6 +129,48 @@ export async function updateAlertStatus(alertId, enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the min/max thresholds on an alert rule
|
||||
* @param {number} alertId - Alert rule ID
|
||||
* @param {number|null} minimum - Minimum amount (null to clear)
|
||||
* @param {number|null} maximum - Maximum amount (null to clear)
|
||||
* @returns {Promise<Object>} Updated alert rule
|
||||
*/
|
||||
export async function updateAlertThresholds(alertId, minimum, maximum) {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/alerts/${alertId}`, {
|
||||
method: "PATCH",
|
||||
headers: headers,
|
||||
body: JSON.stringify({
|
||||
update_min_max: true,
|
||||
minimum: minimum ?? null,
|
||||
maximum: maximum ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to update alert thresholds";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error(
|
||||
"Cannot connect to server. Is the backend running?",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an alert rule
|
||||
* @param {number} alertId - Alert rule ID
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
/* ── Left Group: Brand + Links ───────────────────────────── */
|
||||
|
||||
.navbar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar__brand-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar__brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
@@ -38,6 +51,14 @@
|
||||
background-color: var(--color-bg-card);
|
||||
}
|
||||
|
||||
/* ── Right Group: Email + Logout + Hamburger ─────────────── */
|
||||
|
||||
.navbar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.navbar__user {
|
||||
font-size: 1.275rem;
|
||||
color: #777;
|
||||
@@ -53,6 +74,10 @@
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.navbar__user-link--mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar__logout {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 1.275rem;
|
||||
@@ -64,3 +89,79 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
/* ── Hamburger Icon (hidden on desktop) ──────────────────── */
|
||||
|
||||
.navbar__hamburger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.navbar__hamburger-bar {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 3px;
|
||||
background-color: var(--color-brand);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Responsive: Tablet & below (≤ 768px) ────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.navbar__brand-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.navbar__brand {
|
||||
margin-right: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.navbar__links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar__user-link--mobile {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.navbar__user-link--desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar__logout {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar__hamburger {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Responsive: Mobile (≤ 480px) ────────────────────────── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.navbar {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.navbar__brand {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.navbar__user-link--mobile {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import NavPanel from "./navPanel/NavPanel";
|
||||
import "./Navbar.css";
|
||||
|
||||
const navLinks = [
|
||||
@@ -11,35 +13,59 @@ const navLinks = [
|
||||
export default function Navbar() {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="flex flex--center gap-sm">
|
||||
<span className="navbar__brand">Koin Ping</span>
|
||||
<div className="navbar__links">
|
||||
{navLinks.map(({ to, label }) => {
|
||||
const isActive = location.pathname === to;
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<>
|
||||
<nav className="navbar">
|
||||
<div className="navbar__left">
|
||||
<div className="navbar__brand-group">
|
||||
<span className="navbar__brand">Koin Ping</span>
|
||||
<Link to="/account" className="navbar__user-link navbar__user-link--mobile">
|
||||
{currentUser.email}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="navbar__links">
|
||||
{navLinks.map(({ to, label }) => {
|
||||
const isActive = location.pathname === to;
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex--center gap-lg">
|
||||
<Link to="/account" className="navbar__user navbar__user-link">{currentUser.email}</Link>
|
||||
<button onClick={logout} className="navbar__logout">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="navbar__right">
|
||||
<Link to="/account" className="navbar__user navbar__user-link navbar__user-link--desktop">
|
||||
{currentUser.email}
|
||||
</Link>
|
||||
<button onClick={logout} className="navbar__logout">
|
||||
Logout
|
||||
</button>
|
||||
<button
|
||||
className="navbar__hamburger"
|
||||
onClick={() => setIsNavPanelOpen(true)}
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<span className="navbar__hamburger-bar" />
|
||||
<span className="navbar__hamburger-bar" />
|
||||
<span className="navbar__hamburger-bar" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<NavPanel
|
||||
isOpen={isNavPanelOpen}
|
||||
onClose={() => setIsNavPanelOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
78
frontend/src/components/navPanel/NavPanel.css
Normal file
78
frontend/src/components/navPanel/NavPanel.css
Normal file
@@ -0,0 +1,78 @@
|
||||
/* ── Overlay Backdrop ─────────────────────────────────────── */
|
||||
|
||||
.nav-panel__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
.nav-panel__overlay--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ── Slide-out Panel ─────────────────────────────────────── */
|
||||
|
||||
.nav-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-right: 1px solid var(--color-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 950;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.nav-panel--open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* ── Nav Links ───────────────────────────────────────────── */
|
||||
|
||||
.nav-panel__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-panel__link {
|
||||
padding: 0.9rem 1.5rem;
|
||||
text-decoration: none;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 200;
|
||||
color: var(--color-text-subtle);
|
||||
border-left: 3px solid transparent;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-panel__link--active {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg-card);
|
||||
border-left-color: var(--color-brand);
|
||||
}
|
||||
|
||||
/* ── Logout Button ───────────────────────────────────────── */
|
||||
|
||||
.nav-panel__logout {
|
||||
margin: 1rem 1.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 400;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
52
frontend/src/components/navPanel/NavPanel.jsx
Normal file
52
frontend/src/components/navPanel/NavPanel.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import "./NavPanel.css";
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/addresses", label: "Addresses" },
|
||||
{ to: "/alerts", label: "Configure Alerts" },
|
||||
{ to: "/alertevents", label: "Alert Event History" },
|
||||
];
|
||||
|
||||
export default function NavPanel({ isOpen, onClose }) {
|
||||
const { logout } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
const handleNavClick = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
onClose();
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`nav-panel__overlay${isOpen ? " nav-panel__overlay--visible" : ""}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside className={`nav-panel${isOpen ? " nav-panel--open" : ""}`}>
|
||||
<nav className="nav-panel__nav">
|
||||
{navLinks.map(({ to, label }) => {
|
||||
const isActive = location.pathname === to;
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={`nav-panel__link${isActive ? " nav-panel__link--active" : ""}`}
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<button onClick={handleLogout} className="nav-panel__logout">
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -355,4 +355,74 @@ button {
|
||||
.text-white-sm {
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Responsive Breakpoints ──────────────────────────────── */
|
||||
/* Mobile: <= 480px */
|
||||
/* Tablet: 481px – 768px */
|
||||
/* Laptop: 769px – 1024px */
|
||||
/* Desktop: >= 1025px (default styles above) */
|
||||
|
||||
/* Laptop & below */
|
||||
@media (max-width: 1024px) {
|
||||
.page--wide {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet & below */
|
||||
@media (max-width: 768px) {
|
||||
html {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.btn--lg {
|
||||
padding: 0.6rem 1.2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 480px) {
|
||||
html {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn--lg {
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -28,3 +28,13 @@
|
||||
margin-left: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Addresses Responsive ────────────────────────────────── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.address__remove {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,69 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.alerts__accordion-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.alerts__accordion-toggle:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.alerts__accordion-chevron {
|
||||
display: inline-block;
|
||||
transition: transform 0.2s ease;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.alerts__accordion-chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.alerts__accordion-panel {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transition: max-height 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.alerts__accordion-panel--open {
|
||||
max-height: 200px;
|
||||
opacity: 1;
|
||||
padding: 0.75rem 0.25rem 0.25rem;
|
||||
}
|
||||
|
||||
.alerts__threshold-inputs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.alerts__threshold-inputs .form-field {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.alerts__threshold-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.alerts__email-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -61,3 +124,54 @@
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* ── Alerts Page Responsive ──────────────────────────────── */
|
||||
|
||||
/* Laptop: tighten the gap */
|
||||
@media (max-width: 1024px) {
|
||||
.alerts-grid {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet & below: collapse to single column */
|
||||
@media (max-width: 768px) {
|
||||
.alerts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: compact spacing */
|
||||
@media (max-width: 480px) {
|
||||
.alerts-grid {
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.alerts__address-info {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.alerts__rule {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.alerts__rule .flex--between {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.alerts__save-test {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.alerts__save-test .btn--lg {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alerts__email-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getAlerts,
|
||||
createAlert,
|
||||
updateAlertStatus,
|
||||
updateAlertThresholds,
|
||||
deleteAlert,
|
||||
} from "../../api/alerts";
|
||||
import {
|
||||
@@ -39,6 +40,64 @@ export default function Alerts() {
|
||||
const [sendingDigest, setSendingDigest] = useState(false);
|
||||
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
||||
|
||||
const [openAccordions, setOpenAccordions] = useState({});
|
||||
const [thresholdEdits, setThresholdEdits] = useState({});
|
||||
|
||||
function toggleAccordion(alertId) {
|
||||
setOpenAccordions((prev) => ({ ...prev, [alertId]: !prev[alertId] }));
|
||||
}
|
||||
|
||||
function getThresholdEdit(alert) {
|
||||
if (thresholdEdits[alert.id]) return thresholdEdits[alert.id];
|
||||
return {
|
||||
minimum: alert.minimum != null ? String(alert.minimum) : "",
|
||||
maximum: alert.maximum != null ? String(alert.maximum) : "",
|
||||
};
|
||||
}
|
||||
|
||||
function setThresholdEdit(alertId, field, value) {
|
||||
setThresholdEdits((prev) => {
|
||||
const current = prev[alertId] || getThresholdEditForAlert(alertId);
|
||||
return { ...prev, [alertId]: { ...current, [field]: value } };
|
||||
});
|
||||
}
|
||||
|
||||
function getThresholdEditForAlert(alertId) {
|
||||
const alert = alerts.find((a) => a.id === alertId);
|
||||
return {
|
||||
minimum: alert?.minimum != null ? String(alert.minimum) : "",
|
||||
maximum: alert?.maximum != null ? String(alert.maximum) : "",
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSaveThresholds(alertId) {
|
||||
const edit = thresholdEdits[alertId];
|
||||
if (!edit) return;
|
||||
|
||||
const min = edit.minimum.trim() === "" ? null : Number(edit.minimum);
|
||||
const max = edit.maximum.trim() === "" ? null : Number(edit.maximum);
|
||||
|
||||
if (min !== null && isNaN(min)) return;
|
||||
if (max !== null && isNaN(max)) return;
|
||||
if (min !== null && max !== null && min > max) return;
|
||||
|
||||
try {
|
||||
const updated = await updateAlertThresholds(alertId, min, max);
|
||||
setAlerts((prev) =>
|
||||
prev.map((a) => (a.id === alertId ? updated : a)),
|
||||
);
|
||||
setThresholdEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[alertId];
|
||||
return next;
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to update thresholds:", err);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
@@ -314,42 +373,132 @@ export default function Alerts() {
|
||||
</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
|
||||
{alerts.map((alert) => {
|
||||
const hasAccordion =
|
||||
alert.type === "incoming_tx" ||
|
||||
alert.type === "outgoing_tx";
|
||||
const isOpen = !!openAccordions[alert.id];
|
||||
const edit = getThresholdEdit(alert);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={alert.id}
|
||||
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
||||
>
|
||||
<div className="flex flex--between">
|
||||
<div>
|
||||
<div className="text-bold mb-sm">
|
||||
{formatAlertType(alert.type)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-dimmed">
|
||||
Status: {alert.enabled ? "Enabled" : "Disabled"}
|
||||
{alert.threshold && (
|
||||
<div className="text-sm text-muted">
|
||||
Threshold: {alert.threshold} ETH
|
||||
</div>
|
||||
)}
|
||||
{alert.minimum != null && (
|
||||
<div className="text-sm text-muted">
|
||||
Min: {alert.minimum} ETH
|
||||
</div>
|
||||
)}
|
||||
{alert.maximum != null && (
|
||||
<div className="text-sm text-muted">
|
||||
Max: {alert.maximum} ETH
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-dimmed">
|
||||
Status:{" "}
|
||||
{alert.enabled ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex--center gap-sm">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleToggleAlert(
|
||||
alert.id,
|
||||
alert.enabled,
|
||||
)
|
||||
}
|
||||
>
|
||||
{alert.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleDeleteAlert(alert.id)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
|
||||
{hasAccordion && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="alerts__accordion-toggle"
|
||||
onClick={() =>
|
||||
toggleAccordion(alert.id)
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`alerts__accordion-chevron ${isOpen ? "alerts__accordion-chevron--open" : ""}`}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
Add optional minimum and maximum
|
||||
threshold values
|
||||
</button>
|
||||
<div
|
||||
className={`alerts__accordion-panel ${isOpen ? "alerts__accordion-panel--open" : ""}`}
|
||||
>
|
||||
<div className="alerts__threshold-inputs">
|
||||
<Input
|
||||
label="Minimum"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
value={edit.minimum}
|
||||
onChange={(v) =>
|
||||
setThresholdEdit(
|
||||
alert.id,
|
||||
"minimum",
|
||||
v,
|
||||
)
|
||||
}
|
||||
placeholder="No minimum"
|
||||
/>
|
||||
<Input
|
||||
label="Maximum"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
min="0"
|
||||
value={edit.maximum}
|
||||
onChange={(v) =>
|
||||
setThresholdEdit(
|
||||
alert.id,
|
||||
"maximum",
|
||||
v,
|
||||
)
|
||||
}
|
||||
placeholder="No maximum"
|
||||
/>
|
||||
</div>
|
||||
<div className="alerts__threshold-actions">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleSaveThresholds(alert.id)
|
||||
}
|
||||
className="btn--sm"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,10 +89,29 @@
|
||||
color: #FFFFFF;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-interactive-fadein {
|
||||
opacity: 0;
|
||||
animation: fadeIn 2s ease-in-out forwards;
|
||||
animation-delay: 6s;
|
||||
}
|
||||
|
||||
/* ── Login Responsive ────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
max-width: 100%;
|
||||
margin: 0 0.75rem;
|
||||
padding: 1.5rem;
|
||||
padding-top: 6rem;
|
||||
}
|
||||
|
||||
.login-tagline {
|
||||
margin-top: 3rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export default function Login() {
|
||||
playsInline
|
||||
className="login-bg-video"
|
||||
>
|
||||
<source src="/koin_spin.mp4" type="video/mp4" />
|
||||
<source src="/koin_exp_2.mp4" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
<div className="login-card login-card-fadein">
|
||||
|
||||
@@ -235,4 +235,26 @@
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Subscribe Responsive ────────────────────────────────── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.subscribe {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.subscribe__card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.progress-bar__connector {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.progress-bar__dot {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,3 @@
|
||||
/**
|
||||
* Subscribe / Subscribe Wizard
|
||||
*
|
||||
* 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
|
||||
* After account creation, user is redirected to Stripe Checkout for payment.
|
||||
* On successful payment, they return here at step 2 (Add Wallet).
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
Reference in New Issue
Block a user