diff --git a/.gitignore b/.gitignore index 57df8aa..9013a4b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ node_modules/ # Go build artifacts backend/bin/ +backend/api +backend/poller *.exe *.exe~ *.dll diff --git a/backend/README.md b/backend/README.md index 5b4adac..940937f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,13 +1,18 @@ Backend startup quickstarat: - +-----------------------------> BEST ## 1. brew services start postgresql@15 OR - /opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start + /opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start -## 2. From the backend directory, you have a few options: +## 2. ALLIN ONE: + Make dev-all — Runs both the API and poller concurrently. +-----------------------------> BEST + - OR - + + ## 3. Option 1: Single command (both API + poller) cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all @@ -25,5 +30,3 @@ Backend startup quickstarat: make poller — Builds and runs the poller. make poller-dev — Runs the poller with auto-reload. -## 6. ALLIN ONE: - Make dev-all — Runs both the API and poller concurrently. \ No newline at end of file diff --git a/backend/api b/backend/api deleted file mode 100755 index cd0dfb3..0000000 Binary files a/backend/api and /dev/null differ diff --git a/backend/cmd/poller/main.go b/backend/cmd/poller/main.go index f7b0cbe..1a6429e 100644 --- a/backend/cmd/poller/main.go +++ b/backend/cmd/poller/main.go @@ -51,6 +51,7 @@ func main() { defer database.Close() + userModel := models.NewUserModel(pool) addressModel := models.NewAddressModel(pool) alertRuleModel := models.NewAlertRuleModel(pool) alertEventModel := models.NewAlertEventModel(pool) @@ -59,7 +60,7 @@ func main() { observer := services.NewObserverService(eth, addressModel, checkpointModel) evaluator := services.NewEvaluatorService( - eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel, + eth, alertRuleModel, alertEventModel, addressModel, userModel, notifConfigModel, ) digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ff4cd51..f67c592 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -94,6 +94,19 @@ func (c *Config) DSN() string { ) } +// TierForPriceID maps a Stripe price ID back to the corresponding +// subscription tier. Returns empty string if the price is unrecognised. +func (c *Config) TierForPriceID(priceID string) string { + switch priceID { + case c.StripePriceIDPremium: + return "premium" + case c.StripePriceIDPro: + return "pro" + default: + return "" + } +} + func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..5f26b77 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,28 @@ +package config + +import "testing" + +func TestTierForPriceID(t *testing.T) { + t.Parallel() + + cfg := &Config{ + StripePriceIDPremium: "price_premium_123", + StripePriceIDPro: "price_pro_456", + } + + tests := []struct { + priceID string + want string + }{ + {"price_premium_123", "premium"}, + {"price_pro_456", "pro"}, + {"price_unknown", ""}, + {"", ""}, + } + + for _, tt := range tests { + if got := cfg.TierForPriceID(tt.priceID); got != tt.want { + t.Errorf("TierForPriceID(%q) = %q, want %q", tt.priceID, got, tt.want) + } + } +} diff --git a/backend/internal/domain/types_test.go b/backend/internal/domain/types_test.go new file mode 100644 index 0000000..66d1874 --- /dev/null +++ b/backend/internal/domain/types_test.go @@ -0,0 +1,104 @@ +package domain + +import "testing" + +func TestIsValidTier(t *testing.T) { + t.Parallel() + + valid := []string{"free", "premium", "pro"} + for _, tier := range valid { + if !IsValidTier(tier) { + t.Errorf("expected %q to be valid", tier) + } + } + + invalid := []string{"", "basic", "enterprise", "FREE", "Pro"} + for _, tier := range invalid { + if IsValidTier(tier) { + t.Errorf("expected %q to be invalid", tier) + } + } +} + +func TestGetTierLimits_Free(t *testing.T) { + t.Parallel() + + limits := GetTierLimits(TierFree) + if limits.MaxAddresses != 1 { + t.Errorf("free MaxAddresses = %d, want 1", limits.MaxAddresses) + } + if limits.MaxAlertTypes != 1 { + t.Errorf("free MaxAlertTypes = %d, want 1", limits.MaxAlertTypes) + } + if len(limits.AllowedChannels) != 1 || limits.AllowedChannels[0] != "email" { + t.Errorf("free AllowedChannels = %v, want [email]", limits.AllowedChannels) + } + if limits.IsUnlimitedAddresses() { + t.Error("free should not have unlimited addresses") + } + if limits.IsUnlimitedAlertTypes() { + t.Error("free should not have unlimited alert types") + } +} + +func TestGetTierLimits_Premium(t *testing.T) { + t.Parallel() + + limits := GetTierLimits(TierPremium) + if limits.MaxAddresses != 3 { + t.Errorf("premium MaxAddresses = %d, want 3", limits.MaxAddresses) + } + if limits.MaxAlertTypes != 2 { + t.Errorf("premium MaxAlertTypes = %d, want 2", limits.MaxAlertTypes) + } + if !limits.ChannelAllowed("email") { + t.Error("premium should allow email") + } + if !limits.ChannelAllowed("discord") { + t.Error("premium should allow discord") + } + if !limits.ChannelAllowed("telegram") { + t.Error("premium should allow telegram") + } + if limits.ChannelAllowed("slack") { + t.Error("premium should NOT allow slack") + } +} + +func TestGetTierLimits_Pro(t *testing.T) { + t.Parallel() + + limits := GetTierLimits(TierPro) + if !limits.IsUnlimitedAddresses() { + t.Error("pro should have unlimited addresses") + } + if !limits.IsUnlimitedAlertTypes() { + t.Error("pro should have unlimited alert types") + } + for _, ch := range []string{"email", "discord", "telegram", "slack"} { + if !limits.ChannelAllowed(ch) { + t.Errorf("pro should allow %s", ch) + } + } +} + +func TestGetTierLimits_Unknown(t *testing.T) { + t.Parallel() + + limits := GetTierLimits(SubscriptionTier("unknown")) + if limits.MaxAddresses != 1 { + t.Errorf("unknown tier should default to free limits, got MaxAddresses=%d", limits.MaxAddresses) + } +} + +func TestChannelAllowed_NotInList(t *testing.T) { + t.Parallel() + + limits := GetTierLimits(TierFree) + if limits.ChannelAllowed("discord") { + t.Error("free should not allow discord") + } + if limits.ChannelAllowed("nonexistent") { + t.Error("nonexistent channel should not be allowed") + } +} diff --git a/backend/internal/handlers/stripe.go b/backend/internal/handlers/stripe.go index 5f5e330..83cb61b 100644 --- a/backend/internal/handlers/stripe.go +++ b/backend/internal/handlers/stripe.go @@ -384,11 +384,26 @@ func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe. } status := string(sub.Status) - if err := h.users.UpdateSubscriptionStatus(r.Context(), customerID, status); err != nil { - log.Printf("Failed to update subscription status: %v", err) + + // Detect tier from the subscription's current price so that + // upgrades/downgrades via the Stripe portal are reflected. + tier := domain.TierPremium + if sub.Items != nil { + for _, item := range sub.Items.Data { + if item.Price != nil { + if t := h.cfg.TierForPriceID(item.Price.ID); t != "" { + tier = domain.SubscriptionTier(t) + break + } + } + } } - log.Printf("Subscription %s updated to %s for customer %s", sub.ID, status, customerID) + if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil { + log.Printf("Failed to update subscription: %v", err) + } + + log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID) } func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) { diff --git a/backend/internal/middleware/auth_test.go b/backend/internal/middleware/auth_test.go new file mode 100644 index 0000000..8c53c16 --- /dev/null +++ b/backend/internal/middleware/auth_test.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "context" + "testing" +) + +func TestGetUserID_Empty(t *testing.T) { + t.Parallel() + + ctx := context.Background() + if id := GetUserID(ctx); id != "" { + t.Errorf("expected empty user ID, got %q", id) + } +} + +func TestGetUserID_Set(t *testing.T) { + t.Parallel() + + ctx := context.WithValue(context.Background(), UserIDKey, "abc-123") + if id := GetUserID(ctx); id != "abc-123" { + t.Errorf("expected abc-123, got %q", id) + } +} + +func TestGetUserEmail_Empty(t *testing.T) { + t.Parallel() + + ctx := context.Background() + if email := GetUserEmail(ctx); email != "" { + t.Errorf("expected empty email, got %q", email) + } +} + +func TestGetUserEmail_Set(t *testing.T) { + t.Parallel() + + ctx := context.WithValue(context.Background(), UserEmailKey, "test@example.com") + if email := GetUserEmail(ctx); email != "test@example.com" { + t.Errorf("expected test@example.com, got %q", email) + } +} + +func TestGetUserTier_Default(t *testing.T) { + t.Parallel() + + ctx := context.Background() + if tier := GetUserTier(ctx); tier != "free" { + t.Errorf("expected default tier 'free', got %q", tier) + } +} + +func TestGetUserTier_Set(t *testing.T) { + t.Parallel() + + ctx := context.WithValue(context.Background(), UserTierKey, "pro") + if tier := GetUserTier(ctx); tier != "pro" { + t.Errorf("expected 'pro', got %q", tier) + } +} diff --git a/backend/internal/models/address.go b/backend/internal/models/address.go index fbd6d45..6e47d3e 100644 --- a/backend/internal/models/address.go +++ b/backend/internal/models/address.go @@ -55,12 +55,14 @@ func (m *AddressModel) ListByUser(ctx context.Context, userID string) ([]domain. return addresses, rows.Err() } -// ListAll returns all addresses system-wide (used by the poller). +// ListAll returns addresses for users with an active subscription (used by the poller). func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) { rows, err := m.pool.Query(ctx, - `SELECT id, user_id, address, label, created_at - FROM addresses - ORDER BY created_at DESC`, + `SELECT a.id, a.user_id, a.address, a.label, a.created_at + FROM addresses a + JOIN users u ON u.id = a.user_id + WHERE u.subscription_status IN ('active', 'trialing') + ORDER BY a.created_at DESC`, ) if err != nil { return nil, err diff --git a/backend/internal/models/notification_config.go b/backend/internal/models/notification_config.go index 1d66325..885efb8 100644 --- a/backend/internal/models/notification_config.go +++ b/backend/internal/models/notification_config.go @@ -77,10 +77,12 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) { rows, err := m.pool.Query(ctx, - `SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, - email, slack_webhook_url - FROM user_notification_configs - WHERE notification_enabled = TRUE`, + `SELECT nc.user_id, nc.discord_webhook_url, nc.telegram_chat_id, nc.telegram_bot_token, + nc.email, nc.slack_webhook_url + FROM user_notification_configs nc + JOIN users u ON u.id = nc.user_id + WHERE nc.notification_enabled = TRUE + AND u.subscription_status IN ('active', 'trialing')`, ) if err != nil { return nil, err diff --git a/backend/internal/services/evaluator.go b/backend/internal/services/evaluator.go index e664df7..ebb7e45 100644 --- a/backend/internal/services/evaluator.go +++ b/backend/internal/services/evaluator.go @@ -28,6 +28,7 @@ type EvaluatorService struct { alertRules *models.AlertRuleModel alertEvents *models.AlertEventModel addresses *models.AddressModel + users *models.UserModel notifConfigs *models.NotificationConfigModel notifSem *semaphore.Weighted notifWg sync.WaitGroup @@ -38,6 +39,7 @@ func NewEvaluatorService( alertRules *models.AlertRuleModel, alertEvents *models.AlertEventModel, addresses *models.AddressModel, + users *models.UserModel, notifConfigs *models.NotificationConfigModel, ) *EvaluatorService { return &EvaluatorService{ @@ -45,6 +47,7 @@ func NewEvaluatorService( alertRules: alertRules, alertEvents: alertEvents, addresses: addresses, + users: users, notifConfigs: notifConfigs, notifSem: semaphore.NewWeighted(maxConcurrentNotifications), } @@ -248,14 +251,16 @@ func (s *EvaluatorService) WaitForNotifications() { s.notifWg.Wait() } -func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier { +func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig, limits domain.TierLimits) []notifications.Notifier { var notifiers []notifications.Notifier - if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" { + if limits.ChannelAllowed("discord") && + cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" { notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL}) } - if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" && + if limits.ChannelAllowed("telegram") && + cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" && cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" { notifiers = append(notifiers, ¬ifications.TelegramNotifier{ BotToken: *cfg.TelegramBotToken, @@ -263,7 +268,8 @@ func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []noti }) } - if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" { + if limits.ChannelAllowed("slack") && + cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" { notifiers = append(notifiers, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL}) } @@ -313,6 +319,13 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message return } + user, err := s.users.GetByID(ctx, userID) + if err != nil || user == nil { + log.Printf("Failed to load user %s for tier check in notification: %v", userID, err) + return + } + limits := domain.GetTierLimits(user.SubscriptionTier) + meta := notifications.AlertMetadata{ TxHash: obs.Hash, AddressLabel: addressLabel, @@ -320,7 +333,7 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message Address: address, } - for _, n := range s.buildNotifiers(notifConfig) { + for _, n := range s.buildNotifiers(notifConfig, limits) { if err := sendWithRetry(ctx, n, message, meta); err != nil { log.Printf("Notification channel failed for user %s after retries: %v", userID, err) } else { diff --git a/backend/poller b/backend/poller deleted file mode 100755 index 6c22333..0000000 Binary files a/backend/poller and /dev/null differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7624a56..47fb0d3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -10,7 +10,7 @@ import AlertHistory from "./pages/alertHistory/AlertHistory"; import Account from "./pages/user_account/Account"; export default function App() { - const { currentUser } = useAuth(); + const { currentUser, isSubscribed } = useAuth(); if (!currentUser) { return ( @@ -23,6 +23,16 @@ export default function App() { ); } + if (!isSubscribed) { + return ( + + } /> + } /> + } /> + + ); + } + return (
diff --git a/frontend/src/components/UpgradeBanner.jsx b/frontend/src/components/UpgradeBanner.jsx index 3b66f28..4bcd7f4 100644 --- a/frontend/src/components/UpgradeBanner.jsx +++ b/frontend/src/components/UpgradeBanner.jsx @@ -1,7 +1,7 @@ import { useNavigate } from "react-router-dom"; import "./UpgradeBanner.css"; -export default function UpgradeBanner({ message, linkTo = "/account" }) { +export default function UpgradeBanner({ message, linkTo = "/subscribe?upgrade=true" }) { const navigate = useNavigate(); return ( diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 03e1082..0413bd3 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -45,6 +45,7 @@ export function AuthProvider({ children }) { const [userTier, setUserTier] = useState("free"); const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS); const [addressCount, setAddressCount] = useState(0); + const [subscriptionStatus, setSubscriptionStatus] = useState("none"); const refreshAccount = useCallback(async () => { try { @@ -52,6 +53,7 @@ export function AuthProvider({ children }) { setUserTier(data.subscription_tier || "free"); setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS); setAddressCount(data.address_count || 0); + setSubscriptionStatus(data.subscription_status || "none"); } catch { // account fetch can fail during onboarding before subscription is active } @@ -93,6 +95,7 @@ export function AuthProvider({ children }) { setUserTier("free"); setTierLimits(DEFAULT_TIER_LIMITS); setAddressCount(0); + setSubscriptionStatus("none"); await signOut(auth); } catch (err) { setError(err.message); @@ -111,6 +114,9 @@ export function AuthProvider({ children }) { return unsubscribe; }, [refreshAccount]); + const isSubscribed = + subscriptionStatus === "active" || subscriptionStatus === "trialing"; + const value = { currentUser, signup, @@ -121,6 +127,8 @@ export function AuthProvider({ children }) { userTier, tierLimits, addressCount, + subscriptionStatus, + isSubscribed, refreshAccount, }; diff --git a/frontend/src/index.css b/frontend/src/index.css index aeea9b6..cc94363 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -397,6 +397,14 @@ button { } } +/* Tier-locked: greyed out, non-interactive overlay for features above the user's plan */ +.tier-locked { + opacity: 0.45; + pointer-events: none; + filter: grayscale(40%); + user-select: none; +} + /* Mobile */ @media (max-width: 480px) { html { diff --git a/frontend/src/pages/addresses/Addresses.jsx b/frontend/src/pages/addresses/Addresses.jsx index a23144c..8031969 100644 --- a/frontend/src/pages/addresses/Addresses.jsx +++ b/frontend/src/pages/addresses/Addresses.jsx @@ -95,11 +95,9 @@ export default function Addresses() { /> )} - {!atLimit && ( -
- -
- )} +
+ +

Existing Tracked Addresses

diff --git a/frontend/src/pages/alerts/Alerts.jsx b/frontend/src/pages/alerts/Alerts.jsx index b5315e1..ca939ac 100644 --- a/frontend/src/pages/alerts/Alerts.jsx +++ b/frontend/src/pages/alerts/Alerts.jsx @@ -382,12 +382,10 @@ export default function Alerts() { /> )} - {!atAlertLimit && ( -
-

Create New Alert

- -
- )} +
+

Create New Alert

+ +

Active Alert Rules

diff --git a/frontend/src/pages/subscribe/Subscribe.jsx b/frontend/src/pages/subscribe/Subscribe.jsx index 971ad06..3a04cf7 100644 --- a/frontend/src/pages/subscribe/Subscribe.jsx +++ b/frontend/src/pages/subscribe/Subscribe.jsx @@ -34,6 +34,7 @@ export default function Subscribe() { const [searchParams, setSearchParams] = useSearchParams(); const hasPaymentReturn = searchParams.get("payment") === "success"; + const isUpgrade = searchParams.get("upgrade") === "true"; const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1); const [loading, setLoading] = useState(hasPaymentReturn); const [error, setError] = useState(""); @@ -69,7 +70,7 @@ export default function Subscribe() { const tierLimits = TIER_LIMITS[data.selectedTier] || TIER_LIMITS.free; useEffect(() => { - if (!currentUser) return; + if (!currentUser || isUpgrade) return; getAddresses() .then((addresses) => { if (addresses.length > 0) { @@ -77,7 +78,7 @@ export default function Subscribe() { } }) .catch(() => { }); - }, [currentUser, navigate]); + }, [currentUser, navigate, isUpgrade]); useEffect(() => { const payment = searchParams.get("payment"); diff --git a/memberships.pdf b/memberships.pdf new file mode 100644 index 0000000..dfdef36 Binary files /dev/null and b/memberships.pdf differ