Compare commits
4 Commits
setup-free
...
mega-blast
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ed4f6afd | ||
|
|
26324150d2 | ||
|
|
b0572451d3 | ||
|
|
d9c3bd1db5 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,6 +22,8 @@ node_modules/
|
|||||||
|
|
||||||
# Go build artifacts
|
# Go build artifacts
|
||||||
backend/bin/
|
backend/bin/
|
||||||
|
backend/api
|
||||||
|
backend/poller
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
Backend startup quickstarat:
|
Backend startup quickstarat:
|
||||||
|
-----------------------------> BEST
|
||||||
## 1. brew services start postgresql@15
|
## 1. brew services start postgresql@15
|
||||||
|
|
||||||
OR
|
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)
|
## 3. Option 1: Single command (both API + poller)
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
|
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 — Builds and runs the poller.
|
||||||
make poller-dev — Runs the poller with auto-reload.
|
make poller-dev — Runs the poller with auto-reload.
|
||||||
|
|
||||||
## 6. ALLIN ONE:
|
|
||||||
Make dev-all — Runs both the API and poller concurrently.
|
|
||||||
BIN
backend/api
BIN
backend/api
Binary file not shown.
@@ -51,6 +51,7 @@ func main() {
|
|||||||
|
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
|
userModel := models.NewUserModel(pool)
|
||||||
addressModel := models.NewAddressModel(pool)
|
addressModel := models.NewAddressModel(pool)
|
||||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||||
alertEventModel := models.NewAlertEventModel(pool)
|
alertEventModel := models.NewAlertEventModel(pool)
|
||||||
@@ -59,7 +60,7 @@ func main() {
|
|||||||
|
|
||||||
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
||||||
evaluator := services.NewEvaluatorService(
|
evaluator := services.NewEvaluatorService(
|
||||||
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
|
eth, alertRuleModel, alertEventModel, addressModel, userModel, notifConfigModel,
|
||||||
)
|
)
|
||||||
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
|
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
func getEnv(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
28
backend/internal/config/config_test.go
Normal file
28
backend/internal/config/config_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTierForPriceID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
StripePriceIDPremium: "price_premium_123",
|
||||||
|
StripePriceIDPro: "price_pro_456",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
priceID string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"price_premium_123", "premium"},
|
||||||
|
{"price_pro_456", "pro"},
|
||||||
|
{"price_unknown", ""},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := cfg.TierForPriceID(tt.priceID); got != tt.want {
|
||||||
|
t.Errorf("TierForPriceID(%q) = %q, want %q", tt.priceID, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
backend/internal/domain/types_test.go
Normal file
104
backend/internal/domain/types_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsValidTier(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
valid := []string{"free", "premium", "pro"}
|
||||||
|
for _, tier := range valid {
|
||||||
|
if !IsValidTier(tier) {
|
||||||
|
t.Errorf("expected %q to be valid", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{"", "basic", "enterprise", "FREE", "Pro"}
|
||||||
|
for _, tier := range invalid {
|
||||||
|
if IsValidTier(tier) {
|
||||||
|
t.Errorf("expected %q to be invalid", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Free(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierFree)
|
||||||
|
if limits.MaxAddresses != 1 {
|
||||||
|
t.Errorf("free MaxAddresses = %d, want 1", limits.MaxAddresses)
|
||||||
|
}
|
||||||
|
if limits.MaxAlertTypes != 1 {
|
||||||
|
t.Errorf("free MaxAlertTypes = %d, want 1", limits.MaxAlertTypes)
|
||||||
|
}
|
||||||
|
if len(limits.AllowedChannels) != 1 || limits.AllowedChannels[0] != "email" {
|
||||||
|
t.Errorf("free AllowedChannels = %v, want [email]", limits.AllowedChannels)
|
||||||
|
}
|
||||||
|
if limits.IsUnlimitedAddresses() {
|
||||||
|
t.Error("free should not have unlimited addresses")
|
||||||
|
}
|
||||||
|
if limits.IsUnlimitedAlertTypes() {
|
||||||
|
t.Error("free should not have unlimited alert types")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Premium(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierPremium)
|
||||||
|
if limits.MaxAddresses != 3 {
|
||||||
|
t.Errorf("premium MaxAddresses = %d, want 3", limits.MaxAddresses)
|
||||||
|
}
|
||||||
|
if limits.MaxAlertTypes != 2 {
|
||||||
|
t.Errorf("premium MaxAlertTypes = %d, want 2", limits.MaxAlertTypes)
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("email") {
|
||||||
|
t.Error("premium should allow email")
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("discord") {
|
||||||
|
t.Error("premium should allow discord")
|
||||||
|
}
|
||||||
|
if !limits.ChannelAllowed("telegram") {
|
||||||
|
t.Error("premium should allow telegram")
|
||||||
|
}
|
||||||
|
if limits.ChannelAllowed("slack") {
|
||||||
|
t.Error("premium should NOT allow slack")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Pro(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierPro)
|
||||||
|
if !limits.IsUnlimitedAddresses() {
|
||||||
|
t.Error("pro should have unlimited addresses")
|
||||||
|
}
|
||||||
|
if !limits.IsUnlimitedAlertTypes() {
|
||||||
|
t.Error("pro should have unlimited alert types")
|
||||||
|
}
|
||||||
|
for _, ch := range []string{"email", "discord", "telegram", "slack"} {
|
||||||
|
if !limits.ChannelAllowed(ch) {
|
||||||
|
t.Errorf("pro should allow %s", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTierLimits_Unknown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(SubscriptionTier("unknown"))
|
||||||
|
if limits.MaxAddresses != 1 {
|
||||||
|
t.Errorf("unknown tier should default to free limits, got MaxAddresses=%d", limits.MaxAddresses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelAllowed_NotInList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := GetTierLimits(TierFree)
|
||||||
|
if limits.ChannelAllowed("discord") {
|
||||||
|
t.Error("free should not allow discord")
|
||||||
|
}
|
||||||
|
if limits.ChannelAllowed("nonexistent") {
|
||||||
|
t.Error("nonexistent channel should not be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -384,11 +384,26 @@ func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.
|
|||||||
}
|
}
|
||||||
|
|
||||||
status := string(sub.Status)
|
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) {
|
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {
|
||||||
|
|||||||
60
backend/internal/middleware/auth_test.go
Normal file
60
backend/internal/middleware/auth_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetUserID_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if id := GetUserID(ctx); id != "" {
|
||||||
|
t.Errorf("expected empty user ID, got %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserID_Set(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), UserIDKey, "abc-123")
|
||||||
|
if id := GetUserID(ctx); id != "abc-123" {
|
||||||
|
t.Errorf("expected abc-123, got %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserEmail_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if email := GetUserEmail(ctx); email != "" {
|
||||||
|
t.Errorf("expected empty email, got %q", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserEmail_Set(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), UserEmailKey, "test@example.com")
|
||||||
|
if email := GetUserEmail(ctx); email != "test@example.com" {
|
||||||
|
t.Errorf("expected test@example.com, got %q", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserTier_Default(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if tier := GetUserTier(ctx); tier != "free" {
|
||||||
|
t.Errorf("expected default tier 'free', got %q", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserTier_Set(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), UserTierKey, "pro")
|
||||||
|
if tier := GetUserTier(ctx); tier != "pro" {
|
||||||
|
t.Errorf("expected 'pro', got %q", tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,12 +55,14 @@ func (m *AddressModel) ListByUser(ctx context.Context, userID string) ([]domain.
|
|||||||
return addresses, rows.Err()
|
return addresses, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAll returns all addresses system-wide (used by the poller).
|
// ListAll returns addresses for users with an active subscription (used by the poller).
|
||||||
func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) {
|
func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT id, user_id, address, label, created_at
|
`SELECT a.id, a.user_id, a.address, a.label, a.created_at
|
||||||
FROM addresses
|
FROM addresses a
|
||||||
ORDER BY created_at DESC`,
|
JOIN users u ON u.id = a.user_id
|
||||||
|
WHERE u.subscription_status IN ('active', 'trialing')
|
||||||
|
ORDER BY a.created_at DESC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -77,10 +77,12 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
|
|||||||
|
|
||||||
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
`SELECT nc.user_id, nc.discord_webhook_url, nc.telegram_chat_id, nc.telegram_bot_token,
|
||||||
email, slack_webhook_url
|
nc.email, nc.slack_webhook_url
|
||||||
FROM user_notification_configs
|
FROM user_notification_configs nc
|
||||||
WHERE notification_enabled = TRUE`,
|
JOIN users u ON u.id = nc.user_id
|
||||||
|
WHERE nc.notification_enabled = TRUE
|
||||||
|
AND u.subscription_status IN ('active', 'trialing')`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type EvaluatorService struct {
|
|||||||
alertRules *models.AlertRuleModel
|
alertRules *models.AlertRuleModel
|
||||||
alertEvents *models.AlertEventModel
|
alertEvents *models.AlertEventModel
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
|
users *models.UserModel
|
||||||
notifConfigs *models.NotificationConfigModel
|
notifConfigs *models.NotificationConfigModel
|
||||||
notifSem *semaphore.Weighted
|
notifSem *semaphore.Weighted
|
||||||
notifWg sync.WaitGroup
|
notifWg sync.WaitGroup
|
||||||
@@ -38,6 +39,7 @@ func NewEvaluatorService(
|
|||||||
alertRules *models.AlertRuleModel,
|
alertRules *models.AlertRuleModel,
|
||||||
alertEvents *models.AlertEventModel,
|
alertEvents *models.AlertEventModel,
|
||||||
addresses *models.AddressModel,
|
addresses *models.AddressModel,
|
||||||
|
users *models.UserModel,
|
||||||
notifConfigs *models.NotificationConfigModel,
|
notifConfigs *models.NotificationConfigModel,
|
||||||
) *EvaluatorService {
|
) *EvaluatorService {
|
||||||
return &EvaluatorService{
|
return &EvaluatorService{
|
||||||
@@ -45,6 +47,7 @@ func NewEvaluatorService(
|
|||||||
alertRules: alertRules,
|
alertRules: alertRules,
|
||||||
alertEvents: alertEvents,
|
alertEvents: alertEvents,
|
||||||
addresses: addresses,
|
addresses: addresses,
|
||||||
|
users: users,
|
||||||
notifConfigs: notifConfigs,
|
notifConfigs: notifConfigs,
|
||||||
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
||||||
}
|
}
|
||||||
@@ -248,14 +251,16 @@ func (s *EvaluatorService) WaitForNotifications() {
|
|||||||
s.notifWg.Wait()
|
s.notifWg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
|
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig, limits domain.TierLimits) []notifications.Notifier {
|
||||||
var notifiers []notifications.Notifier
|
var notifiers []notifications.Notifier
|
||||||
|
|
||||||
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
if limits.ChannelAllowed("discord") &&
|
||||||
|
cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||||
notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
if limits.ChannelAllowed("telegram") &&
|
||||||
|
cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||||
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||||
notifiers = append(notifiers, ¬ifications.TelegramNotifier{
|
notifiers = append(notifiers, ¬ifications.TelegramNotifier{
|
||||||
BotToken: *cfg.TelegramBotToken,
|
BotToken: *cfg.TelegramBotToken,
|
||||||
@@ -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})
|
notifiers = append(notifiers, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +319,13 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, err := s.users.GetByID(ctx, userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("Failed to load user %s for tier check in notification: %v", userID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limits := domain.GetTierLimits(user.SubscriptionTier)
|
||||||
|
|
||||||
meta := notifications.AlertMetadata{
|
meta := notifications.AlertMetadata{
|
||||||
TxHash: obs.Hash,
|
TxHash: obs.Hash,
|
||||||
AddressLabel: addressLabel,
|
AddressLabel: addressLabel,
|
||||||
@@ -320,7 +333,7 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
|||||||
Address: address,
|
Address: address,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, n := range s.buildNotifiers(notifConfig) {
|
for _, n := range s.buildNotifiers(notifConfig, limits) {
|
||||||
if err := sendWithRetry(ctx, n, message, meta); err != nil {
|
if err := sendWithRetry(ctx, n, message, meta); err != nil {
|
||||||
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
|
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
BIN
backend/poller
BIN
backend/poller
Binary file not shown.
@@ -10,7 +10,7 @@ import AlertHistory from "./pages/alertHistory/AlertHistory";
|
|||||||
import Account from "./pages/user_account/Account";
|
import Account from "./pages/user_account/Account";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser, isSubscribed } = useAuth();
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
@@ -23,6 +23,16 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isSubscribed) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/subscribe" element={<Subscribe />} />
|
||||||
|
<Route path="/account" element={<><Navbar /><Account /></>} />
|
||||||
|
<Route path="*" element={<Navigate to="/subscribe" />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import "./UpgradeBanner.css";
|
import "./UpgradeBanner.css";
|
||||||
|
|
||||||
export default function UpgradeBanner({ message, linkTo = "/account" }) {
|
export default function UpgradeBanner({ message, linkTo = "/subscribe?upgrade=true" }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export function AuthProvider({ children }) {
|
|||||||
const [userTier, setUserTier] = useState("free");
|
const [userTier, setUserTier] = useState("free");
|
||||||
const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS);
|
const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS);
|
||||||
const [addressCount, setAddressCount] = useState(0);
|
const [addressCount, setAddressCount] = useState(0);
|
||||||
|
const [subscriptionStatus, setSubscriptionStatus] = useState("none");
|
||||||
|
|
||||||
const refreshAccount = useCallback(async () => {
|
const refreshAccount = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -52,6 +53,7 @@ export function AuthProvider({ children }) {
|
|||||||
setUserTier(data.subscription_tier || "free");
|
setUserTier(data.subscription_tier || "free");
|
||||||
setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS);
|
setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS);
|
||||||
setAddressCount(data.address_count || 0);
|
setAddressCount(data.address_count || 0);
|
||||||
|
setSubscriptionStatus(data.subscription_status || "none");
|
||||||
} catch {
|
} catch {
|
||||||
// account fetch can fail during onboarding before subscription is active
|
// account fetch can fail during onboarding before subscription is active
|
||||||
}
|
}
|
||||||
@@ -93,6 +95,7 @@ export function AuthProvider({ children }) {
|
|||||||
setUserTier("free");
|
setUserTier("free");
|
||||||
setTierLimits(DEFAULT_TIER_LIMITS);
|
setTierLimits(DEFAULT_TIER_LIMITS);
|
||||||
setAddressCount(0);
|
setAddressCount(0);
|
||||||
|
setSubscriptionStatus("none");
|
||||||
await signOut(auth);
|
await signOut(auth);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -111,6 +114,9 @@ export function AuthProvider({ children }) {
|
|||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [refreshAccount]);
|
}, [refreshAccount]);
|
||||||
|
|
||||||
|
const isSubscribed =
|
||||||
|
subscriptionStatus === "active" || subscriptionStatus === "trialing";
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
currentUser,
|
currentUser,
|
||||||
signup,
|
signup,
|
||||||
@@ -121,6 +127,8 @@ export function AuthProvider({ children }) {
|
|||||||
userTier,
|
userTier,
|
||||||
tierLimits,
|
tierLimits,
|
||||||
addressCount,
|
addressCount,
|
||||||
|
subscriptionStatus,
|
||||||
|
isSubscribed,
|
||||||
refreshAccount,
|
refreshAccount,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -397,6 +397,14 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tier-locked: greyed out, non-interactive overlay for features above the user's plan */
|
||||||
|
.tier-locked {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: grayscale(40%);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
html {
|
html {
|
||||||
|
|||||||
@@ -95,11 +95,9 @@ export default function Addresses() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!atLimit && (
|
<div className={`mb-xl${atLimit ? " tier-locked" : ""}`}>
|
||||||
<div className="mb-xl">
|
|
||||||
<AddressForm onSubmit={handleAddressSubmit} />
|
<AddressForm onSubmit={handleAddressSubmit} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2>Existing Tracked Addresses</h2>
|
<h2>Existing Tracked Addresses</h2>
|
||||||
|
|||||||
@@ -382,12 +382,10 @@ export default function Alerts() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!atAlertLimit && (
|
<div className={`mb-xl${atAlertLimit ? " tier-locked" : ""}`}>
|
||||||
<div className="mb-xl">
|
|
||||||
<h3>Create New Alert</h3>
|
<h3>Create New Alert</h3>
|
||||||
<AlertForm onSubmit={handleAlertSubmit} />
|
<AlertForm onSubmit={handleAlertSubmit} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3>Active Alert Rules</h3>
|
<h3>Active Alert Rules</h3>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function Subscribe() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const hasPaymentReturn = searchParams.get("payment") === "success";
|
const hasPaymentReturn = searchParams.get("payment") === "success";
|
||||||
|
const isUpgrade = searchParams.get("upgrade") === "true";
|
||||||
const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1);
|
const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1);
|
||||||
const [loading, setLoading] = useState(hasPaymentReturn);
|
const [loading, setLoading] = useState(hasPaymentReturn);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -69,7 +70,7 @@ export default function Subscribe() {
|
|||||||
const tierLimits = TIER_LIMITS[data.selectedTier] || TIER_LIMITS.free;
|
const tierLimits = TIER_LIMITS[data.selectedTier] || TIER_LIMITS.free;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser) return;
|
if (!currentUser || isUpgrade) return;
|
||||||
getAddresses()
|
getAddresses()
|
||||||
.then((addresses) => {
|
.then((addresses) => {
|
||||||
if (addresses.length > 0) {
|
if (addresses.length > 0) {
|
||||||
@@ -77,7 +78,7 @@ export default function Subscribe() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { });
|
.catch(() => { });
|
||||||
}, [currentUser, navigate]);
|
}, [currentUser, navigate, isUpgrade]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const payment = searchParams.get("payment");
|
const payment = searchParams.get("payment");
|
||||||
|
|||||||
BIN
memberships.pdf
Normal file
BIN
memberships.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user