Merge pull request #14 from kjannette/feat-discrete-accounts

Feat discrete accounts
This commit is contained in:
S Jannette
2026-03-03 19:14:00 -05:00
committed by GitHub
9 changed files with 250 additions and 65 deletions

View File

@@ -13,3 +13,9 @@ Terminal 1 (API server):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api
Terminal 2 (Poller): Terminal 2 (Poller):
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo 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.

View File

@@ -44,6 +44,7 @@ func main() {
defer database.Close() defer database.Close()
userModel := models.NewUserModel(pool)
addressModel := models.NewAddressModel(pool) addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool) alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool) alertEventModel := models.NewAlertEventModel(pool)
@@ -61,6 +62,8 @@ func main() {
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel) emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel) statusHandler := handlers.NewStatusHandler(checkpointModel)
authenticate := middleware.Authenticate(userModel)
mux := http.NewServeMux() mux := http.NewServeMux()
b := cfg.APIBasePath // e.g. "/v1" b := cfg.APIBasePath // e.g. "/v1"
@@ -70,43 +73,43 @@ func main() {
// Authenticated routes — addresses // Authenticated routes — addresses
mux.Handle("POST "+b+"/addresses", mux.Handle("POST "+b+"/addresses",
middleware.Authenticate(http.HandlerFunc(addressHandler.Create))) authenticate(http.HandlerFunc(addressHandler.Create)))
mux.Handle("GET "+b+"/addresses", mux.Handle("GET "+b+"/addresses",
middleware.Authenticate(http.HandlerFunc(addressHandler.List))) authenticate(http.HandlerFunc(addressHandler.List)))
mux.Handle("DELETE "+b+"/addresses/{addressId}", mux.Handle("DELETE "+b+"/addresses/{addressId}",
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove))) authenticate(http.HandlerFunc(addressHandler.Remove)))
mux.Handle("PATCH "+b+"/addresses/{addressId}", mux.Handle("PATCH "+b+"/addresses/{addressId}",
middleware.Authenticate(http.HandlerFunc(addressHandler.UpdateLabel))) authenticate(http.HandlerFunc(addressHandler.UpdateLabel)))
// Authenticated routes for alert rules // Authenticated routes for alert rules
mux.Handle("POST "+b+"/addresses/{addressId}/alerts", mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create))) authenticate(http.HandlerFunc(alertRuleHandler.Create)))
mux.Handle("GET "+b+"/addresses/{addressId}/alerts", mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
mux.Handle("PATCH "+b+"/alerts/{alertId}", mux.Handle("PATCH "+b+"/alerts/{alertId}",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
mux.Handle("DELETE "+b+"/alerts/{alertId}", mux.Handle("DELETE "+b+"/alerts/{alertId}",
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove))) authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
// Authenticated routes — alert events // Authenticated routes — alert events
mux.Handle("GET "+b+"/alert-events", mux.Handle("GET "+b+"/alert-events",
middleware.Authenticate(http.HandlerFunc(alertEventHandler.List))) authenticate(http.HandlerFunc(alertEventHandler.List)))
// Authenticated routes — notification config // Authenticated routes — notification config
mux.Handle("GET "+b+"/notification-config", mux.Handle("GET "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
mux.Handle("PUT "+b+"/notification-config", mux.Handle("PUT "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
mux.Handle("DELETE "+b+"/notification-config", mux.Handle("DELETE "+b+"/notification-config",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
mux.Handle("POST "+b+"/notification-config/test", mux.Handle("POST "+b+"/notification-config/test",
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestChannels))) authenticate(http.HandlerFunc(notifConfigHandler.TestChannels)))
// Authenticated routes — email digest // Authenticated routes — email digest
mux.Handle("POST "+b+"/email/setup", mux.Handle("POST "+b+"/email/setup",
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail))) authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail)))
mux.Handle("POST "+b+"/email/digest", mux.Handle("POST "+b+"/email/digest",
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SendDigest))) authenticate(http.HandlerFunc(emailDigestHandler.SendDigest)))
handler := corsMiddleware(mux) handler := corsMiddleware(mux)

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

@@ -4,15 +4,24 @@ DROP TABLE IF EXISTS alert_rules CASCADE;
DROP TABLE IF EXISTS address_checkpoints CASCADE; DROP TABLE IF EXISTS address_checkpoints CASCADE;
DROP TABLE IF EXISTS user_notification_configs CASCADE; DROP TABLE IF EXISTS user_notification_configs CASCADE;
DROP TABLE IF EXISTS addresses 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),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE addresses ( CREATE TABLE addresses (
id SERIAL PRIMARY KEY, 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) address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars)
label VARCHAR(255), -- Optional human-readable label label VARCHAR(255), -- Optional human-readable label
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
-- Unique users can track the same address independently
UNIQUE(user_id, address) UNIQUE(user_id, address)
); );
@@ -51,7 +60,7 @@ CREATE TABLE address_checkpoints (
); );
CREATE TABLE user_notification_configs ( 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) discord_webhook_url TEXT, -- Discord webhook URL (nullable)
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable) telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable) telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
@@ -63,6 +72,7 @@ CREATE TABLE user_notification_configs (
); );
-- Create indexes for common queries -- Create indexes for common queries
CREATE INDEX idx_users_firebase_uid ON users(firebase_uid);
CREATE INDEX idx_addresses_user_id ON addresses(user_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_address_id ON alert_rules(address_id);
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled); CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);

View File

@@ -2,6 +2,15 @@ package domain
import "time" import "time"
type User struct {
ID string `json:"id"`
FirebaseUID string `json:"-"`
Email string `json:"email"`
DisplayName *string `json:"display_name"` //nolint:tagliatelle
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
}
type Address struct { type Address struct {
ID int `json:"id"` ID int `json:"id"`
UserID string `json:"user_id"` //nolint:tagliatelle UserID string `json:"user_id"` //nolint:tagliatelle

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase" fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
"github.com/kjannette/koin-ping/backend-go/internal/models"
) )
type contextKey string type contextKey string
@@ -26,10 +27,14 @@ type errorResponse struct {
func writeJSON(w http.ResponseWriter, status int, v interface{}) { func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
json.NewEncoder(w).Encode(v) json.NewEncoder(w).Encode(v) //nolint:errcheck
} }
func Authenticate(next http.Handler) http.Handler { // 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
@@ -72,16 +77,27 @@ func Authenticate(next http.Handler) http.Handler {
return return
} }
userID := decoded.UID firebaseUID := decoded.UID
email, _ := decoded.Claims["email"].(string) email, _ := decoded.Claims["email"].(string)
log.Printf("Token verified! User ID: %s, Email: %s", userID, email) 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
}
ctx := context.WithValue(r.Context(), UserIDKey, userID) 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) ctx = context.WithValue(ctx, UserEmailKey, email)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
}
} }
func GetUserID(ctx context.Context) string { func GetUserID(ctx context.Context) string {

View File

@@ -0,0 +1,53 @@
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"
)
type UserModel struct {
pool *pgxpool.Pool
}
func NewUserModel(pool *pgxpool.Pool) *UserModel {
return &UserModel{pool: pool}
}
// 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) {
var u domain.User
err := m.pool.QueryRow(ctx,
`INSERT INTO users (firebase_uid, email)
VALUES ($1, $2)
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
RETURNING id, firebase_uid, email, display_name, created_at, updated_at`,
firebaseUID, email,
).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return nil, err
}
return &u, nil
}
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
var u domain.User
err := m.pool.QueryRow(ctx,
`SELECT id, firebase_uid, email, display_name, created_at, updated_at
FROM users
WHERE id = $1`,
id,
).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &u, nil
}

View File

@@ -19,6 +19,8 @@
.login-heading { .login-heading {
margin-bottom: 2rem; margin-bottom: 2rem;
text-align: center; text-align: center;
position: relative;
top: -20px;
} }
.login-brand { .login-brand {