Update terminology, fix search feed, and deploy updates
This commit is contained in:
parent
403f522a0b
commit
002f960142
|
|
@ -99,6 +99,7 @@ func main() {
|
|||
chatRepo := repository.NewChatRepository(dbPool)
|
||||
categoryRepo := repository.NewCategoryRepository(dbPool)
|
||||
notifRepo := repository.NewNotificationRepository(dbPool)
|
||||
tagRepo := repository.NewTagRepository(dbPool)
|
||||
|
||||
assetService := services.NewAssetService(cfg.R2SigningSecret, cfg.R2PublicBaseURL, cfg.R2ImgDomain, cfg.R2VidDomain)
|
||||
feedService := services.NewFeedService(postRepo, assetService)
|
||||
|
|
@ -108,15 +109,16 @@ func main() {
|
|||
log.Warn().Err(err).Msg("Failed to initialize PushService")
|
||||
}
|
||||
|
||||
notificationService := services.NewNotificationService(notifRepo, pushService)
|
||||
notificationService := services.NewNotificationService(notifRepo, pushService, userRepo)
|
||||
|
||||
emailService := services.NewEmailService(cfg)
|
||||
moderationService := services.NewModerationService(dbPool)
|
||||
|
||||
hub := realtime.NewHub()
|
||||
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
|
||||
|
||||
userHandler := handlers.NewUserHandler(userRepo, postRepo, pushService, assetService)
|
||||
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService)
|
||||
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService)
|
||||
chatHandler := handlers.NewChatHandler(chatRepo, pushService, hub)
|
||||
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService)
|
||||
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
||||
|
|
@ -203,6 +205,13 @@ func main() {
|
|||
users.GET("/:id/posts", postHandler.GetProfilePosts)
|
||||
users.GET("/:id/saved", userHandler.GetSavedPosts)
|
||||
users.GET("/me/liked", userHandler.GetLikedPosts)
|
||||
users.POST("/:id/block", userHandler.BlockUser)
|
||||
users.DELETE("/:id/block", userHandler.UnblockUser)
|
||||
users.GET("/blocked", userHandler.GetBlockedUsers)
|
||||
users.POST("/report", userHandler.ReportUser)
|
||||
users.POST("/block_by_handle", userHandler.BlockUserByHandle)
|
||||
users.DELETE("/notifications/device", userHandler.RemoveFCMToken)
|
||||
users.POST("/notifications/device", userHandler.RegisterFCMToken)
|
||||
}
|
||||
|
||||
authorized.POST("/posts", postHandler.CreatePost)
|
||||
|
|
@ -271,15 +280,29 @@ func main() {
|
|||
// Media routes
|
||||
authorized.POST("/upload", mediaHandler.Upload)
|
||||
|
||||
// Search route
|
||||
searchHandler := handlers.NewSearchHandler(userRepo, postRepo, assetService)
|
||||
authorized.GET("/search", searchHandler.Search)
|
||||
// Search & Discover routes
|
||||
discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService)
|
||||
authorized.GET("/search", discoverHandler.Search)
|
||||
authorized.GET("/discover", discoverHandler.GetDiscover)
|
||||
authorized.GET("/hashtags/trending", discoverHandler.GetTrendingHashtags)
|
||||
authorized.GET("/hashtags/following", discoverHandler.GetFollowedHashtags)
|
||||
authorized.GET("/hashtags/:name", discoverHandler.GetHashtagPage)
|
||||
authorized.POST("/hashtags/:name/follow", discoverHandler.FollowHashtag)
|
||||
authorized.DELETE("/hashtags/:name/follow", discoverHandler.UnfollowHashtag)
|
||||
|
||||
// Notifications
|
||||
notificationHandler := handlers.NewNotificationHandler(notifRepo)
|
||||
notificationHandler := handlers.NewNotificationHandler(notifRepo, notificationService)
|
||||
authorized.GET("/notifications", notificationHandler.GetNotifications)
|
||||
authorized.POST("/notifications/device", settingsHandler.RegisterDevice)
|
||||
authorized.DELETE("/notifications/device", settingsHandler.UnregisterDevice)
|
||||
authorized.GET("/notifications/unread", notificationHandler.GetUnreadCount)
|
||||
authorized.GET("/notifications/badge", notificationHandler.GetBadgeCount)
|
||||
authorized.PUT("/notifications/:id/read", notificationHandler.MarkAsRead)
|
||||
authorized.PUT("/notifications/read-all", notificationHandler.MarkAllAsRead)
|
||||
authorized.DELETE("/notifications/:id", notificationHandler.DeleteNotification)
|
||||
authorized.GET("/notifications/preferences", notificationHandler.GetNotificationPreferences)
|
||||
authorized.PUT("/notifications/preferences", notificationHandler.UpdateNotificationPreferences)
|
||||
authorized.POST("/notifications/device", notificationHandler.RegisterDevice)
|
||||
authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice)
|
||||
authorized.DELETE("/notifications/devices", notificationHandler.UnregisterAllDevices)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
85
go-backend/cmd/migrate_new/main.go
Normal file
85
go-backend/cmd/migrate_new/main.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/patbritton/sojorn-backend/internal/config"
|
||||
"github.com/patbritton/sojorn-backend/internal/database"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Setup logging
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||
|
||||
// Load configuration
|
||||
cfg := config.LoadConfig()
|
||||
if cfg.DatabaseURL == "" {
|
||||
log.Fatal().Msg("DATABASE_URL environment variable is not set")
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
log.Info().Msg("Connecting to database...")
|
||||
pool, err := database.Connect(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to connect to database")
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
migrationsDir := "internal/database/migrations"
|
||||
|
||||
// New migrations to apply
|
||||
migrations := []string{
|
||||
"000010_notification_preferences.up.sql",
|
||||
"000011_tagging_system.up.sql",
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for _, filename := range migrations {
|
||||
log.Info().Msgf("Applying migration: %s", filename)
|
||||
|
||||
content, err := ioutil.ReadFile(filepath.Join(migrationsDir, filename))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Failed to read file: %s", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
_, err = pool.Exec(ctx, string(content))
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is just "already exists"
|
||||
errStr := err.Error()
|
||||
if contains(errStr, "already exists") || contains(errStr, "duplicate key") {
|
||||
log.Warn().Msgf("Migration %s may have already been applied (partial): %s", filename, errStr)
|
||||
continue
|
||||
}
|
||||
log.Error().Err(err).Msgf("Failed to execute migration: %s", filename)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log.Info().Msgf("Successfully applied: %s", filename)
|
||||
}
|
||||
|
||||
log.Info().Msg("All new migrations applied successfully.")
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsRune(s, substr))
|
||||
}
|
||||
|
||||
func containsRune(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
-- 000010_notification_preferences.down.sql
|
||||
|
||||
DROP TRIGGER IF EXISTS notification_count_trigger ON notifications;
|
||||
DROP FUNCTION IF EXISTS update_unread_notification_count();
|
||||
|
||||
ALTER TABLE notifications DROP COLUMN IF EXISTS group_key;
|
||||
ALTER TABLE notifications DROP COLUMN IF EXISTS priority;
|
||||
ALTER TABLE profiles DROP COLUMN IF EXISTS unread_notification_count;
|
||||
|
||||
DROP TABLE IF EXISTS notification_preferences;
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
-- 000010_notification_preferences.up.sql
|
||||
-- User notification preferences for granular control
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
|
||||
-- Push notification toggles
|
||||
push_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
push_likes BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
push_comments BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
push_replies BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
push_mentions BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
push_follows BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
push_follow_requests BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
push_messages BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
push_saves BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
push_beacons BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
-- Email notification toggles (for future use)
|
||||
email_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
email_digest_frequency TEXT NOT NULL DEFAULT 'never', -- 'never', 'daily', 'weekly'
|
||||
|
||||
-- Quiet hours (UTC)
|
||||
quiet_hours_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
quiet_hours_start TIME, -- e.g., '22:00:00'
|
||||
quiet_hours_end TIME, -- e.g., '08:00:00'
|
||||
|
||||
-- Badge settings
|
||||
show_badge_count BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add unread count cache to profiles for badge display
|
||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS unread_notification_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Add group_key for notification batching/grouping
|
||||
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS group_key TEXT;
|
||||
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'normal'; -- 'low', 'normal', 'high', 'urgent'
|
||||
|
||||
-- Create indexes for efficient querying
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read) WHERE is_read = FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_group_key ON notifications(group_key) WHERE group_key IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_fcm_tokens_user ON user_fcm_tokens(user_id);
|
||||
|
||||
-- Function to update unread count on notification insert
|
||||
CREATE OR REPLACE FUNCTION update_unread_notification_count()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
UPDATE profiles SET unread_notification_count = unread_notification_count + 1 WHERE id = NEW.user_id;
|
||||
ELSIF TG_OP = 'UPDATE' AND OLD.is_read = FALSE AND NEW.is_read = TRUE THEN
|
||||
UPDATE profiles SET unread_notification_count = GREATEST(0, unread_notification_count - 1) WHERE id = NEW.user_id;
|
||||
ELSIF TG_OP = 'DELETE' AND OLD.is_read = FALSE THEN
|
||||
UPDATE profiles SET unread_notification_count = GREATEST(0, unread_notification_count - 1) WHERE id = OLD.user_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger for automatic badge count updates
|
||||
DROP TRIGGER IF EXISTS notification_count_trigger ON notifications;
|
||||
CREATE TRIGGER notification_count_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE ON notifications
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_unread_notification_count();
|
||||
|
||||
-- Initialize notification preferences for existing users
|
||||
INSERT INTO notification_preferences (user_id)
|
||||
SELECT id FROM profiles
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
-- 000011_tagging_system.down.sql
|
||||
|
||||
DROP TRIGGER IF EXISTS hashtag_count_trigger ON post_hashtags;
|
||||
DROP FUNCTION IF EXISTS update_hashtag_count();
|
||||
DROP FUNCTION IF EXISTS calculate_trending_scores();
|
||||
|
||||
DROP TABLE IF EXISTS suggested_users;
|
||||
DROP TABLE IF EXISTS trending_hashtags;
|
||||
DROP TABLE IF EXISTS post_mentions;
|
||||
DROP TABLE IF EXISTS hashtag_follows;
|
||||
DROP TABLE IF EXISTS post_hashtags;
|
||||
DROP TABLE IF EXISTS hashtags;
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
-- 000011_tagging_system.up.sql
|
||||
-- Complete tagging system for hashtags and mentions
|
||||
|
||||
-- Hashtags master table
|
||||
CREATE TABLE IF NOT EXISTS hashtags (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL, -- lowercase, without #
|
||||
display_name TEXT NOT NULL, -- original casing
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
trending_score FLOAT NOT NULL DEFAULT 0,
|
||||
is_trending BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
category TEXT, -- optional category grouping
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(name)
|
||||
);
|
||||
|
||||
-- Post-to-hashtag junction table
|
||||
CREATE TABLE IF NOT EXISTS post_hashtags (
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
hashtag_id UUID NOT NULL REFERENCES hashtags(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (post_id, hashtag_id)
|
||||
);
|
||||
|
||||
-- User-to-hashtag follows (users can follow hashtags)
|
||||
CREATE TABLE IF NOT EXISTS hashtag_follows (
|
||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
hashtag_id UUID NOT NULL REFERENCES hashtags(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, hashtag_id)
|
||||
);
|
||||
|
||||
-- Post mentions (tracks @mentions in posts)
|
||||
CREATE TABLE IF NOT EXISTS post_mentions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
mentioned_user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(post_id, mentioned_user_id)
|
||||
);
|
||||
|
||||
-- Trending hashtags snapshot (for discover page)
|
||||
CREATE TABLE IF NOT EXISTS trending_hashtags (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
hashtag_id UUID NOT NULL REFERENCES hashtags(id) ON DELETE CASCADE,
|
||||
rank INTEGER NOT NULL,
|
||||
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
period TEXT NOT NULL DEFAULT 'daily', -- 'hourly', 'daily', 'weekly'
|
||||
post_count_in_period INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(hashtag_id, period)
|
||||
);
|
||||
|
||||
-- Suggested users for discover page
|
||||
CREATE TABLE IF NOT EXISTS suggested_users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
reason TEXT NOT NULL, -- 'popular', 'category', 'similar', 'new_creator'
|
||||
category TEXT,
|
||||
score FLOAT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, reason)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_hashtags_name ON hashtags(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_hashtags_trending ON hashtags(is_trending, trending_score DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_hashtags_use_count ON hashtags(use_count DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_hashtags_hashtag ON post_hashtags(hashtag_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_mentions_user ON post_mentions(mentioned_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trending_hashtags_rank ON trending_hashtags(period, rank);
|
||||
CREATE INDEX IF NOT EXISTS idx_suggested_users_active ON suggested_users(is_active, score DESC);
|
||||
|
||||
-- Function to update hashtag use count
|
||||
CREATE OR REPLACE FUNCTION update_hashtag_count()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
UPDATE hashtags SET use_count = use_count + 1, updated_at = NOW() WHERE id = NEW.hashtag_id;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
UPDATE hashtags SET use_count = GREATEST(0, use_count - 1), updated_at = NOW() WHERE id = OLD.hashtag_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS hashtag_count_trigger ON post_hashtags;
|
||||
CREATE TRIGGER hashtag_count_trigger
|
||||
AFTER INSERT OR DELETE ON post_hashtags
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_hashtag_count();
|
||||
|
||||
-- Function to calculate trending score (decay-based)
|
||||
CREATE OR REPLACE FUNCTION calculate_trending_scores()
|
||||
RETURNS void AS $$
|
||||
DECLARE
|
||||
decay_factor FLOAT := 0.95;
|
||||
hours_window INTEGER := 24;
|
||||
BEGIN
|
||||
-- Calculate trending score based on recent usage with time decay
|
||||
UPDATE hashtags h
|
||||
SET
|
||||
trending_score = COALESCE((
|
||||
SELECT SUM(POWER(decay_factor, EXTRACT(EPOCH FROM (NOW() - ph.created_at)) / 3600))
|
||||
FROM post_hashtags ph
|
||||
WHERE ph.hashtag_id = h.id
|
||||
AND ph.created_at > NOW() - INTERVAL '24 hours'
|
||||
), 0),
|
||||
is_trending = (
|
||||
SELECT COUNT(*) >= 5
|
||||
FROM post_hashtags ph
|
||||
WHERE ph.hashtag_id = h.id
|
||||
AND ph.created_at > NOW() - INTERVAL '24 hours'
|
||||
),
|
||||
updated_at = NOW();
|
||||
|
||||
-- Update trending_hashtags table
|
||||
DELETE FROM trending_hashtags WHERE period = 'daily';
|
||||
INSERT INTO trending_hashtags (hashtag_id, rank, period, post_count_in_period)
|
||||
SELECT
|
||||
h.id,
|
||||
ROW_NUMBER() OVER (ORDER BY h.trending_score DESC),
|
||||
'daily',
|
||||
(SELECT COUNT(*) FROM post_hashtags ph WHERE ph.hashtag_id = h.id AND ph.created_at > NOW() - INTERVAL '24 hours')
|
||||
FROM hashtags h
|
||||
WHERE h.trending_score > 0
|
||||
ORDER BY h.trending_score DESC
|
||||
LIMIT 50;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
-- Add privacy settings to profiles
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS default_post_visibility TEXT NOT NULL DEFAULT 'public',
|
||||
ADD COLUMN IF NOT EXISTS is_private_profile BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Create table for blocked users
|
||||
CREATE TABLE IF NOT EXISTS public.blocks (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
blocker_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
blocked_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(blocker_id, blocked_id)
|
||||
);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
-- Add missing columns to profile_privacy_settings
|
||||
ALTER TABLE public.profile_privacy_settings
|
||||
ADD COLUMN IF NOT EXISTS default_post_visibility TEXT NOT NULL DEFAULT 'public',
|
||||
ADD COLUMN IF NOT EXISTS is_private_profile BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Ensure blocks table exists
|
||||
CREATE TABLE IF NOT EXISTS public.blocks (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
blocker_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
blocked_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(blocker_id, blocked_id)
|
||||
);
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
-- Structural Blocking & Abuse Tracking
|
||||
|
||||
-- Create abuse_logs table
|
||||
CREATE TABLE IF NOT EXISTS public.abuse_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
actor_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
|
||||
blocked_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
blocked_handle TEXT NOT NULL,
|
||||
actor_ip TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create reports table
|
||||
CREATE TABLE IF NOT EXISTS public.reports (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
reporter_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
target_user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
post_id UUID REFERENCES public.posts(id) ON DELETE SET NULL,
|
||||
comment_id UUID REFERENCES public.comments(id) ON DELETE SET NULL,
|
||||
violation_type TEXT NOT NULL, -- e.g., 'hate', 'greed', 'delusion'
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending, reviewed, resolved
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create pending_moderation table for AI flags
|
||||
CREATE TABLE IF NOT EXISTS public.pending_moderation (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
post_id UUID REFERENCES public.posts(id) ON DELETE CASCADE,
|
||||
comment_id UUID REFERENCES public.comments(id) ON DELETE CASCADE,
|
||||
flag_reason TEXT NOT NULL,
|
||||
scores JSONB, -- store AI scores for 'hate', 'greed', 'delusion'
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Blocking Function
|
||||
CREATE OR REPLACE FUNCTION public.has_block_between(user_a UUID, user_b UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
RETURN EXISTS (
|
||||
SELECT 1 FROM public.blocks
|
||||
WHERE (blocker_id = user_a AND blocked_id = user_b)
|
||||
OR (blocker_id = user_b AND blocked_id = user_a)
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Note: RLS implementation typically requires the database to be aware of the "current_user_id".
|
||||
-- Since this is an external Go API, we will enforce structural invisibility in the Repositories/Queries
|
||||
-- for now if RLS isn't fully configured with the Auth provider's claims.
|
||||
-- However, we'll set up the policies anyway for parity.
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Post Policy: Hide posts from/to blocked users
|
||||
-- This assuming jwt.claims.sub can be passed to session configs
|
||||
-- For now, we will focus on SQL Query enforcement in Repo layer which is more reliable for custom Go Backends
|
||||
367
go-backend/internal/handlers/discover_handler.go
Normal file
367
go-backend/internal/handlers/discover_handler.go
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/patbritton/sojorn-backend/internal/models"
|
||||
"github.com/patbritton/sojorn-backend/internal/repository"
|
||||
"github.com/patbritton/sojorn-backend/internal/services"
|
||||
"github.com/patbritton/sojorn-backend/pkg/utils"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type DiscoverHandler struct {
|
||||
userRepo *repository.UserRepository
|
||||
postRepo *repository.PostRepository
|
||||
tagRepo *repository.TagRepository
|
||||
categoryRepo repository.CategoryRepository
|
||||
assetService *services.AssetService
|
||||
}
|
||||
|
||||
func NewDiscoverHandler(
|
||||
userRepo *repository.UserRepository,
|
||||
postRepo *repository.PostRepository,
|
||||
tagRepo *repository.TagRepository,
|
||||
categoryRepo repository.CategoryRepository,
|
||||
assetService *services.AssetService,
|
||||
) *DiscoverHandler {
|
||||
return &DiscoverHandler{
|
||||
userRepo: userRepo,
|
||||
postRepo: postRepo,
|
||||
tagRepo: tagRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
assetService: assetService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDiscover returns the discover page data
|
||||
// GET /api/v1/discover
|
||||
func (h *DiscoverHandler) GetDiscover(c *gin.Context) {
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
userID := userIDStr.(string)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
start := time.Now()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var trending []models.Hashtag
|
||||
var popularPosts []models.Post
|
||||
|
||||
wg.Add(2)
|
||||
|
||||
// Get top 4 tags
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
trending, err = h.tagRepo.GetTrendingHashtags(ctx, 4)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to get trending hashtags")
|
||||
}
|
||||
}()
|
||||
|
||||
// Get popular public posts
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
popularPosts, err = h.postRepo.GetPopularPublicPosts(ctx, userID, 20)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to get popular posts")
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Sign URLs
|
||||
for i := range popularPosts {
|
||||
h.signPostURLs(&popularPosts[i])
|
||||
}
|
||||
|
||||
// Ensure non-nil slices
|
||||
if trending == nil {
|
||||
trending = []models.Hashtag{}
|
||||
}
|
||||
if popularPosts == nil {
|
||||
popularPosts = []models.Post{}
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"top_tags": trending,
|
||||
"popular_posts": popularPosts,
|
||||
}
|
||||
|
||||
log.Debug().Dur("duration", time.Since(start)).Msg("Discover page generated")
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Search performs a combined search across users, hashtags, and posts
|
||||
// GET /api/v1/search
|
||||
func (h *DiscoverHandler) Search(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
searchType := c.Query("type") // "all", "users", "hashtags", "posts"
|
||||
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if searchType == "" {
|
||||
searchType = "all"
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
start := time.Now()
|
||||
|
||||
viewerID := ""
|
||||
if val, exists := c.Get("user_id"); exists {
|
||||
viewerID = val.(string)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var users []models.Profile
|
||||
var hashtags []models.Hashtag
|
||||
var posts []models.Post
|
||||
var tags []models.TagResult
|
||||
|
||||
if searchType == "all" || searchType == "users" {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
users, err = h.userRepo.SearchUsers(ctx, query, 10)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to search users")
|
||||
users = []models.Profile{}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if searchType == "all" || searchType == "hashtags" {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
hashtags, err = h.tagRepo.SearchHashtags(ctx, query, 10)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to search hashtags")
|
||||
hashtags = []models.Hashtag{}
|
||||
}
|
||||
// Also get legacy tag results for backward compatibility
|
||||
tags, _ = h.postRepo.SearchTags(ctx, query, 5)
|
||||
}()
|
||||
}
|
||||
|
||||
if searchType == "all" || searchType == "posts" {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
posts, err = h.postRepo.SearchPosts(ctx, query, viewerID, 20)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to search posts")
|
||||
posts = []models.Post{}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Sign URLs
|
||||
for i := range users {
|
||||
if users[i].AvatarURL != nil {
|
||||
signed := h.assetService.SignImageURL(*users[i].AvatarURL)
|
||||
users[i].AvatarURL = &signed
|
||||
}
|
||||
}
|
||||
for i := range posts {
|
||||
h.signPostURLs(&posts[i])
|
||||
}
|
||||
|
||||
// Ensure non-nil slices
|
||||
if users == nil {
|
||||
users = []models.Profile{}
|
||||
}
|
||||
if hashtags == nil {
|
||||
hashtags = []models.Hashtag{}
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []models.TagResult{}
|
||||
}
|
||||
if posts == nil {
|
||||
posts = []models.Post{}
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"users": users,
|
||||
"hashtags": hashtags,
|
||||
"tags": tags, // Legacy format
|
||||
"posts": posts,
|
||||
"query": query,
|
||||
}
|
||||
|
||||
log.Info().Str("query", query).Str("type", searchType).Dur("duration", time.Since(start)).Msg("Search completed")
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetHashtagPage returns posts for a specific hashtag
|
||||
// GET /api/v1/hashtags/:name
|
||||
func (h *DiscoverHandler) GetHashtagPage(c *gin.Context) {
|
||||
hashtagName := c.Param("name")
|
||||
if hashtagName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Hashtag name required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
limit := utils.GetQueryInt(c, "limit", 20)
|
||||
offset := utils.GetQueryInt(c, "offset", 0)
|
||||
|
||||
viewerID := ""
|
||||
if val, exists := c.Get("user_id"); exists {
|
||||
viewerID = val.(string)
|
||||
}
|
||||
|
||||
// Get hashtag info
|
||||
hashtag, err := h.tagRepo.GetHashtagByName(ctx, hashtagName)
|
||||
if err != nil || hashtag == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Hashtag not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user follows this hashtag
|
||||
isFollowing := false
|
||||
if viewerID != "" {
|
||||
userUUID, err := uuid.Parse(viewerID)
|
||||
if err == nil {
|
||||
isFollowing, _ = h.tagRepo.IsFollowingHashtag(ctx, userUUID, hashtag.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Get posts
|
||||
posts, err := h.tagRepo.GetPostsByHashtag(ctx, hashtagName, viewerID, limit, offset)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("hashtag", hashtagName).Msg("Failed to get posts by hashtag")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch posts"})
|
||||
return
|
||||
}
|
||||
|
||||
// Sign URLs
|
||||
for i := range posts {
|
||||
h.signPostURLs(&posts[i])
|
||||
}
|
||||
|
||||
if posts == nil {
|
||||
posts = []models.Post{}
|
||||
}
|
||||
|
||||
response := models.HashtagPageResponse{
|
||||
Hashtag: *hashtag,
|
||||
Posts: posts,
|
||||
IsFollowing: isFollowing,
|
||||
TotalPosts: hashtag.UseCount,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// FollowHashtag follows a hashtag
|
||||
// POST /api/v1/hashtags/:name/follow
|
||||
func (h *DiscoverHandler) FollowHashtag(c *gin.Context) {
|
||||
hashtagName := c.Param("name")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
userUUID, _ := uuid.Parse(userIDStr.(string))
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Get or create the hashtag
|
||||
hashtag, err := h.tagRepo.GetOrCreateHashtag(ctx, hashtagName)
|
||||
if err != nil || hashtag == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process hashtag"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tagRepo.FollowHashtag(ctx, userUUID, hashtag.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to follow hashtag"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Now following #" + hashtag.Name})
|
||||
}
|
||||
|
||||
// UnfollowHashtag unfollows a hashtag
|
||||
// DELETE /api/v1/hashtags/:name/follow
|
||||
func (h *DiscoverHandler) UnfollowHashtag(c *gin.Context) {
|
||||
hashtagName := c.Param("name")
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
userUUID, _ := uuid.Parse(userIDStr.(string))
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
hashtag, err := h.tagRepo.GetHashtagByName(ctx, hashtagName)
|
||||
if err != nil || hashtag == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Hashtag not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tagRepo.UnfollowHashtag(ctx, userUUID, hashtag.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unfollow hashtag"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Unfollowed #" + hashtag.Name})
|
||||
}
|
||||
|
||||
// GetTrendingHashtags returns trending hashtags
|
||||
// GET /api/v1/hashtags/trending
|
||||
func (h *DiscoverHandler) GetTrendingHashtags(c *gin.Context) {
|
||||
limit := utils.GetQueryInt(c, "limit", 20)
|
||||
|
||||
hashtags, err := h.tagRepo.GetTrendingHashtags(c.Request.Context(), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch trending hashtags"})
|
||||
return
|
||||
}
|
||||
|
||||
if hashtags == nil {
|
||||
hashtags = []models.Hashtag{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"hashtags": hashtags})
|
||||
}
|
||||
|
||||
// GetFollowedHashtags returns hashtags the user follows
|
||||
// GET /api/v1/hashtags/following
|
||||
func (h *DiscoverHandler) GetFollowedHashtags(c *gin.Context) {
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
userUUID, _ := uuid.Parse(userIDStr.(string))
|
||||
|
||||
hashtags, err := h.tagRepo.GetFollowedHashtags(c.Request.Context(), userUUID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch followed hashtags"})
|
||||
return
|
||||
}
|
||||
|
||||
if hashtags == nil {
|
||||
hashtags = []models.Hashtag{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"hashtags": hashtags})
|
||||
}
|
||||
|
||||
// signPostURLs signs all URLs in a post
|
||||
func (h *DiscoverHandler) signPostURLs(post *models.Post) {
|
||||
if post.ImageURL != nil {
|
||||
signed := h.assetService.SignImageURL(*post.ImageURL)
|
||||
post.ImageURL = &signed
|
||||
}
|
||||
if post.VideoURL != nil {
|
||||
signed := h.assetService.SignVideoURL(*post.VideoURL)
|
||||
post.VideoURL = &signed
|
||||
}
|
||||
if post.Author != nil && post.Author.AvatarURL != "" {
|
||||
post.Author.AvatarURL = h.assetService.SignImageURL(post.Author.AvatarURL)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,46 +2,281 @@ package handlers
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/patbritton/sojorn-backend/internal/models"
|
||||
"github.com/patbritton/sojorn-backend/internal/repository"
|
||||
"github.com/patbritton/sojorn-backend/internal/services"
|
||||
"github.com/patbritton/sojorn-backend/pkg/utils"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type NotificationHandler struct {
|
||||
repo *repository.NotificationRepository
|
||||
notifRepo *repository.NotificationRepository
|
||||
notifService *services.NotificationService
|
||||
}
|
||||
|
||||
func NewNotificationHandler(repo *repository.NotificationRepository) *NotificationHandler {
|
||||
return &NotificationHandler{repo: repo}
|
||||
func NewNotificationHandler(notifRepo *repository.NotificationRepository, notifService *services.NotificationService) *NotificationHandler {
|
||||
return &NotificationHandler{
|
||||
notifRepo: notifRepo,
|
||||
notifService: notifService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetNotifications retrieves paginated notifications for the user
|
||||
// GET /api/v1/notifications
|
||||
func (h *NotificationHandler) GetNotifications(c *gin.Context) {
|
||||
userIdStr, exists := c.Get("user_id")
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 20
|
||||
offset := 0
|
||||
limit := utils.GetQueryInt(c, "limit", 20)
|
||||
offset := utils.GetQueryInt(c, "offset", 0)
|
||||
grouped := c.Query("grouped") == "true"
|
||||
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if val, err := strconv.Atoi(l); err == nil {
|
||||
limit = val
|
||||
}
|
||||
}
|
||||
if o := c.Query("offset"); o != "" {
|
||||
if val, err := strconv.Atoi(o); err == nil {
|
||||
offset = val
|
||||
}
|
||||
var notifications []models.Notification
|
||||
var err error
|
||||
|
||||
if grouped {
|
||||
notifications, err = h.notifRepo.GetGroupedNotifications(c.Request.Context(), userIDStr.(string), limit, offset)
|
||||
} else {
|
||||
notifications, err = h.notifRepo.GetNotifications(c.Request.Context(), userIDStr.(string), limit, offset)
|
||||
}
|
||||
|
||||
notifications, err := h.repo.GetNotifications(c.Request.Context(), userIdStr.(string), limit, offset)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to fetch notifications")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, notifications)
|
||||
c.JSON(http.StatusOK, gin.H{"notifications": notifications})
|
||||
}
|
||||
|
||||
// GetUnreadCount returns the unread notification count
|
||||
// GET /api/v1/notifications/unread
|
||||
func (h *NotificationHandler) GetUnreadCount(c *gin.Context) {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.notifRepo.GetUnreadCount(c.Request.Context(), userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch unread count"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"count": count})
|
||||
}
|
||||
|
||||
// GetBadgeCount returns the badge count for app icon badges
|
||||
// GET /api/v1/notifications/badge
|
||||
func (h *NotificationHandler) GetBadgeCount(c *gin.Context) {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
badge, err := h.notifRepo.GetUnreadBadge(c.Request.Context(), userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch badge count"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, badge)
|
||||
}
|
||||
|
||||
// MarkAsRead marks a single notification as read
|
||||
// PUT /api/v1/notifications/:id/read
|
||||
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
notificationID := c.Param("id")
|
||||
if _, err := uuid.Parse(notificationID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.notifRepo.MarkAsRead(c.Request.Context(), notificationID, userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// MarkAllAsRead marks all notifications as read
|
||||
// PUT /api/v1/notifications/read-all
|
||||
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.notifRepo.MarkAllAsRead(c.Request.Context(), userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark all as read"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DeleteNotification deletes a notification
|
||||
// DELETE /api/v1/notifications/:id
|
||||
func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
notificationID := c.Param("id")
|
||||
if _, err := uuid.Parse(notificationID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.notifRepo.DeleteNotification(c.Request.Context(), notificationID, userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete notification"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// GetNotificationPreferences returns the user's notification preferences
|
||||
// GET /api/v1/notifications/preferences
|
||||
func (h *NotificationHandler) GetNotificationPreferences(c *gin.Context) {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
prefs, err := h.notifRepo.GetNotificationPreferences(c.Request.Context(), userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch preferences"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, prefs)
|
||||
}
|
||||
|
||||
// UpdateNotificationPreferences updates the user's notification preferences
|
||||
// PUT /api/v1/notifications/preferences
|
||||
func (h *NotificationHandler) UpdateNotificationPreferences(c *gin.Context) {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := uuid.Parse(userIDStr.(string))
|
||||
|
||||
var prefs models.NotificationPreferences
|
||||
if err := c.ShouldBindJSON(&prefs); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
prefs.UserID = userID
|
||||
|
||||
if err := h.notifRepo.UpdateNotificationPreferences(c.Request.Context(), &prefs); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to update notification preferences")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, prefs)
|
||||
}
|
||||
|
||||
// RegisterDevice registers an FCM token for push notifications
|
||||
// POST /api/v1/notifications/device
|
||||
func (h *NotificationHandler) RegisterDevice(c *gin.Context) {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
userID, _ := uuid.Parse(userIDStr.(string))
|
||||
|
||||
var req models.UserFCMToken
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
req.UserID = userID
|
||||
|
||||
if err := h.notifRepo.UpsertFCMToken(c.Request.Context(), &req); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to register device")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register device"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("user_id", userID.String()).
|
||||
Str("platform", req.Platform).
|
||||
Msg("FCM token registered")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Device registered"})
|
||||
}
|
||||
|
||||
// UnregisterDevice removes an FCM token
|
||||
// DELETE /api/v1/notifications/device
|
||||
func (h *NotificationHandler) UnregisterDevice(c *gin.Context) {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
FCMToken string `json:"fcm_token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.notifRepo.DeleteFCMToken(c.Request.Context(), userIDStr.(string), req.FCMToken); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to unregister device")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unregister device"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("user_id", userIDStr.(string)).
|
||||
Msg("FCM token unregistered")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Device unregistered"})
|
||||
}
|
||||
|
||||
// UnregisterAllDevices removes all FCM tokens for the user (logout from all devices)
|
||||
// DELETE /api/v1/notifications/devices
|
||||
func (h *NotificationHandler) UnregisterAllDevices(c *gin.Context) {
|
||||
userIDStr, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.notifRepo.DeleteAllFCMTokensForUser(c.Request.Context(), userIDStr.(string)); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to unregister all devices")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unregister devices"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "All devices unregistered"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,15 +20,17 @@ type PostHandler struct {
|
|||
feedService *services.FeedService
|
||||
assetService *services.AssetService
|
||||
notificationService *services.NotificationService
|
||||
moderationService *services.ModerationService
|
||||
}
|
||||
|
||||
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService) *PostHandler {
|
||||
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService) *PostHandler {
|
||||
return &PostHandler{
|
||||
postRepo: postRepo,
|
||||
userRepo: userRepo,
|
||||
feedService: feedService,
|
||||
assetService: assetService,
|
||||
notificationService: notificationService,
|
||||
moderationService: moderationService,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -236,6 +238,22 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// 5. AI Moderation Check
|
||||
if h.moderationService != nil {
|
||||
scores, reason, err := h.moderationService.AnalyzeContent(c.Request.Context(), req.Body)
|
||||
if err == nil {
|
||||
cis = (scores.Hate + scores.Greed + scores.Delusion) / 3.0
|
||||
cis = 1.0 - cis // Invert so 1.0 is pure, 0.0 is toxic
|
||||
post.CISScore = &cis
|
||||
post.ToneLabel = &reason
|
||||
|
||||
if reason != "" {
|
||||
// Flag if any poison is detected
|
||||
post.Status = "pending_moderation"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create post
|
||||
err = h.postRepo.CreatePost(c.Request.Context(), post)
|
||||
if err != nil {
|
||||
|
|
@ -243,6 +261,20 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Handle Flags
|
||||
if h.moderationService != nil && post.Status == "pending_moderation" {
|
||||
scores, reason, _ := h.moderationService.AnalyzeContent(c.Request.Context(), req.Body)
|
||||
_ = h.moderationService.FlagPost(c.Request.Context(), post.ID, scores, reason)
|
||||
}
|
||||
|
||||
// Check for @mentions and notify mentioned users
|
||||
go func() {
|
||||
if h.notificationService != nil && strings.Contains(req.Body, "@") {
|
||||
postIDStr := post.ID.String()
|
||||
h.notificationService.NotifyMention(c.Request.Context(), userIDStr.(string), postIDStr, req.Body)
|
||||
}
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"post": post,
|
||||
"tags": tags,
|
||||
|
|
@ -409,6 +441,31 @@ func (h *PostHandler) LikePost(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Send push notification to post author
|
||||
go func() {
|
||||
post, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string))
|
||||
if err != nil || post.AuthorID.String() == userIDStr.(string) {
|
||||
return // Don't notify self
|
||||
}
|
||||
|
||||
if h.notificationService != nil {
|
||||
postType := "standard"
|
||||
if post.IsBeacon {
|
||||
postType = "beacon"
|
||||
} else if post.VideoURL != nil && *post.VideoURL != "" {
|
||||
postType = "quip"
|
||||
}
|
||||
|
||||
h.notificationService.NotifyLike(
|
||||
c.Request.Context(),
|
||||
post.AuthorID.String(),
|
||||
userIDStr.(string),
|
||||
postID,
|
||||
postType,
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Post liked"})
|
||||
}
|
||||
|
||||
|
|
@ -435,6 +492,42 @@ func (h *PostHandler) SavePost(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Send push notification to post author
|
||||
go func() {
|
||||
post, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string))
|
||||
if err != nil || post.AuthorID.String() == userIDStr.(string) {
|
||||
return // Don't notify self
|
||||
}
|
||||
|
||||
actor, err := h.userRepo.GetProfileByID(c.Request.Context(), userIDStr.(string))
|
||||
if err != nil || h.notificationService == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine post type for proper deep linking
|
||||
postType := "standard"
|
||||
if post.IsBeacon {
|
||||
postType = "beacon"
|
||||
} else if post.VideoURL != nil && *post.VideoURL != "" {
|
||||
postType = "quip"
|
||||
}
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"actor_name": actor.DisplayName,
|
||||
"post_id": postID,
|
||||
"post_type": postType,
|
||||
}
|
||||
h.notificationService.CreateNotification(
|
||||
c.Request.Context(),
|
||||
post.AuthorID.String(),
|
||||
userIDStr.(string),
|
||||
"save",
|
||||
&postID,
|
||||
nil,
|
||||
metadata,
|
||||
)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Post saved"})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -355,3 +355,156 @@ func (h *UserHandler) RejectFollowRequest(c *gin.Context) {
|
|||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Follow request rejected"})
|
||||
}
|
||||
|
||||
func (h *UserHandler) BlockUser(c *gin.Context) {
|
||||
blockerID, _ := c.Get("user_id")
|
||||
blockedID := c.Param("id")
|
||||
actorIP := c.ClientIP()
|
||||
|
||||
if err := h.repo.BlockUser(c.Request.Context(), blockerID.(string), blockedID, actorIP); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to block user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Also unfollow automatically
|
||||
_ = h.repo.UnfollowUser(c.Request.Context(), blockerID.(string), blockedID)
|
||||
_ = h.repo.UnfollowUser(c.Request.Context(), blockedID, blockerID.(string))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "User blocked"})
|
||||
}
|
||||
|
||||
func (h *UserHandler) ReportUser(c *gin.Context) {
|
||||
reporterID, _ := c.Get("user_id")
|
||||
|
||||
var input struct {
|
||||
TargetUserID string `json:"target_user_id" binding:"required"`
|
||||
PostID string `json:"post_id"`
|
||||
CommentID string `json:"comment_id"`
|
||||
ViolationType string `json:"violation_type" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
rID, _ := uuid.Parse(reporterID.(string))
|
||||
tID, _ := uuid.Parse(input.TargetUserID)
|
||||
|
||||
report := &models.Report{
|
||||
ReporterID: rID,
|
||||
TargetUserID: tID,
|
||||
ViolationType: input.ViolationType,
|
||||
Description: input.Description,
|
||||
}
|
||||
|
||||
if input.PostID != "" {
|
||||
pID, _ := uuid.Parse(input.PostID)
|
||||
report.PostID = &pID
|
||||
}
|
||||
if input.CommentID != "" {
|
||||
cID, _ := uuid.Parse(input.CommentID)
|
||||
report.CommentID = &cID
|
||||
}
|
||||
|
||||
if err := h.repo.CreateReport(c.Request.Context(), report); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create report"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Report submitted successfully"})
|
||||
}
|
||||
|
||||
func (h *UserHandler) UnblockUser(c *gin.Context) {
|
||||
blockerID, _ := c.Get("user_id")
|
||||
blockedID := c.Param("id")
|
||||
|
||||
if err := h.repo.UnblockUser(c.Request.Context(), blockerID.(string), blockedID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unblock user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "User unblocked"})
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetBlockedUsers(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
blocked, err := h.repo.GetBlockedUsers(c.Request.Context(), userID.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch blocked users"})
|
||||
return
|
||||
}
|
||||
|
||||
// Sign URLs
|
||||
for i := range blocked {
|
||||
if blocked[i].AvatarURL != nil {
|
||||
signed := h.assetService.SignImageURL(*blocked[i].AvatarURL)
|
||||
blocked[i].AvatarURL = &signed
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"users": blocked})
|
||||
}
|
||||
|
||||
func (h *UserHandler) RemoveFCMToken(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
var input struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.DeleteFCMToken(c.Request.Context(), userID.(string), input.Token); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Token removed successfully"})
|
||||
}
|
||||
func (h *UserHandler) BlockUserByHandle(c *gin.Context) {
|
||||
actorID, _ := c.Get("user_id")
|
||||
actorIP := c.ClientIP()
|
||||
|
||||
var input struct {
|
||||
Handle string `json:"handle" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Handle is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.BlockUserByHandle(c.Request.Context(), actorID.(string), input.Handle, actorIP); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to block user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "User blocked by handle"})
|
||||
}
|
||||
|
||||
func (h *UserHandler) RegisterFCMToken(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
var input struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Token and platform are required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertFCMToken(c.Request.Context(), userID.(string), input.Token, input.Platform); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Token registered successfully"})
|
||||
}
|
||||
|
|
|
|||
37
go-backend/internal/models/moderation.go
Normal file
37
go-backend/internal/models/moderation.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Report struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ReporterID uuid.UUID `json:"reporter_id"`
|
||||
TargetUserID uuid.UUID `json:"target_user_id"`
|
||||
PostID *uuid.UUID `json:"post_id,omitempty"`
|
||||
CommentID *uuid.UUID `json:"comment_id,omitempty"`
|
||||
ViolationType string `json:"violation_type"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type AbuseLog struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ActorID *uuid.UUID `json:"actor_id,omitempty"`
|
||||
BlockedID uuid.UUID `json:"blocked_id"`
|
||||
BlockedHandle string `json:"blocked_handle"`
|
||||
ActorIP string `json:"actor_ip"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ModerationFlag struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
PostID *uuid.UUID `json:"post_id,omitempty"`
|
||||
CommentID *uuid.UUID `json:"comment_id,omitempty"`
|
||||
FlagReason string `json:"flag_reason"`
|
||||
Scores map[string]float64 `json:"scores"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
|
@ -7,22 +7,53 @@ import (
|
|||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// NotificationType constants for type safety
|
||||
const (
|
||||
NotificationTypeLike = "like"
|
||||
NotificationTypeComment = "comment"
|
||||
NotificationTypeReply = "reply"
|
||||
NotificationTypeMention = "mention"
|
||||
NotificationTypeFollow = "follow"
|
||||
NotificationTypeFollowRequest = "follow_request"
|
||||
NotificationTypeFollowAccept = "follow_accepted"
|
||||
NotificationTypeMessage = "message"
|
||||
NotificationTypeSave = "save"
|
||||
NotificationTypeBeaconVouch = "beacon_vouch"
|
||||
NotificationTypeBeaconReport = "beacon_report"
|
||||
NotificationTypeShare = "share"
|
||||
NotificationTypeQuipReaction = "quip_reaction"
|
||||
)
|
||||
|
||||
// NotificationPriority constants
|
||||
const (
|
||||
PriorityLow = "low"
|
||||
PriorityNormal = "normal"
|
||||
PriorityHigh = "high"
|
||||
PriorityUrgent = "urgent"
|
||||
)
|
||||
|
||||
type Notification struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
UserID uuid.UUID `json:"user_id" db:"user_id"`
|
||||
Type string `json:"type" db:"type"` // like, comment, follow, reply, mention
|
||||
Type string `json:"type" db:"type"`
|
||||
ActorID uuid.UUID `json:"actor_id" db:"actor_id"`
|
||||
PostID *uuid.UUID `json:"post_id,omitempty" db:"post_id"`
|
||||
CommentID *uuid.UUID `json:"comment_id,omitempty" db:"comment_id"`
|
||||
IsRead bool `json:"is_read" db:"is_read"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
Metadata json.RawMessage `json:"metadata" db:"metadata"`
|
||||
GroupKey *string `json:"group_key,omitempty" db:"group_key"`
|
||||
Priority string `json:"priority" db:"priority"`
|
||||
|
||||
// Joined fields
|
||||
// Joined fields for display
|
||||
ActorHandle string `json:"actor_handle" db:"actor_handle"`
|
||||
ActorDisplayName string `json:"actor_display_name" db:"actor_display_name"`
|
||||
ActorAvatarURL string `json:"actor_avatar_url" db:"actor_avatar_url"`
|
||||
PostImageURL *string `json:"post_image_url,omitempty" db:"post_image_url"` // Preview of post
|
||||
PostImageURL *string `json:"post_image_url,omitempty" db:"post_image_url"`
|
||||
PostBody *string `json:"post_body,omitempty" db:"post_body"`
|
||||
|
||||
// For grouped notifications
|
||||
GroupCount int `json:"group_count,omitempty" db:"group_count"`
|
||||
}
|
||||
|
||||
type UserFCMToken struct {
|
||||
|
|
@ -32,3 +63,68 @@ type UserFCMToken struct {
|
|||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type NotificationPreferences struct {
|
||||
UserID uuid.UUID `json:"user_id" db:"user_id"`
|
||||
|
||||
// Push toggles
|
||||
PushEnabled bool `json:"push_enabled" db:"push_enabled"`
|
||||
PushLikes bool `json:"push_likes" db:"push_likes"`
|
||||
PushComments bool `json:"push_comments" db:"push_comments"`
|
||||
PushReplies bool `json:"push_replies" db:"push_replies"`
|
||||
PushMentions bool `json:"push_mentions" db:"push_mentions"`
|
||||
PushFollows bool `json:"push_follows" db:"push_follows"`
|
||||
PushFollowRequests bool `json:"push_follow_requests" db:"push_follow_requests"`
|
||||
PushMessages bool `json:"push_messages" db:"push_messages"`
|
||||
PushSaves bool `json:"push_saves" db:"push_saves"`
|
||||
PushBeacons bool `json:"push_beacons" db:"push_beacons"`
|
||||
|
||||
// Email toggles
|
||||
EmailEnabled bool `json:"email_enabled" db:"email_enabled"`
|
||||
EmailDigestFrequency string `json:"email_digest_frequency" db:"email_digest_frequency"`
|
||||
|
||||
// Quiet hours
|
||||
QuietHoursEnabled bool `json:"quiet_hours_enabled" db:"quiet_hours_enabled"`
|
||||
QuietHoursStart *string `json:"quiet_hours_start,omitempty" db:"quiet_hours_start"` // "22:00:00"
|
||||
QuietHoursEnd *string `json:"quiet_hours_end,omitempty" db:"quiet_hours_end"` // "08:00:00"
|
||||
|
||||
// Badge
|
||||
ShowBadgeCount bool `json:"show_badge_count" db:"show_badge_count"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// NotificationPayload is the structure sent to FCM
|
||||
type NotificationPayload struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
Data map[string]string `json:"data"`
|
||||
Priority string `json:"priority"`
|
||||
Badge int `json:"badge,omitempty"`
|
||||
}
|
||||
|
||||
// PushNotificationRequest for internal use
|
||||
type PushNotificationRequest struct {
|
||||
UserID uuid.UUID
|
||||
Type string
|
||||
ActorID uuid.UUID
|
||||
ActorName string
|
||||
ActorAvatar string
|
||||
PostID *uuid.UUID
|
||||
CommentID *uuid.UUID
|
||||
PostType string // "standard", "quip", "beacon"
|
||||
PostPreview string // First ~50 chars of post body
|
||||
PostImageURL string
|
||||
GroupKey string
|
||||
Priority string
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
// UnreadBadge for badge count responses
|
||||
type UnreadBadge struct {
|
||||
NotificationCount int `json:"notification_count"`
|
||||
MessageCount int `json:"message_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,16 @@ import (
|
|||
)
|
||||
|
||||
type PrivacySettings struct {
|
||||
UserID uuid.UUID `json:"user_id" db:"user_id"`
|
||||
ShowLocation *bool `json:"show_location" db:"show_location"`
|
||||
ShowInterests *bool `json:"show_interests" db:"show_interests"`
|
||||
ProfileVisibility *string `json:"profile_visibility" db:"profile_visibility"`
|
||||
PostsVisibility *string `json:"posts_visibility" db:"posts_visibility"`
|
||||
SavedVisibility *string `json:"saved_visibility" db:"saved_visibility"`
|
||||
FollowRequestPolicy *string `json:"follow_request_policy" db:"follow_request_policy"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
UserID uuid.UUID `json:"user_id" db:"user_id"`
|
||||
ShowLocation *bool `json:"show_location" db:"show_location"`
|
||||
ShowInterests *bool `json:"show_interests" db:"show_interests"`
|
||||
ProfileVisibility *string `json:"profile_visibility" db:"profile_visibility"`
|
||||
PostsVisibility *string `json:"posts_visibility" db:"posts_visibility"`
|
||||
SavedVisibility *string `json:"saved_visibility" db:"saved_visibility"`
|
||||
FollowRequestPolicy *string `json:"follow_request_policy" db:"follow_request_policy"`
|
||||
DefaultPostVisibility *string `json:"default_post_visibility" db:"default_post_visibility"`
|
||||
IsPrivateProfile *bool `json:"is_private_profile" db:"is_private_profile"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type UserSettings struct {
|
||||
|
|
|
|||
90
go-backend/internal/models/tag.go
Normal file
90
go-backend/internal/models/tag.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Hashtag represents a hashtag in the system
|
||||
type Hashtag struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"` // lowercase, without #
|
||||
DisplayName string `json:"display_name" db:"display_name"` // original casing
|
||||
UseCount int `json:"use_count" db:"use_count"`
|
||||
TrendingScore float64 `json:"trending_score" db:"trending_score"`
|
||||
IsTrending bool `json:"is_trending" db:"is_trending"`
|
||||
IsFeatured bool `json:"is_featured" db:"is_featured"`
|
||||
Category *string `json:"category,omitempty" db:"category"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
|
||||
// Computed fields
|
||||
RecentCount int `json:"recent_count,omitempty" db:"-"`
|
||||
IsFollowing bool `json:"is_following,omitempty" db:"-"`
|
||||
}
|
||||
|
||||
// NOTE: TagResult is defined in post.go
|
||||
|
||||
// PostMention represents an @mention in a post
|
||||
type PostMention struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
PostID uuid.UUID `json:"post_id" db:"post_id"`
|
||||
MentionedUserID uuid.UUID `json:"mentioned_user_id" db:"mentioned_user_id"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
|
||||
// HashtagFollow represents a user following a hashtag
|
||||
type HashtagFollow struct {
|
||||
UserID uuid.UUID `json:"user_id" db:"user_id"`
|
||||
HashtagID uuid.UUID `json:"hashtag_id" db:"hashtag_id"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
|
||||
// SuggestedUser for discover page
|
||||
type SuggestedUser struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
IsOfficial bool `json:"is_official"`
|
||||
Reason string `json:"reason"` // e.g., "popular", "similar", "category"
|
||||
Category *string `json:"category,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
FollowerCount int `json:"follower_count"`
|
||||
}
|
||||
|
||||
// TrendingHashtag for trending display
|
||||
type TrendingHashtag struct {
|
||||
Hashtag
|
||||
Rank int `json:"rank" db:"rank"`
|
||||
PostCountInPeriod int `json:"post_count_in_period" db:"post_count_in_period"`
|
||||
}
|
||||
|
||||
// DiscoverSection represents a section in the discover page
|
||||
type DiscoverSection struct {
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"` // "hashtags", "users", "posts", "categories"
|
||||
Items interface{} `json:"items"`
|
||||
ViewAllID string `json:"view_all_id,omitempty"` // ID or slug for "View All" link
|
||||
}
|
||||
|
||||
// DiscoverResponse is the full discover page response
|
||||
type DiscoverResponse struct {
|
||||
TrendingHashtags []Hashtag `json:"trending_hashtags"`
|
||||
FeaturedHashtags []Hashtag `json:"featured_hashtags,omitempty"`
|
||||
SuggestedUsers []SuggestedUser `json:"suggested_users"`
|
||||
PopularCreators []Profile `json:"popular_creators,omitempty"`
|
||||
TrendingPosts []Post `json:"trending_posts,omitempty"`
|
||||
FollowedHashtags []Hashtag `json:"followed_hashtags,omitempty"`
|
||||
Categories []Category `json:"categories,omitempty"`
|
||||
}
|
||||
|
||||
// HashtagPageResponse for viewing a specific hashtag
|
||||
type HashtagPageResponse struct {
|
||||
Hashtag Hashtag `json:"hashtag"`
|
||||
Posts []Post `json:"posts"`
|
||||
IsFollowing bool `json:"is_following"`
|
||||
TotalPosts int `json:"total_posts"`
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ type Profile struct {
|
|||
CoverURL *string `json:"cover_url" db:"cover_url"`
|
||||
IsOfficial *bool `json:"is_official" db:"is_official"`
|
||||
IsPrivate *bool `json:"is_private" db:"is_private"`
|
||||
IsVerified *bool `json:"is_verified" db:"is_verified"`
|
||||
BeaconEnabled bool `json:"beacon_enabled" db:"beacon_enabled"`
|
||||
Location *string `json:"location" db:"location"`
|
||||
Website *string `json:"website" db:"website"`
|
||||
|
|
@ -48,6 +49,10 @@ type Profile struct {
|
|||
Role string `json:"role" db:"role"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
|
||||
// Computed fields (not stored in DB)
|
||||
FollowerCount *int `json:"follower_count,omitempty" db:"-"`
|
||||
FollowingCount *int `json:"following_count,omitempty" db:"-"`
|
||||
}
|
||||
|
||||
type Follow struct {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ package repository
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/patbritton/sojorn-backend/internal/models"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type NotificationRepository struct {
|
||||
|
|
@ -16,6 +19,10 @@ func NewNotificationRepository(pool *pgxpool.Pool) *NotificationRepository {
|
|||
return &NotificationRepository{pool: pool}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FCM Token Management
|
||||
// ============================================================================
|
||||
|
||||
func (r *NotificationRepository) UpsertFCMToken(ctx context.Context, token *models.UserFCMToken) error {
|
||||
query := `
|
||||
INSERT INTO public.user_fcm_tokens (user_id, token, device_type, created_at, last_updated)
|
||||
|
|
@ -60,25 +67,52 @@ func (r *NotificationRepository) GetFCMTokensForUser(ctx context.Context, userID
|
|||
}
|
||||
|
||||
func (r *NotificationRepository) DeleteFCMToken(ctx context.Context, userID string, token string) error {
|
||||
commandTag, err := r.pool.Exec(ctx, `
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
DELETE FROM public.user_fcm_tokens
|
||||
WHERE user_id = $1::uuid AND token = $2
|
||||
`, userID, token)
|
||||
if err != nil {
|
||||
return err
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *NotificationRepository) DeleteAllFCMTokensForUser(ctx context.Context, userID string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
DELETE FROM public.user_fcm_tokens WHERE user_id = $1::uuid
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Notification CRUD
|
||||
// ============================================================================
|
||||
|
||||
func (r *NotificationRepository) CreateNotification(ctx context.Context, notif *models.Notification) error {
|
||||
query := `
|
||||
INSERT INTO public.notifications (user_id, type, actor_id, post_id, comment_id, is_read, metadata, group_key, priority)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, created_at
|
||||
`
|
||||
|
||||
priority := notif.Priority
|
||||
if priority == "" {
|
||||
priority = models.PriorityNormal
|
||||
}
|
||||
if commandTag.RowsAffected() == 0 {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
|
||||
err := r.pool.QueryRow(ctx, query,
|
||||
notif.UserID, notif.Type, notif.ActorID, notif.PostID, notif.CommentID, notif.IsRead, notif.Metadata, notif.GroupKey, priority,
|
||||
).Scan(¬if.ID, ¬if.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *NotificationRepository) GetNotifications(ctx context.Context, userID string, limit, offset int) ([]models.Notification, error) {
|
||||
query := `
|
||||
SELECT
|
||||
n.id, n.user_id, n.type, n.actor_id, n.post_id, n.comment_id, n.is_read, n.created_at, n.metadata,
|
||||
COALESCE(n.group_key, '') as group_key,
|
||||
COALESCE(n.priority, 'normal') as priority,
|
||||
pr.handle, pr.display_name, COALESCE(pr.avatar_url, ''),
|
||||
po.image_url
|
||||
po.image_url,
|
||||
LEFT(po.body, 100) as post_body
|
||||
FROM public.notifications n
|
||||
JOIN public.profiles pr ON n.actor_id = pr.id
|
||||
LEFT JOIN public.posts po ON n.post_id = po.id
|
||||
|
|
@ -95,34 +129,417 @@ func (r *NotificationRepository) GetNotifications(ctx context.Context, userID st
|
|||
notifications := []models.Notification{}
|
||||
for rows.Next() {
|
||||
var n models.Notification
|
||||
var groupKey string
|
||||
var postImageURL *string
|
||||
var postBody *string
|
||||
err := rows.Scan(
|
||||
&n.ID, &n.UserID, &n.Type, &n.ActorID, &n.PostID, &n.CommentID, &n.IsRead, &n.CreatedAt, &n.Metadata,
|
||||
&groupKey, &n.Priority,
|
||||
&n.ActorHandle, &n.ActorDisplayName, &n.ActorAvatarURL,
|
||||
&postImageURL,
|
||||
&postImageURL, &postBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if groupKey != "" {
|
||||
n.GroupKey = &groupKey
|
||||
}
|
||||
n.PostImageURL = postImageURL
|
||||
n.PostBody = postBody
|
||||
notifications = append(notifications, n)
|
||||
}
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func (r *NotificationRepository) CreateNotification(ctx context.Context, notif *models.Notification) error {
|
||||
// GetGroupedNotifications returns notifications with grouping (e.g., "5 people liked your post")
|
||||
func (r *NotificationRepository) GetGroupedNotifications(ctx context.Context, userID string, limit, offset int) ([]models.Notification, error) {
|
||||
query := `
|
||||
INSERT INTO public.notifications (user_id, type, actor_id, post_id, comment_id, is_read, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, created_at
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
n.*,
|
||||
pr.handle as actor_handle,
|
||||
pr.display_name as actor_display_name,
|
||||
COALESCE(pr.avatar_url, '') as actor_avatar_url,
|
||||
po.image_url as post_image_url,
|
||||
LEFT(po.body, 100) as post_body,
|
||||
COUNT(*) OVER (PARTITION BY n.group_key) as group_count,
|
||||
ROW_NUMBER() OVER (PARTITION BY n.group_key ORDER BY n.created_at DESC) as rn
|
||||
FROM public.notifications n
|
||||
JOIN public.profiles pr ON n.actor_id = pr.id
|
||||
LEFT JOIN public.posts po ON n.post_id = po.id
|
||||
WHERE n.user_id = $1::uuid
|
||||
)
|
||||
SELECT
|
||||
id, user_id, type, actor_id, post_id, comment_id, is_read, created_at, metadata,
|
||||
COALESCE(group_key, '') as group_key,
|
||||
COALESCE(priority, 'normal') as priority,
|
||||
actor_handle, actor_display_name, actor_avatar_url,
|
||||
post_image_url, post_body,
|
||||
group_count
|
||||
FROM ranked
|
||||
WHERE rn = 1 OR group_key IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
err := r.pool.QueryRow(ctx, query,
|
||||
notif.UserID, notif.Type, notif.ActorID, notif.PostID, notif.CommentID, notif.IsRead, notif.Metadata,
|
||||
).Scan(¬if.ID, ¬if.CreatedAt)
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
notifications := []models.Notification{}
|
||||
for rows.Next() {
|
||||
var n models.Notification
|
||||
var groupKey string
|
||||
err := rows.Scan(
|
||||
&n.ID, &n.UserID, &n.Type, &n.ActorID, &n.PostID, &n.CommentID, &n.IsRead, &n.CreatedAt, &n.Metadata,
|
||||
&groupKey, &n.Priority,
|
||||
&n.ActorHandle, &n.ActorDisplayName, &n.ActorAvatarURL,
|
||||
&n.PostImageURL, &n.PostBody,
|
||||
&n.GroupCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if groupKey != "" {
|
||||
n.GroupKey = &groupKey
|
||||
}
|
||||
notifications = append(notifications, n)
|
||||
}
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func (r *NotificationRepository) MarkAsRead(ctx context.Context, notificationID, userID string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE public.notifications SET is_read = TRUE
|
||||
WHERE id = $1::uuid AND user_id = $2::uuid
|
||||
`, notificationID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *NotificationRepository) MarkAllAsRead(ctx context.Context, userID string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE public.notifications SET is_read = TRUE
|
||||
WHERE user_id = $1::uuid AND is_read = FALSE
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *NotificationRepository) DeleteNotification(ctx context.Context, notificationID, userID string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
DELETE FROM public.notifications
|
||||
WHERE id = $1::uuid AND user_id = $2::uuid
|
||||
`, notificationID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *NotificationRepository) GetUnreadCount(ctx context.Context, userID string) (int, error) {
|
||||
var count int
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM public.notifications
|
||||
WHERE user_id = $1::uuid AND is_read = FALSE
|
||||
`, userID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *NotificationRepository) GetUnreadBadge(ctx context.Context, userID string) (*models.UnreadBadge, error) {
|
||||
badge := &models.UnreadBadge{}
|
||||
|
||||
// Get notification count
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(unread_notification_count, 0) FROM profiles WHERE id = $1::uuid
|
||||
`, userID).Scan(&badge.NotificationCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil
|
||||
// Get unread message count
|
||||
err = r.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM encrypted_messages
|
||||
WHERE receiver_id = $1::uuid AND is_read = FALSE
|
||||
`, userID).Scan(&badge.MessageCount)
|
||||
if err != nil {
|
||||
// Table might not exist or column missing, ignore
|
||||
badge.MessageCount = 0
|
||||
}
|
||||
|
||||
badge.TotalCount = badge.NotificationCount + badge.MessageCount
|
||||
return badge, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Notification Preferences
|
||||
// ============================================================================
|
||||
|
||||
func (r *NotificationRepository) GetNotificationPreferences(ctx context.Context, userID string) (*models.NotificationPreferences, error) {
|
||||
prefs := &models.NotificationPreferences{}
|
||||
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
user_id, push_enabled, push_likes, push_comments, push_replies, push_mentions,
|
||||
push_follows, push_follow_requests, push_messages, push_saves, push_beacons,
|
||||
email_enabled, email_digest_frequency, quiet_hours_enabled,
|
||||
quiet_hours_start::text, quiet_hours_end::text, show_badge_count,
|
||||
created_at, updated_at
|
||||
FROM notification_preferences
|
||||
WHERE user_id = $1::uuid
|
||||
`, userID).Scan(
|
||||
&prefs.UserID, &prefs.PushEnabled, &prefs.PushLikes, &prefs.PushComments, &prefs.PushReplies, &prefs.PushMentions,
|
||||
&prefs.PushFollows, &prefs.PushFollowRequests, &prefs.PushMessages, &prefs.PushSaves, &prefs.PushBeacons,
|
||||
&prefs.EmailEnabled, &prefs.EmailDigestFrequency, &prefs.QuietHoursEnabled,
|
||||
&prefs.QuietHoursStart, &prefs.QuietHoursEnd, &prefs.ShowBadgeCount,
|
||||
&prefs.CreatedAt, &prefs.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
// Return defaults if not found
|
||||
return r.createDefaultPreferences(ctx, userID)
|
||||
}
|
||||
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
func (r *NotificationRepository) createDefaultPreferences(ctx context.Context, userID string) (*models.NotificationPreferences, error) {
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prefs := &models.NotificationPreferences{
|
||||
UserID: userUUID,
|
||||
PushEnabled: true,
|
||||
PushLikes: true,
|
||||
PushComments: true,
|
||||
PushReplies: true,
|
||||
PushMentions: true,
|
||||
PushFollows: true,
|
||||
PushFollowRequests: true,
|
||||
PushMessages: true,
|
||||
PushSaves: true,
|
||||
PushBeacons: true,
|
||||
EmailEnabled: false,
|
||||
EmailDigestFrequency: "never",
|
||||
QuietHoursEnabled: false,
|
||||
ShowBadgeCount: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_, err = r.pool.Exec(ctx, `
|
||||
INSERT INTO notification_preferences (user_id, push_enabled, push_likes, push_comments, push_replies, push_mentions,
|
||||
push_follows, push_follow_requests, push_messages, push_saves, push_beacons,
|
||||
email_enabled, email_digest_frequency, quiet_hours_enabled, show_badge_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
`,
|
||||
prefs.UserID, prefs.PushEnabled, prefs.PushLikes, prefs.PushComments, prefs.PushReplies, prefs.PushMentions,
|
||||
prefs.PushFollows, prefs.PushFollowRequests, prefs.PushMessages, prefs.PushSaves, prefs.PushBeacons,
|
||||
prefs.EmailEnabled, prefs.EmailDigestFrequency, prefs.QuietHoursEnabled, prefs.ShowBadgeCount,
|
||||
)
|
||||
|
||||
return prefs, err
|
||||
}
|
||||
|
||||
func (r *NotificationRepository) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO notification_preferences (
|
||||
user_id, push_enabled, push_likes, push_comments, push_replies, push_mentions,
|
||||
push_follows, push_follow_requests, push_messages, push_saves, push_beacons,
|
||||
email_enabled, email_digest_frequency, quiet_hours_enabled, quiet_hours_start, quiet_hours_end,
|
||||
show_badge_count, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::time, $16::time, $17, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
push_enabled = EXCLUDED.push_enabled,
|
||||
push_likes = EXCLUDED.push_likes,
|
||||
push_comments = EXCLUDED.push_comments,
|
||||
push_replies = EXCLUDED.push_replies,
|
||||
push_mentions = EXCLUDED.push_mentions,
|
||||
push_follows = EXCLUDED.push_follows,
|
||||
push_follow_requests = EXCLUDED.push_follow_requests,
|
||||
push_messages = EXCLUDED.push_messages,
|
||||
push_saves = EXCLUDED.push_saves,
|
||||
push_beacons = EXCLUDED.push_beacons,
|
||||
email_enabled = EXCLUDED.email_enabled,
|
||||
email_digest_frequency = EXCLUDED.email_digest_frequency,
|
||||
quiet_hours_enabled = EXCLUDED.quiet_hours_enabled,
|
||||
quiet_hours_start = EXCLUDED.quiet_hours_start,
|
||||
quiet_hours_end = EXCLUDED.quiet_hours_end,
|
||||
show_badge_count = EXCLUDED.show_badge_count,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
prefs.UserID, prefs.PushEnabled, prefs.PushLikes, prefs.PushComments, prefs.PushReplies, prefs.PushMentions,
|
||||
prefs.PushFollows, prefs.PushFollowRequests, prefs.PushMessages, prefs.PushSaves, prefs.PushBeacons,
|
||||
prefs.EmailEnabled, prefs.EmailDigestFrequency, prefs.QuietHoursEnabled, prefs.QuietHoursStart, prefs.QuietHoursEnd,
|
||||
prefs.ShowBadgeCount,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ShouldSendPush checks user preferences and quiet hours to determine if push should be sent
|
||||
func (r *NotificationRepository) ShouldSendPush(ctx context.Context, userID, notificationType string) (bool, error) {
|
||||
prefs, err := r.GetNotificationPreferences(ctx, userID)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("user_id", userID).Msg("Failed to get notification preferences, defaulting to send")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if !prefs.PushEnabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check quiet hours
|
||||
if prefs.QuietHoursEnabled && prefs.QuietHoursStart != nil && prefs.QuietHoursEnd != nil {
|
||||
if r.isInQuietHours(*prefs.QuietHoursStart, *prefs.QuietHoursEnd) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check specific notification type
|
||||
switch notificationType {
|
||||
case models.NotificationTypeLike:
|
||||
return prefs.PushLikes, nil
|
||||
case models.NotificationTypeComment:
|
||||
return prefs.PushComments, nil
|
||||
case models.NotificationTypeReply:
|
||||
return prefs.PushReplies, nil
|
||||
case models.NotificationTypeMention:
|
||||
return prefs.PushMentions, nil
|
||||
case models.NotificationTypeFollow:
|
||||
return prefs.PushFollows, nil
|
||||
case models.NotificationTypeFollowRequest:
|
||||
return prefs.PushFollowRequests, nil
|
||||
case models.NotificationTypeMessage:
|
||||
return prefs.PushMessages, nil
|
||||
case models.NotificationTypeSave:
|
||||
return prefs.PushSaves, nil
|
||||
case models.NotificationTypeBeaconVouch, models.NotificationTypeBeaconReport:
|
||||
return prefs.PushBeacons, nil
|
||||
default:
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *NotificationRepository) isInQuietHours(start, end string) bool {
|
||||
now := time.Now().UTC()
|
||||
currentTime := now.Format("15:04:05")
|
||||
|
||||
// Simple string comparison for time ranges
|
||||
// Handle cases where quiet hours span midnight
|
||||
if start > end {
|
||||
// Spans midnight: 22:00 -> 08:00
|
||||
return currentTime >= start || currentTime <= end
|
||||
}
|
||||
// Same day: 23:00 -> 23:59
|
||||
return currentTime >= start && currentTime <= end
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mention Extraction
|
||||
// ============================================================================
|
||||
|
||||
// ExtractMentions finds @username patterns in text and returns user IDs
|
||||
func (r *NotificationRepository) ExtractMentions(ctx context.Context, text string) ([]uuid.UUID, error) {
|
||||
// Extract @mentions using regex
|
||||
mentions := extractMentionHandles(text)
|
||||
if len(mentions) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Look up user IDs for handles
|
||||
query := `SELECT id FROM profiles WHERE handle = ANY($1)`
|
||||
rows, err := r.pool.Query(ctx, query, mentions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userIDs []uuid.UUID
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
userIDs = append(userIDs, id)
|
||||
}
|
||||
|
||||
return userIDs, nil
|
||||
}
|
||||
|
||||
func extractMentionHandles(text string) []string {
|
||||
var mentions []string
|
||||
inMention := false
|
||||
current := ""
|
||||
|
||||
for i, r := range text {
|
||||
if r == '@' {
|
||||
inMention = true
|
||||
current = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if inMention {
|
||||
// Valid handle characters: alphanumeric and underscore
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
|
||||
current += string(r)
|
||||
} else {
|
||||
if len(current) > 0 {
|
||||
mentions = append(mentions, current)
|
||||
}
|
||||
inMention = false
|
||||
current = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Check end of string
|
||||
if i == len(text)-1 && inMention && len(current) > 0 {
|
||||
mentions = append(mentions, current)
|
||||
}
|
||||
}
|
||||
|
||||
return mentions
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Notification Cleanup
|
||||
// ============================================================================
|
||||
|
||||
// DeleteOldNotifications removes notifications older than the specified days
|
||||
func (r *NotificationRepository) DeleteOldNotifications(ctx context.Context, daysOld int) (int64, error) {
|
||||
result, err := r.pool.Exec(ctx, `
|
||||
DELETE FROM notifications
|
||||
WHERE created_at < NOW() - INTERVAL '1 day' * $1
|
||||
`, daysOld)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// ArchiveOldNotifications marks old notifications as archived instead of deleting
|
||||
func (r *NotificationRepository) ArchiveOldNotifications(ctx context.Context, daysOld int) (int64, error) {
|
||||
result, err := r.pool.Exec(ctx, `
|
||||
UPDATE notifications
|
||||
SET archived_at = NOW()
|
||||
WHERE created_at < NOW() - INTERVAL '1 day' * $1 AND archived_at IS NULL
|
||||
`, daysOld)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper for building metadata JSON
|
||||
// ============================================================================
|
||||
|
||||
func BuildNotificationMetadata(data map[string]interface{}) json.RawMessage {
|
||||
if data == nil {
|
||||
return json.RawMessage("{}")
|
||||
}
|
||||
bytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return json.RawMessage("{}")
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
|
|||
WHERE f.follower_id = CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
)
|
||||
)
|
||||
AND NOT public.has_block_between(p.author_id, CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END)
|
||||
AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%.mp4')))
|
||||
AND ($5 = '' OR c.slug = $5)
|
||||
ORDER BY p.created_at DESC
|
||||
|
|
@ -306,6 +307,7 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID
|
|||
WHERE f.follower_id = CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted'
|
||||
)
|
||||
)
|
||||
AND NOT public.has_block_between(p.author_id, CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END)
|
||||
`
|
||||
var p models.Post
|
||||
err := r.pool.QueryRow(ctx, query, postID, userID).Scan(
|
||||
|
|
@ -1263,3 +1265,59 @@ func (r *PostRepository) LoadReactionsForPost(ctx context.Context, postID string
|
|||
|
||||
return counts, myReactions, reactionUsers, nil
|
||||
}
|
||||
|
||||
func (r *PostRepository) GetPopularPublicPosts(ctx context.Context, viewerID string, limit int) ([]models.Post, error) {
|
||||
query := `
|
||||
SELECT
|
||||
p.id, p.author_id, p.category_id, p.body,
|
||||
COALESCE(p.image_url, ''),
|
||||
CASE
|
||||
WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
|
||||
WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
|
||||
ELSE ''
|
||||
END AS resolved_video_url,
|
||||
COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
|
||||
p.duration_ms,
|
||||
COALESCE(p.tags, ARRAY[]::text[]),
|
||||
p.created_at,
|
||||
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
||||
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
||||
CASE WHEN ($2::text) != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2::text::uuid) ELSE FALSE END as is_liked,
|
||||
p.allow_chain, p.visibility
|
||||
FROM public.posts p
|
||||
JOIN public.profiles pr ON p.author_id = pr.id
|
||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||
WHERE p.deleted_at IS NULL AND p.status = 'active'
|
||||
AND pr.is_private = FALSE
|
||||
AND p.visibility = 'public'
|
||||
ORDER BY (COALESCE(m.like_count, 0) * 2 + COALESCE(m.comment_count, 0) * 5) DESC, p.created_at DESC
|
||||
LIMIT $1
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, query, limit, viewerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
posts := []models.Post{}
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
||||
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
||||
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
||||
&p.AllowChain, &p.Visibility,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: p.AuthorID,
|
||||
Handle: p.AuthorHandle,
|
||||
DisplayName: p.AuthorDisplayName,
|
||||
AvatarURL: p.AuthorAvatarURL,
|
||||
}
|
||||
posts = append(posts, p)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
|
|
|||
569
go-backend/internal/repository/tag_repository.go
Normal file
569
go-backend/internal/repository/tag_repository.go
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/patbritton/sojorn-backend/internal/models"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type TagRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewTagRepository(pool *pgxpool.Pool) *TagRepository {
|
||||
return &TagRepository{pool: pool}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hashtag CRUD
|
||||
// ============================================================================
|
||||
|
||||
// GetOrCreateHashtag finds or creates a hashtag by name
|
||||
func (r *TagRepository) GetOrCreateHashtag(ctx context.Context, name string) (*models.Hashtag, error) {
|
||||
normalized := strings.ToLower(strings.TrimPrefix(name, "#"))
|
||||
if normalized == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var hashtag models.Hashtag
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO hashtags (name, display_name)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (name) DO UPDATE SET updated_at = NOW()
|
||||
RETURNING id, name, display_name, use_count, trending_score, is_trending, is_featured, category, created_at
|
||||
`, normalized, name).Scan(
|
||||
&hashtag.ID, &hashtag.Name, &hashtag.DisplayName, &hashtag.UseCount,
|
||||
&hashtag.TrendingScore, &hashtag.IsTrending, &hashtag.IsFeatured, &hashtag.Category, &hashtag.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &hashtag, nil
|
||||
}
|
||||
|
||||
// GetHashtagByName retrieves a hashtag by its normalized name
|
||||
func (r *TagRepository) GetHashtagByName(ctx context.Context, name string) (*models.Hashtag, error) {
|
||||
normalized := strings.ToLower(strings.TrimPrefix(name, "#"))
|
||||
|
||||
var hashtag models.Hashtag
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, name, display_name, use_count, trending_score, is_trending, is_featured, category, created_at
|
||||
FROM hashtags WHERE name = $1
|
||||
`, normalized).Scan(
|
||||
&hashtag.ID, &hashtag.Name, &hashtag.DisplayName, &hashtag.UseCount,
|
||||
&hashtag.TrendingScore, &hashtag.IsTrending, &hashtag.IsFeatured, &hashtag.Category, &hashtag.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &hashtag, nil
|
||||
}
|
||||
|
||||
// SearchHashtags searches for hashtags matching a query
|
||||
func (r *TagRepository) SearchHashtags(ctx context.Context, query string, limit int) ([]models.Hashtag, error) {
|
||||
normalized := strings.ToLower(strings.TrimPrefix(query, "#"))
|
||||
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, name, display_name, use_count, trending_score, is_trending, is_featured, category, created_at
|
||||
FROM hashtags
|
||||
WHERE name ILIKE $1 || '%'
|
||||
ORDER BY use_count DESC, name ASC
|
||||
LIMIT $2
|
||||
`, normalized, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hashtags []models.Hashtag
|
||||
for rows.Next() {
|
||||
var h models.Hashtag
|
||||
err := rows.Scan(
|
||||
&h.ID, &h.Name, &h.DisplayName, &h.UseCount,
|
||||
&h.TrendingScore, &h.IsTrending, &h.IsFeatured, &h.Category, &h.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hashtags = append(hashtags, h)
|
||||
}
|
||||
|
||||
return hashtags, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Post-Hashtag Linking
|
||||
// ============================================================================
|
||||
|
||||
// LinkHashtagsToPost extracts hashtags from text and links them to a post
|
||||
func (r *TagRepository) LinkHashtagsToPost(ctx context.Context, postID uuid.UUID, text string) error {
|
||||
hashtags := extractHashtags(text)
|
||||
if len(hashtags) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
for _, tag := range hashtags {
|
||||
// Get or create the hashtag
|
||||
var hashtagID uuid.UUID
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO hashtags (name, display_name)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (name) DO UPDATE SET updated_at = NOW()
|
||||
RETURNING id
|
||||
`, strings.ToLower(tag), tag).Scan(&hashtagID)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("tag", tag).Msg("Failed to create hashtag")
|
||||
continue
|
||||
}
|
||||
|
||||
// Link to post
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO post_hashtags (post_id, hashtag_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, postID, hashtagID)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to link hashtag to post")
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// UnlinkHashtagsFromPost removes all hashtag links for a post
|
||||
func (r *TagRepository) UnlinkHashtagsFromPost(ctx context.Context, postID uuid.UUID) error {
|
||||
_, err := r.pool.Exec(ctx, `DELETE FROM post_hashtags WHERE post_id = $1`, postID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetHashtagsForPost returns all hashtags linked to a post
|
||||
func (r *TagRepository) GetHashtagsForPost(ctx context.Context, postID uuid.UUID) ([]models.Hashtag, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT h.id, h.name, h.display_name, h.use_count, h.trending_score, h.is_trending, h.is_featured, h.category, h.created_at
|
||||
FROM hashtags h
|
||||
JOIN post_hashtags ph ON h.id = ph.hashtag_id
|
||||
WHERE ph.post_id = $1
|
||||
ORDER BY h.name
|
||||
`, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hashtags []models.Hashtag
|
||||
for rows.Next() {
|
||||
var h models.Hashtag
|
||||
err := rows.Scan(
|
||||
&h.ID, &h.Name, &h.DisplayName, &h.UseCount,
|
||||
&h.TrendingScore, &h.IsTrending, &h.IsFeatured, &h.Category, &h.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hashtags = append(hashtags, h)
|
||||
}
|
||||
|
||||
return hashtags, nil
|
||||
}
|
||||
|
||||
// GetPostsByHashtag returns posts that contain a specific hashtag
|
||||
func (r *TagRepository) GetPostsByHashtag(ctx context.Context, hashtagName, viewerID string, limit, offset int) ([]models.Post, error) {
|
||||
normalized := strings.ToLower(strings.TrimPrefix(hashtagName, "#"))
|
||||
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT
|
||||
p.id, p.author_id, p.body, p.image_url, p.video_url, p.status, p.created_at,
|
||||
p.tone_label, p.cis_score, p.body_format, p.tags, p.visibility, p.is_beacon,
|
||||
pr.id, pr.handle, pr.display_name, pr.avatar_url,
|
||||
(SELECT COUNT(*) FROM post_likes WHERE post_id = p.id) as like_count,
|
||||
EXISTS(SELECT 1 FROM post_likes WHERE post_id = p.id AND user_id = $3::uuid) as user_has_liked
|
||||
FROM posts p
|
||||
JOIN post_hashtags ph ON p.id = ph.post_id
|
||||
JOIN hashtags h ON ph.hashtag_id = h.id
|
||||
JOIN profiles pr ON p.author_id = pr.id
|
||||
WHERE h.name = $1
|
||||
AND p.deleted_at IS NULL
|
||||
AND p.status = 'active'
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $4 OFFSET $5
|
||||
`, normalized, normalized, viewerID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []models.Post
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
var authorID uuid.UUID
|
||||
var authorHandle, authorDisplayName, authorAvatarURL *string
|
||||
var likeCount int
|
||||
var isLiked bool
|
||||
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AuthorID, &p.Body, &p.ImageURL, &p.VideoURL, &p.Status, &p.CreatedAt,
|
||||
&p.ToneLabel, &p.CISScore, &p.BodyFormat, &p.Tags, &p.Visibility, &p.IsBeacon,
|
||||
&authorID, &authorHandle, &authorDisplayName, &authorAvatarURL,
|
||||
&likeCount, &isLiked,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.LikeCount = likeCount
|
||||
p.IsLiked = isLiked
|
||||
|
||||
// Build author profile
|
||||
handle := ""
|
||||
displayName := ""
|
||||
avatarURL := ""
|
||||
if authorHandle != nil {
|
||||
handle = *authorHandle
|
||||
}
|
||||
if authorDisplayName != nil {
|
||||
displayName = *authorDisplayName
|
||||
}
|
||||
if authorAvatarURL != nil {
|
||||
avatarURL = *authorAvatarURL
|
||||
}
|
||||
p.Author = &models.AuthorProfile{
|
||||
ID: authorID,
|
||||
Handle: handle,
|
||||
DisplayName: displayName,
|
||||
AvatarURL: avatarURL,
|
||||
}
|
||||
|
||||
posts = append(posts, p)
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trending & Discover
|
||||
// ============================================================================
|
||||
|
||||
// GetTrendingHashtags returns top trending hashtags
|
||||
func (r *TagRepository) GetTrendingHashtags(ctx context.Context, limit int) ([]models.Hashtag, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT h.id, h.name, h.display_name, h.use_count, h.trending_score, h.is_trending, h.is_featured, h.category, h.created_at,
|
||||
(SELECT COUNT(*) FROM post_hashtags ph WHERE ph.hashtag_id = h.id AND ph.created_at > NOW() - INTERVAL '24 hours') as recent_count
|
||||
FROM hashtags h
|
||||
WHERE h.trending_score > 0
|
||||
ORDER BY h.trending_score DESC, h.use_count DESC
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hashtags []models.Hashtag
|
||||
for rows.Next() {
|
||||
var h models.Hashtag
|
||||
var recentCount int
|
||||
err := rows.Scan(
|
||||
&h.ID, &h.Name, &h.DisplayName, &h.UseCount,
|
||||
&h.TrendingScore, &h.IsTrending, &h.IsFeatured, &h.Category, &h.CreatedAt, &recentCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.RecentCount = recentCount
|
||||
hashtags = append(hashtags, h)
|
||||
}
|
||||
|
||||
return hashtags, nil
|
||||
}
|
||||
|
||||
// GetFeaturedHashtags returns curated/featured hashtags
|
||||
func (r *TagRepository) GetFeaturedHashtags(ctx context.Context, limit int) ([]models.Hashtag, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, name, display_name, use_count, trending_score, is_trending, is_featured, category, created_at
|
||||
FROM hashtags
|
||||
WHERE is_featured = true
|
||||
ORDER BY use_count DESC
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hashtags []models.Hashtag
|
||||
for rows.Next() {
|
||||
var h models.Hashtag
|
||||
err := rows.Scan(
|
||||
&h.ID, &h.Name, &h.DisplayName, &h.UseCount,
|
||||
&h.TrendingScore, &h.IsTrending, &h.IsFeatured, &h.Category, &h.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hashtags = append(hashtags, h)
|
||||
}
|
||||
|
||||
return hashtags, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hashtag Follows
|
||||
// ============================================================================
|
||||
|
||||
// FollowHashtag adds a user to a hashtag's followers
|
||||
func (r *TagRepository) FollowHashtag(ctx context.Context, userID, hashtagID uuid.UUID) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO hashtag_follows (user_id, hashtag_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, userID, hashtagID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UnfollowHashtag removes a user from a hashtag's followers
|
||||
func (r *TagRepository) UnfollowHashtag(ctx context.Context, userID, hashtagID uuid.UUID) error {
|
||||
_, err := r.pool.Exec(ctx, `DELETE FROM hashtag_follows WHERE user_id = $1 AND hashtag_id = $2`, userID, hashtagID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetFollowedHashtags returns hashtags followed by a user
|
||||
func (r *TagRepository) GetFollowedHashtags(ctx context.Context, userID uuid.UUID) ([]models.Hashtag, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT h.id, h.name, h.display_name, h.use_count, h.trending_score, h.is_trending, h.is_featured, h.category, h.created_at
|
||||
FROM hashtags h
|
||||
JOIN hashtag_follows hf ON h.id = hf.hashtag_id
|
||||
WHERE hf.user_id = $1
|
||||
ORDER BY h.name
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hashtags []models.Hashtag
|
||||
for rows.Next() {
|
||||
var h models.Hashtag
|
||||
err := rows.Scan(
|
||||
&h.ID, &h.Name, &h.DisplayName, &h.UseCount,
|
||||
&h.TrendingScore, &h.IsTrending, &h.IsFeatured, &h.Category, &h.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hashtags = append(hashtags, h)
|
||||
}
|
||||
|
||||
return hashtags, nil
|
||||
}
|
||||
|
||||
// IsFollowingHashtag checks if a user is following a hashtag
|
||||
func (r *TagRepository) IsFollowingHashtag(ctx context.Context, userID, hashtagID uuid.UUID) (bool, error) {
|
||||
var exists bool
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM hashtag_follows WHERE user_id = $1 AND hashtag_id = $2)
|
||||
`, userID, hashtagID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mentions
|
||||
// ============================================================================
|
||||
|
||||
// LinkMentionsToPost extracts @mentions from text and links them to a post
|
||||
func (r *TagRepository) LinkMentionsToPost(ctx context.Context, postID uuid.UUID, text string) ([]uuid.UUID, error) {
|
||||
mentions := extractMentions(text)
|
||||
if len(mentions) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var mentionedUserIDs []uuid.UUID
|
||||
|
||||
for _, handle := range mentions {
|
||||
var userID uuid.UUID
|
||||
err := r.pool.QueryRow(ctx, `SELECT id FROM profiles WHERE handle = $1`, handle).Scan(&userID)
|
||||
if err != nil {
|
||||
continue // User not found
|
||||
}
|
||||
|
||||
_, err = r.pool.Exec(ctx, `
|
||||
INSERT INTO post_mentions (post_id, mentioned_user_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, postID, userID)
|
||||
if err == nil {
|
||||
mentionedUserIDs = append(mentionedUserIDs, userID)
|
||||
}
|
||||
}
|
||||
|
||||
return mentionedUserIDs, nil
|
||||
}
|
||||
|
||||
// GetMentionsForPost returns all mentioned users in a post
|
||||
func (r *TagRepository) GetMentionsForPost(ctx context.Context, postID uuid.UUID) ([]models.Profile, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT p.id, p.handle, p.display_name, p.avatar_url, p.is_verified
|
||||
FROM profiles p
|
||||
JOIN post_mentions pm ON p.id = pm.mentioned_user_id
|
||||
WHERE pm.post_id = $1
|
||||
`, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var profiles []models.Profile
|
||||
for rows.Next() {
|
||||
var p models.Profile
|
||||
if err := rows.Scan(&p.ID, &p.Handle, &p.DisplayName, &p.AvatarURL, &p.IsVerified); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles = append(profiles, p)
|
||||
}
|
||||
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Suggested Users (Discover)
|
||||
// ============================================================================
|
||||
|
||||
// GetSuggestedUsers returns suggested users for the discover page
|
||||
func (r *TagRepository) GetSuggestedUsers(ctx context.Context, userID string, limit int) ([]models.SuggestedUser, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT
|
||||
p.id, p.handle, p.display_name, p.avatar_url, p.bio, p.is_verified, p.is_official,
|
||||
su.reason, su.category, su.score,
|
||||
(SELECT COUNT(*) FROM follows WHERE following_id = p.id AND status = 'accepted') as follower_count
|
||||
FROM suggested_users su
|
||||
JOIN profiles p ON su.user_id = p.id
|
||||
WHERE su.is_active = true
|
||||
AND p.id != $1::uuid
|
||||
AND NOT EXISTS (SELECT 1 FROM follows WHERE follower_id = $1::uuid AND following_id = p.id)
|
||||
ORDER BY su.score DESC, p.is_verified DESC
|
||||
LIMIT $2
|
||||
`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []models.SuggestedUser
|
||||
for rows.Next() {
|
||||
var u models.SuggestedUser
|
||||
err := rows.Scan(
|
||||
&u.ID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.Bio, &u.IsVerified, &u.IsOfficial,
|
||||
&u.Reason, &u.Category, &u.Score, &u.FollowerCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// GetPopularCreators returns popular users for the discover page
|
||||
func (r *TagRepository) GetPopularCreators(ctx context.Context, userID string, limit int) ([]models.Profile, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT
|
||||
p.id, p.handle, p.display_name, p.avatar_url, p.bio, p.is_verified, p.is_official,
|
||||
(SELECT COUNT(*) FROM follows WHERE following_id = p.id AND status = 'accepted') as follower_count
|
||||
FROM profiles p
|
||||
WHERE p.id != $1::uuid
|
||||
AND p.is_official = false
|
||||
AND NOT EXISTS (SELECT 1 FROM follows WHERE follower_id = $1::uuid AND following_id = p.id)
|
||||
ORDER BY (SELECT COUNT(*) FROM follows WHERE following_id = p.id AND status = 'accepted') DESC
|
||||
LIMIT $2
|
||||
`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var profiles []models.Profile
|
||||
for rows.Next() {
|
||||
var p models.Profile
|
||||
var followerCount int
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.Handle, &p.DisplayName, &p.AvatarURL, &p.Bio, &p.IsVerified, &p.IsOfficial, &followerCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.FollowerCount = &followerCount
|
||||
profiles = append(profiles, p)
|
||||
}
|
||||
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trending Calculation (for scheduled jobs)
|
||||
// ============================================================================
|
||||
|
||||
// RefreshTrendingScores recalculates trending scores for all hashtags
|
||||
func (r *TagRepository) RefreshTrendingScores(ctx context.Context) error {
|
||||
_, err := r.pool.Exec(ctx, `SELECT calculate_trending_scores()`)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
var hashtagRegex = regexp.MustCompile(`#(\w+)`)
|
||||
var mentionRegex = regexp.MustCompile(`@(\w+)`)
|
||||
|
||||
func extractHashtags(text string) []string {
|
||||
matches := hashtagRegex.FindAllStringSubmatch(text, -1)
|
||||
seen := make(map[string]bool)
|
||||
var hashtags []string
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 {
|
||||
tag := match[1]
|
||||
if !seen[strings.ToLower(tag)] {
|
||||
seen[strings.ToLower(tag)] = true
|
||||
hashtags = append(hashtags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hashtags
|
||||
}
|
||||
|
||||
func extractMentions(text string) []string {
|
||||
matches := mentionRegex.FindAllStringSubmatch(text, -1)
|
||||
seen := make(map[string]bool)
|
||||
var mentions []string
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 {
|
||||
handle := strings.ToLower(match[1])
|
||||
if !seen[handle] {
|
||||
seen[handle] = true
|
||||
mentions = append(mentions, handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mentions
|
||||
}
|
||||
|
|
@ -314,6 +314,79 @@ func (r *UserRepository) UnfollowUser(ctx context.Context, followerID, following
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) BlockUser(ctx context.Context, blockerID, blockedID, actorIP string) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Step 1: Insert Block
|
||||
query := `INSERT INTO public.blocks (blocker_id, blocked_id) VALUES ($1::uuid, $2::uuid) ON CONFLICT DO NOTHING`
|
||||
_, err = tx.Exec(ctx, query, blockerID, blockedID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: Log Abuse
|
||||
var handle string
|
||||
_ = tx.QueryRow(ctx, `SELECT handle FROM public.profiles WHERE id = $1::uuid`, blockedID).Scan(&handle)
|
||||
|
||||
abuseQuery := `
|
||||
INSERT INTO public.abuse_logs (actor_id, blocked_id, blocked_handle, actor_ip)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4)
|
||||
`
|
||||
_, _ = tx.Exec(ctx, abuseQuery, blockerID, blockedID, handle, actorIP)
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *UserRepository) UnblockUser(ctx context.Context, blockerID, blockedID string) error {
|
||||
query := `DELETE FROM public.blocks WHERE blocker_id = $1::uuid AND blocked_id = $2::uuid`
|
||||
_, err := r.pool.Exec(ctx, query, blockerID, blockedID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetBlockedUsers(ctx context.Context, userID string) ([]models.Profile, error) {
|
||||
query := `
|
||||
SELECT p.id, p.handle, p.display_name, p.avatar_url
|
||||
FROM public.profiles p
|
||||
JOIN public.blocks b ON p.id = b.blocked_id
|
||||
WHERE b.blocker_id = $1::uuid
|
||||
`
|
||||
rows, err := r.pool.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var profiles []models.Profile
|
||||
for rows.Next() {
|
||||
var p models.Profile
|
||||
if err := rows.Scan(&p.ID, &p.Handle, &p.DisplayName, &p.AvatarURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles = append(profiles, p)
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) CreateReport(ctx context.Context, report *models.Report) error {
|
||||
query := `
|
||||
INSERT INTO public.reports (reporter_id, target_user_id, post_id, comment_id, violation_type, description, status)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, 'pending')
|
||||
`
|
||||
_, err := r.pool.Exec(ctx, query,
|
||||
report.ReporterID,
|
||||
report.TargetUserID,
|
||||
report.PostID,
|
||||
report.CommentID,
|
||||
report.ViolationType,
|
||||
report.Description,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
type ProfileStats struct {
|
||||
PostCount int `json:"post_count"`
|
||||
FollowerCount int `json:"follower_count"`
|
||||
|
|
@ -570,32 +643,37 @@ func (r *UserRepository) GetSignalKeyBundle(ctx context.Context, userID string)
|
|||
func (r *UserRepository) GetPrivacySettings(ctx context.Context, userID string) (*models.PrivacySettings, error) {
|
||||
query := `
|
||||
SELECT user_id, show_location, show_interests, profile_visibility,
|
||||
posts_visibility, saved_visibility, follow_request_policy, updated_at
|
||||
posts_visibility, saved_visibility, follow_request_policy,
|
||||
default_post_visibility, is_private_profile, updated_at
|
||||
FROM public.profile_privacy_settings
|
||||
WHERE user_id = $1::uuid
|
||||
`
|
||||
var ps models.PrivacySettings
|
||||
err := r.pool.QueryRow(ctx, query, userID).Scan(
|
||||
&ps.UserID, &ps.ShowLocation, &ps.ShowInterests, &ps.ProfileVisibility,
|
||||
&ps.PostsVisibility, &ps.SavedVisibility, &ps.FollowRequestPolicy, &ps.UpdatedAt,
|
||||
&ps.PostsVisibility, &ps.SavedVisibility, &ps.FollowRequestPolicy,
|
||||
&ps.DefaultPostVisibility, &ps.IsPrivateProfile, &ps.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" || err.Error() == "pgx: no rows in result set" {
|
||||
// Return default settings for new users (pointers required)
|
||||
uid, _ := uuid.Parse(userID)
|
||||
t := true
|
||||
f := false
|
||||
pub := "public"
|
||||
priv := "private"
|
||||
anyone := "everyone"
|
||||
return &models.PrivacySettings{
|
||||
UserID: uid,
|
||||
ShowLocation: &t,
|
||||
ShowInterests: &t,
|
||||
ProfileVisibility: &pub,
|
||||
PostsVisibility: &pub,
|
||||
SavedVisibility: &priv,
|
||||
FollowRequestPolicy: &anyone,
|
||||
UpdatedAt: time.Now(),
|
||||
UserID: uid,
|
||||
ShowLocation: &t,
|
||||
ShowInterests: &t,
|
||||
ProfileVisibility: &pub,
|
||||
PostsVisibility: &pub,
|
||||
SavedVisibility: &priv,
|
||||
FollowRequestPolicy: &anyone,
|
||||
DefaultPostVisibility: &pub,
|
||||
IsPrivateProfile: &f,
|
||||
UpdatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
|
|
@ -607,8 +685,9 @@ func (r *UserRepository) UpdatePrivacySettings(ctx context.Context, ps *models.P
|
|||
query := `
|
||||
INSERT INTO public.profile_privacy_settings (
|
||||
user_id, show_location, show_interests, profile_visibility,
|
||||
posts_visibility, saved_visibility, follow_request_policy, updated_at
|
||||
) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, NOW())
|
||||
posts_visibility, saved_visibility, follow_request_policy,
|
||||
default_post_visibility, is_private_profile, updated_at
|
||||
) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
show_location = COALESCE(EXCLUDED.show_location, profile_privacy_settings.show_location),
|
||||
show_interests = COALESCE(EXCLUDED.show_interests, profile_privacy_settings.show_interests),
|
||||
|
|
@ -616,11 +695,14 @@ func (r *UserRepository) UpdatePrivacySettings(ctx context.Context, ps *models.P
|
|||
posts_visibility = COALESCE(EXCLUDED.posts_visibility, profile_privacy_settings.posts_visibility),
|
||||
saved_visibility = COALESCE(EXCLUDED.saved_visibility, profile_privacy_settings.saved_visibility),
|
||||
follow_request_policy = COALESCE(EXCLUDED.follow_request_policy, profile_privacy_settings.follow_request_policy),
|
||||
default_post_visibility = COALESCE(EXCLUDED.default_post_visibility, profile_privacy_settings.default_post_visibility),
|
||||
is_private_profile = COALESCE(EXCLUDED.is_private_profile, profile_privacy_settings.is_private_profile),
|
||||
updated_at = NOW()
|
||||
`
|
||||
_, err := r.pool.Exec(ctx, query,
|
||||
ps.UserID, ps.ShowLocation, ps.ShowInterests, ps.ProfileVisibility,
|
||||
ps.PostsVisibility, ps.SavedVisibility, ps.FollowRequestPolicy,
|
||||
ps.DefaultPostVisibility, ps.IsPrivateProfile,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
|
@ -971,3 +1053,11 @@ func (r *UserRepository) DeleteUser(ctx context.Context, userID uuid.UUID) error
|
|||
_, err := r.pool.Exec(ctx, query, userID)
|
||||
return err
|
||||
}
|
||||
func (r *UserRepository) BlockUserByHandle(ctx context.Context, actorID string, handle string, actorIP string) error {
|
||||
var targetID uuid.UUID
|
||||
err := r.pool.QueryRow(ctx, "SELECT id FROM public.profiles WHERE handle = $1", handle).Scan(&targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.BlockUser(ctx, actorID, targetID.String(), actorIP)
|
||||
}
|
||||
|
|
|
|||
79
go-backend/internal/services/moderation_service.go
Normal file
79
go-backend/internal/services/moderation_service.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type ModerationService struct {
|
||||
pool *pgxpool.Pool
|
||||
// In a real app, we would have API keys here
|
||||
}
|
||||
|
||||
func NewModerationService(pool *pgxpool.Pool) *ModerationService {
|
||||
return &ModerationService{pool: pool}
|
||||
}
|
||||
|
||||
type ThreePoisonsScore struct {
|
||||
Hate float64 `json:"hate"`
|
||||
Greed float64 `json:"greed"`
|
||||
Delusion float64 `json:"delusion"`
|
||||
}
|
||||
|
||||
func (s *ModerationService) AnalyzeContent(ctx context.Context, body string) (*ThreePoisonsScore, string, error) {
|
||||
// PLACEHOLDER: In production, call OpenAI Moderation or HuggingFace
|
||||
// For now, we use a simple rule-based mock to demonstrate the "Sanctuary" gates.
|
||||
|
||||
score := &ThreePoisonsScore{
|
||||
Hate: 0.0,
|
||||
Greed: 0.0,
|
||||
Delusion: 0.0,
|
||||
}
|
||||
|
||||
// Simple mock rules
|
||||
if containsAny(body, []string{"hate", "kill", "attack"}) {
|
||||
score.Hate = 0.8
|
||||
}
|
||||
if containsAny(body, []string{"buy", "crypto", "rich", "scam"}) {
|
||||
score.Greed = 0.7
|
||||
}
|
||||
if containsAny(body, []string{"fake", "truth", "conspiracy"}) {
|
||||
score.Delusion = 0.6
|
||||
}
|
||||
|
||||
flagReason := ""
|
||||
if score.Hate > 0.5 {
|
||||
flagReason = "hate"
|
||||
} else if score.Greed > 0.5 {
|
||||
flagReason = "greed"
|
||||
} else if score.Delusion > 0.5 {
|
||||
flagReason = "delusion"
|
||||
}
|
||||
|
||||
return score, flagReason, nil
|
||||
}
|
||||
|
||||
func (s *ModerationService) FlagPost(ctx context.Context, postID uuid.UUID, scores *ThreePoisonsScore, reason string) error {
|
||||
scoresJSON, _ := json.Marshal(scores)
|
||||
query := `
|
||||
INSERT INTO public.pending_moderation (post_id, flag_reason, scores)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
_, err := s.pool.Exec(ctx, query, postID, reason, scoresJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func containsAny(body string, terms []string) bool {
|
||||
// Case insensitive check
|
||||
lower := bytes.ToLower([]byte(body))
|
||||
for _, term := range terms {
|
||||
if bytes.Contains(lower, []byte(term)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -2,9 +2,11 @@ package services
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/patbritton/sojorn-backend/internal/models"
|
||||
"github.com/patbritton/sojorn-backend/internal/repository"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
|
@ -12,105 +14,446 @@ import (
|
|||
type NotificationService struct {
|
||||
notifRepo *repository.NotificationRepository
|
||||
pushSvc *PushService
|
||||
userRepo *repository.UserRepository
|
||||
}
|
||||
|
||||
func NewNotificationService(notifRepo *repository.NotificationRepository, pushSvc *PushService) *NotificationService {
|
||||
func NewNotificationService(notifRepo *repository.NotificationRepository, pushSvc *PushService, userRepo *repository.UserRepository) *NotificationService {
|
||||
return &NotificationService{
|
||||
notifRepo: notifRepo,
|
||||
pushSvc: pushSvc,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NotificationService) CreateNotification(ctx context.Context, userID, actorID, notificationType string, postID *string, commentID *string, metadata map[string]interface{}) error {
|
||||
// Parse UUIDs
|
||||
// Validate UUIDs (for future use when we fix notification storage)
|
||||
_, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid user ID: %w", err)
|
||||
// ============================================================================
|
||||
// High-Level Notification Methods (Called by Handlers)
|
||||
// ============================================================================
|
||||
|
||||
// NotifyLike sends a notification when someone likes a post
|
||||
func (s *NotificationService) NotifyLike(ctx context.Context, postAuthorID, actorID, postID string, postType string) error {
|
||||
if postAuthorID == actorID {
|
||||
return nil // Don't notify self
|
||||
}
|
||||
|
||||
_, err = uuid.Parse(actorID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid actor ID: %w", err)
|
||||
return s.sendNotification(ctx, models.PushNotificationRequest{
|
||||
UserID: uuid.MustParse(postAuthorID),
|
||||
Type: models.NotificationTypeLike,
|
||||
ActorID: uuid.MustParse(actorID),
|
||||
PostID: uuidPtr(postID),
|
||||
PostType: postType,
|
||||
GroupKey: fmt.Sprintf("like:%s", postID), // Group likes on same post
|
||||
Priority: models.PriorityNormal,
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyComment sends a notification when someone comments on a post
|
||||
func (s *NotificationService) NotifyComment(ctx context.Context, postAuthorID, actorID, postID, commentID string, postType string) error {
|
||||
if postAuthorID == actorID {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send push notification
|
||||
if s.pushSvc != nil {
|
||||
title, body, data := s.buildPushNotification(notificationType, metadata)
|
||||
if err := s.pushSvc.SendPush(ctx, userID, title, body, data); err != nil {
|
||||
log.Warn().Err(err).Str("user_id", userID).Msg("Failed to send push notification")
|
||||
return s.sendNotification(ctx, models.PushNotificationRequest{
|
||||
UserID: uuid.MustParse(postAuthorID),
|
||||
Type: models.NotificationTypeComment,
|
||||
ActorID: uuid.MustParse(actorID),
|
||||
PostID: uuidPtr(postID),
|
||||
CommentID: uuidPtr(commentID),
|
||||
PostType: postType,
|
||||
GroupKey: fmt.Sprintf("comment:%s", postID),
|
||||
Priority: models.PriorityNormal,
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyReply sends a notification when someone replies to a comment
|
||||
func (s *NotificationService) NotifyReply(ctx context.Context, commentAuthorID, actorID, postID, commentID string) error {
|
||||
if commentAuthorID == actorID {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.sendNotification(ctx, models.PushNotificationRequest{
|
||||
UserID: uuid.MustParse(commentAuthorID),
|
||||
Type: models.NotificationTypeReply,
|
||||
ActorID: uuid.MustParse(actorID),
|
||||
PostID: uuidPtr(postID),
|
||||
CommentID: uuidPtr(commentID),
|
||||
Priority: models.PriorityNormal,
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyMention sends notifications to all mentioned users
|
||||
func (s *NotificationService) NotifyMention(ctx context.Context, actorID, postID string, text string) error {
|
||||
mentionedUserIDs, err := s.notifRepo.ExtractMentions(ctx, text)
|
||||
if err != nil || len(mentionedUserIDs) == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
actorUUID := uuid.MustParse(actorID)
|
||||
for _, userID := range mentionedUserIDs {
|
||||
if userID == actorUUID {
|
||||
continue // Don't notify self
|
||||
}
|
||||
|
||||
err := s.sendNotification(ctx, models.PushNotificationRequest{
|
||||
UserID: userID,
|
||||
Type: models.NotificationTypeMention,
|
||||
ActorID: actorUUID,
|
||||
PostID: uuidPtr(postID),
|
||||
Priority: models.PriorityHigh, // Mentions are high priority
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("user_id", userID.String()).Msg("Failed to send mention notification")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NotificationService) buildPushNotification(notificationType string, metadata map[string]interface{}) (title, body string, data map[string]string) {
|
||||
actorName, _ := metadata["actor_name"].(string)
|
||||
// NotifyFollow sends a notification when someone follows a user
|
||||
func (s *NotificationService) NotifyFollow(ctx context.Context, followedUserID, followerID string, isPending bool) error {
|
||||
notifType := models.NotificationTypeFollow
|
||||
if isPending {
|
||||
notifType = models.NotificationTypeFollowRequest
|
||||
}
|
||||
|
||||
switch notificationType {
|
||||
case "beacon_vouch":
|
||||
return s.sendNotification(ctx, models.PushNotificationRequest{
|
||||
UserID: uuid.MustParse(followedUserID),
|
||||
Type: notifType,
|
||||
ActorID: uuid.MustParse(followerID),
|
||||
Priority: models.PriorityNormal,
|
||||
Metadata: map[string]interface{}{
|
||||
"follower_id": followerID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyFollowAccepted sends a notification when a follow request is accepted
|
||||
func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, followerID, acceptorID string) error {
|
||||
return s.sendNotification(ctx, models.PushNotificationRequest{
|
||||
UserID: uuid.MustParse(followerID),
|
||||
Type: models.NotificationTypeFollowAccept,
|
||||
ActorID: uuid.MustParse(acceptorID),
|
||||
Priority: models.PriorityNormal,
|
||||
})
|
||||
}
|
||||
|
||||
// NotifySave sends a notification when someone saves a post
|
||||
func (s *NotificationService) NotifySave(ctx context.Context, postAuthorID, actorID, postID, postType string) error {
|
||||
if postAuthorID == actorID {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.sendNotification(ctx, models.PushNotificationRequest{
|
||||
UserID: uuid.MustParse(postAuthorID),
|
||||
Type: models.NotificationTypeSave,
|
||||
ActorID: uuid.MustParse(actorID),
|
||||
PostID: uuidPtr(postID),
|
||||
PostType: postType,
|
||||
GroupKey: fmt.Sprintf("save:%s", postID),
|
||||
Priority: models.PriorityLow, // Saves are lower priority
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyMessage sends a notification for new chat messages
|
||||
func (s *NotificationService) NotifyMessage(ctx context.Context, receiverID, senderID, conversationID string) error {
|
||||
return s.sendNotification(ctx, models.PushNotificationRequest{
|
||||
UserID: uuid.MustParse(receiverID),
|
||||
Type: models.NotificationTypeMessage,
|
||||
ActorID: uuid.MustParse(senderID),
|
||||
Priority: models.PriorityHigh, // Messages are high priority
|
||||
Metadata: map[string]interface{}{
|
||||
"conversation_id": conversationID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyBeaconVouch sends a notification when someone vouches for a beacon
|
||||
func (s *NotificationService) NotifyBeaconVouch(ctx context.Context, beaconAuthorID, actorID, beaconID string) error {
|
||||
if beaconAuthorID == actorID {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.sendNotification(ctx, models.PushNotificationRequest{
|
||||
UserID: uuid.MustParse(beaconAuthorID),
|
||||
Type: models.NotificationTypeBeaconVouch,
|
||||
ActorID: uuid.MustParse(actorID),
|
||||
PostID: uuidPtr(beaconID),
|
||||
PostType: "beacon",
|
||||
GroupKey: fmt.Sprintf("beacon_vouch:%s", beaconID),
|
||||
Priority: models.PriorityNormal,
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyBeaconReport sends a notification when someone reports a beacon
|
||||
func (s *NotificationService) NotifyBeaconReport(ctx context.Context, beaconAuthorID, actorID, beaconID string) error {
|
||||
if beaconAuthorID == actorID {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.sendNotification(ctx, models.PushNotificationRequest{
|
||||
UserID: uuid.MustParse(beaconAuthorID),
|
||||
Type: models.NotificationTypeBeaconReport,
|
||||
ActorID: uuid.MustParse(actorID),
|
||||
PostID: uuidPtr(beaconID),
|
||||
PostType: "beacon",
|
||||
Priority: models.PriorityNormal,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core Send Logic
|
||||
// ============================================================================
|
||||
|
||||
func (s *NotificationService) sendNotification(ctx context.Context, req models.PushNotificationRequest) error {
|
||||
// Check user preferences
|
||||
shouldSend, err := s.notifRepo.ShouldSendPush(ctx, req.UserID.String(), req.Type)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to check notification preferences")
|
||||
}
|
||||
|
||||
// Get actor details
|
||||
actor, err := s.userRepo.GetProfileByID(ctx, req.ActorID.String())
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to get actor profile for notification")
|
||||
actor = &models.Profile{DisplayName: ptrString("Someone")}
|
||||
}
|
||||
if actor.DisplayName != nil {
|
||||
req.ActorName = *actor.DisplayName
|
||||
}
|
||||
if actor.AvatarURL != nil {
|
||||
req.ActorAvatar = *actor.AvatarURL
|
||||
}
|
||||
|
||||
// Create in-app notification record
|
||||
notif := &models.Notification{
|
||||
UserID: req.UserID,
|
||||
Type: req.Type,
|
||||
ActorID: req.ActorID,
|
||||
PostID: req.PostID,
|
||||
IsRead: false,
|
||||
Priority: req.Priority,
|
||||
Metadata: s.buildMetadata(req),
|
||||
}
|
||||
if req.CommentID != nil {
|
||||
notif.CommentID = req.CommentID
|
||||
}
|
||||
if req.GroupKey != "" {
|
||||
notif.GroupKey = &req.GroupKey
|
||||
}
|
||||
|
||||
if err := s.notifRepo.CreateNotification(ctx, notif); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to create in-app notification")
|
||||
}
|
||||
|
||||
// Send push notification if enabled
|
||||
if shouldSend && s.pushSvc != nil {
|
||||
title, body, data := s.buildPushPayload(req)
|
||||
|
||||
// Get badge count for iOS/macOS
|
||||
badge, _ := s.notifRepo.GetUnreadBadge(ctx, req.UserID.String())
|
||||
|
||||
err := s.pushSvc.SendPushWithBadge(ctx, req.UserID.String(), title, body, data, badge.TotalCount)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("user_id", req.UserID.String()).Msg("Failed to send push notification")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NotificationService) buildMetadata(req models.PushNotificationRequest) json.RawMessage {
|
||||
data := map[string]interface{}{
|
||||
"actor_name": req.ActorName,
|
||||
"post_type": req.PostType,
|
||||
}
|
||||
|
||||
if req.PostID != nil {
|
||||
data["post_id"] = req.PostID.String()
|
||||
}
|
||||
if req.CommentID != nil {
|
||||
data["comment_id"] = req.CommentID.String()
|
||||
}
|
||||
if req.PostPreview != "" {
|
||||
data["post_preview"] = req.PostPreview
|
||||
}
|
||||
|
||||
for k, v := range req.Metadata {
|
||||
data[k] = v
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(data)
|
||||
return bytes
|
||||
}
|
||||
|
||||
func (s *NotificationService) buildPushPayload(req models.PushNotificationRequest) (title, body string, data map[string]string) {
|
||||
actorName := req.ActorName
|
||||
if actorName == "" {
|
||||
actorName = "Someone"
|
||||
}
|
||||
|
||||
data = map[string]string{
|
||||
"type": req.Type,
|
||||
}
|
||||
|
||||
if req.PostID != nil {
|
||||
data["post_id"] = req.PostID.String()
|
||||
}
|
||||
if req.CommentID != nil {
|
||||
data["comment_id"] = req.CommentID.String()
|
||||
}
|
||||
if req.PostType != "" {
|
||||
data["post_type"] = req.PostType
|
||||
}
|
||||
|
||||
// Add target for navigation
|
||||
target := s.getNavigationTarget(req.Type, req.PostType)
|
||||
data["target"] = target
|
||||
|
||||
// Copy metadata
|
||||
for k, v := range req.Metadata {
|
||||
if str, ok := v.(string); ok {
|
||||
data[k] = str
|
||||
}
|
||||
}
|
||||
|
||||
switch req.Type {
|
||||
case models.NotificationTypeLike:
|
||||
title = "New Like"
|
||||
body = fmt.Sprintf("%s liked your %s", actorName, s.formatPostType(req.PostType))
|
||||
|
||||
case models.NotificationTypeComment:
|
||||
title = "New Comment"
|
||||
body = fmt.Sprintf("%s commented on your %s", actorName, s.formatPostType(req.PostType))
|
||||
|
||||
case models.NotificationTypeReply:
|
||||
title = "New Reply"
|
||||
body = fmt.Sprintf("%s replied to your comment", actorName)
|
||||
|
||||
case models.NotificationTypeMention:
|
||||
title = "You Were Mentioned"
|
||||
body = fmt.Sprintf("%s mentioned you in a post", actorName)
|
||||
|
||||
case models.NotificationTypeFollow:
|
||||
title = "New Follower"
|
||||
body = fmt.Sprintf("%s started following you", actorName)
|
||||
data["follower_id"] = req.ActorID.String()
|
||||
|
||||
case models.NotificationTypeFollowRequest:
|
||||
title = "Follow Request"
|
||||
body = fmt.Sprintf("%s wants to follow you", actorName)
|
||||
data["follower_id"] = req.ActorID.String()
|
||||
|
||||
case models.NotificationTypeFollowAccept:
|
||||
title = "Request Accepted"
|
||||
body = fmt.Sprintf("%s accepted your follow request", actorName)
|
||||
|
||||
case models.NotificationTypeSave:
|
||||
title = "Post Saved"
|
||||
body = fmt.Sprintf("%s saved your %s", actorName, s.formatPostType(req.PostType))
|
||||
|
||||
case models.NotificationTypeMessage:
|
||||
title = "New Message"
|
||||
body = "You have a new message"
|
||||
|
||||
case models.NotificationTypeBeaconVouch:
|
||||
title = "Beacon Vouched"
|
||||
body = fmt.Sprintf("%s vouched for your beacon", actorName)
|
||||
data = map[string]string{
|
||||
"type": "beacon_vouch",
|
||||
"beacon_id": getString(metadata, "beacon_id"),
|
||||
"target": "beacon_map", // Deep link to map
|
||||
}
|
||||
case "beacon_report":
|
||||
data["beacon_id"] = req.PostID.String()
|
||||
|
||||
case models.NotificationTypeBeaconReport:
|
||||
title = "Beacon Reported"
|
||||
body = fmt.Sprintf("%s reported your beacon", actorName)
|
||||
data = map[string]string{
|
||||
"type": "beacon_report",
|
||||
"beacon_id": getString(metadata, "beacon_id"),
|
||||
"target": "beacon_map", // Deep link to map
|
||||
}
|
||||
case "comment":
|
||||
title = "New Comment"
|
||||
postType := getString(metadata, "post_type")
|
||||
if postType == "beacon" {
|
||||
body = fmt.Sprintf("%s commented on your beacon", actorName)
|
||||
data = map[string]string{
|
||||
"type": "comment",
|
||||
"post_id": getString(metadata, "post_id"),
|
||||
"target": "beacon_map", // Deep link to map for beacon comments
|
||||
}
|
||||
} else if postType == "quip" {
|
||||
body = fmt.Sprintf("%s commented on your quip", actorName)
|
||||
data = map[string]string{
|
||||
"type": "comment",
|
||||
"post_id": getString(metadata, "post_id"),
|
||||
"target": "quip_feed", // Deep link to quip feed
|
||||
}
|
||||
} else {
|
||||
body = fmt.Sprintf("%s commented on your post", actorName)
|
||||
data = map[string]string{
|
||||
"type": "comment",
|
||||
"post_id": getString(metadata, "post_id"),
|
||||
"target": "main_feed", // Deep link to main feed
|
||||
}
|
||||
}
|
||||
data["beacon_id"] = req.PostID.String()
|
||||
|
||||
default:
|
||||
title = "Sojorn"
|
||||
body = "You have a new notification"
|
||||
data = map[string]string{"type": notificationType}
|
||||
}
|
||||
|
||||
return title, body, data
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func parseNullableUUID(s *string) *uuid.UUID {
|
||||
if s == nil {
|
||||
func (s *NotificationService) getNavigationTarget(notifType, postType string) string {
|
||||
switch notifType {
|
||||
case models.NotificationTypeMessage:
|
||||
return "secure_chat"
|
||||
case models.NotificationTypeFollow, models.NotificationTypeFollowRequest, models.NotificationTypeFollowAccept:
|
||||
return "profile"
|
||||
case models.NotificationTypeBeaconVouch, models.NotificationTypeBeaconReport:
|
||||
return "beacon_map"
|
||||
default:
|
||||
switch postType {
|
||||
case "beacon":
|
||||
return "beacon_map"
|
||||
case "quip":
|
||||
return "quip_feed"
|
||||
default:
|
||||
return "main_feed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NotificationService) formatPostType(postType string) string {
|
||||
switch postType {
|
||||
case "beacon":
|
||||
return "beacon"
|
||||
case "quip":
|
||||
return "quip"
|
||||
default:
|
||||
return "post"
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Legacy Compatibility Method
|
||||
// ============================================================================
|
||||
|
||||
// CreateNotification is the legacy method for backwards compatibility
|
||||
func (s *NotificationService) CreateNotification(ctx context.Context, userID, actorID, notificationType string, postID *string, commentID *string, metadata map[string]interface{}) error {
|
||||
actorName, _ := metadata["actor_name"].(string)
|
||||
postType, _ := metadata["post_type"].(string)
|
||||
|
||||
req := models.PushNotificationRequest{
|
||||
UserID: uuid.MustParse(userID),
|
||||
Type: notificationType,
|
||||
ActorID: uuid.MustParse(actorID),
|
||||
PostType: postType,
|
||||
Priority: models.PriorityNormal,
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
if postID != nil {
|
||||
req.PostID = uuidPtr(*postID)
|
||||
}
|
||||
if commentID != nil {
|
||||
req.CommentID = uuidPtr(*commentID)
|
||||
}
|
||||
if actorName != "" {
|
||||
req.ActorName = actorName
|
||||
}
|
||||
|
||||
return s.sendNotification(ctx, req)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
func uuidPtr(s string) *uuid.UUID {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := uuid.Parse(*s)
|
||||
u, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &u
|
||||
}
|
||||
|
||||
func ptrString(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if val, ok := m[key]; ok {
|
||||
if str, ok := val.(string); ok {
|
||||
|
|
|
|||
|
|
@ -26,11 +26,10 @@ func NewPushService(userRepo *repository.UserRepository, credentialsFile string)
|
|||
opt = option.WithCredentialsFile(credentialsFile)
|
||||
} else {
|
||||
log.Warn().Msg("Firebase credentials file not found, using default credentials")
|
||||
opt = option.WithoutAuthentication() // Or handle differently
|
||||
opt = option.WithoutAuthentication()
|
||||
}
|
||||
} else {
|
||||
// Attempt to use logic suitable for Cloud Run/GCP or emulator
|
||||
opt = option.WithCredentialsFile("firebase-service-account.json") // Default fallback
|
||||
opt = option.WithCredentialsFile("firebase-service-account.json")
|
||||
}
|
||||
|
||||
app, err := firebase.NewApp(ctx, nil, opt)
|
||||
|
|
@ -51,17 +50,24 @@ func NewPushService(userRepo *repository.UserRepository, credentialsFile string)
|
|||
}, nil
|
||||
}
|
||||
|
||||
// SendPush sends a push notification to all user devices
|
||||
func (s *PushService) SendPush(ctx context.Context, userID, title, body string, data map[string]string) error {
|
||||
return s.SendPushWithBadge(ctx, userID, title, body, data, 0)
|
||||
}
|
||||
|
||||
// SendPushWithBadge sends a push notification with badge count for iOS
|
||||
func (s *PushService) SendPushWithBadge(ctx context.Context, userID, title, body string, data map[string]string, badge int) error {
|
||||
tokens, err := s.userRepo.GetFCMTokens(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get FCM tokens: %w", err)
|
||||
}
|
||||
|
||||
if len(tokens) == 0 {
|
||||
return nil // No tokens, no push
|
||||
log.Debug().Str("user_id", userID).Msg("No FCM tokens found for user")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Multicast message
|
||||
// Build the message
|
||||
message := &messaging.MulticastMessage{
|
||||
Tokens: tokens,
|
||||
Notification: &messaging.Notification{
|
||||
|
|
@ -72,10 +78,71 @@ func (s *PushService) SendPush(ctx context.Context, userID, title, body string,
|
|||
Android: &messaging.AndroidConfig{
|
||||
Priority: "high",
|
||||
Notification: &messaging.AndroidNotification{
|
||||
Sound: "default",
|
||||
ClickAction: "FLUTTER_NOTIFICATION_CLICK",
|
||||
Sound: "default",
|
||||
ClickAction: "FLUTTER_NOTIFICATION_CLICK",
|
||||
ChannelID: "sojorn_notifications",
|
||||
DefaultSound: true,
|
||||
DefaultVibrateTimings: true,
|
||||
NotificationCount: func() *int { c := badge; return &c }(),
|
||||
},
|
||||
},
|
||||
APNS: &messaging.APNSConfig{
|
||||
Headers: map[string]string{
|
||||
"apns-priority": "10",
|
||||
},
|
||||
Payload: &messaging.APNSPayload{
|
||||
Aps: &messaging.Aps{
|
||||
Sound: "default",
|
||||
Badge: &badge,
|
||||
MutableContent: true,
|
||||
ContentAvailable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Webpush: &messaging.WebpushConfig{
|
||||
Notification: &messaging.WebpushNotification{
|
||||
Title: title,
|
||||
Body: body,
|
||||
Icon: "/icons/icon-192.png",
|
||||
Badge: "/icons/badge-72.png",
|
||||
Data: data,
|
||||
},
|
||||
FCMOptions: &messaging.WebpushFCMOptions{
|
||||
Link: buildDeepLink(data),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
br, err := s.client.SendMulticast(ctx, message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending multicast message: %w", err)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("user_id", userID).
|
||||
Int("success_count", br.SuccessCount).
|
||||
Int("failure_count", br.FailureCount).
|
||||
Msg("Push notification sent")
|
||||
|
||||
if br.FailureCount > 0 {
|
||||
s.handleFailedTokens(ctx, userID, tokens, br.Responses)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendPushToTopics sends a push notification to a topic
|
||||
func (s *PushService) SendPushToTopic(ctx context.Context, topic, title, body string, data map[string]string) error {
|
||||
message := &messaging.Message{
|
||||
Topic: topic,
|
||||
Notification: &messaging.Notification{
|
||||
Title: title,
|
||||
Body: body,
|
||||
},
|
||||
Data: data,
|
||||
Android: &messaging.AndroidConfig{
|
||||
Priority: "high",
|
||||
},
|
||||
APNS: &messaging.APNSConfig{
|
||||
Payload: &messaging.APNSPayload{
|
||||
Aps: &messaging.Aps{
|
||||
|
|
@ -85,26 +152,102 @@ func (s *PushService) SendPush(ctx context.Context, userID, title, body string,
|
|||
},
|
||||
}
|
||||
|
||||
br, err := s.client.SendMulticast(ctx, message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending multicast message: %w", err)
|
||||
_, err := s.client.Send(ctx, message)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendSilentPush sends a data-only notification for badge updates
|
||||
func (s *PushService) SendSilentPush(ctx context.Context, userID string, data map[string]string, badge int) error {
|
||||
tokens, err := s.userRepo.GetFCMTokens(ctx, userID)
|
||||
if err != nil || len(tokens) == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
if br.FailureCount > 0 {
|
||||
var failedTokens []string
|
||||
for idx, resp := range br.Responses {
|
||||
if !resp.Success {
|
||||
if resp.Error != nil && messaging.IsRegistrationTokenNotRegistered(resp.Error) {
|
||||
if err := s.userRepo.DeleteFCMToken(ctx, userID, tokens[idx]); err != nil {
|
||||
log.Warn().Err(err).Str("user_id", userID).Msg("Failed to delete invalid FCM token")
|
||||
}
|
||||
continue
|
||||
message := &messaging.MulticastMessage{
|
||||
Tokens: tokens,
|
||||
Data: data,
|
||||
Android: &messaging.AndroidConfig{
|
||||
Priority: "normal",
|
||||
},
|
||||
APNS: &messaging.APNSConfig{
|
||||
Payload: &messaging.APNSPayload{
|
||||
Aps: &messaging.Aps{
|
||||
Badge: &badge,
|
||||
ContentAvailable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err = s.client.SendMulticast(ctx, message)
|
||||
return err
|
||||
}
|
||||
|
||||
// handleFailedTokens removes invalid tokens from the database
|
||||
func (s *PushService) handleFailedTokens(ctx context.Context, userID string, tokens []string, responses []*messaging.SendResponse) {
|
||||
var invalidTokens []string
|
||||
|
||||
for idx, resp := range responses {
|
||||
if !resp.Success {
|
||||
if resp.Error != nil && messaging.IsRegistrationTokenNotRegistered(resp.Error) {
|
||||
invalidTokens = append(invalidTokens, tokens[idx])
|
||||
if err := s.userRepo.DeleteFCMToken(ctx, userID, tokens[idx]); err != nil {
|
||||
log.Warn().Err(err).Str("user_id", userID).Msg("Failed to delete invalid FCM token")
|
||||
}
|
||||
failedTokens = append(failedTokens, tokens[idx])
|
||||
} else if resp.Error != nil {
|
||||
log.Warn().
|
||||
Err(resp.Error).
|
||||
Str("user_id", userID).
|
||||
Str("token", tokens[idx][:min(20, len(tokens[idx]))]).
|
||||
Msg("FCM send failed for token")
|
||||
}
|
||||
}
|
||||
log.Warn().Int("failure_count", br.FailureCount).Strs("failed_tokens", failedTokens).Msg("Some push notifications failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
if len(invalidTokens) > 0 {
|
||||
log.Info().
|
||||
Str("user_id", userID).
|
||||
Int("count", len(invalidTokens)).
|
||||
Msg("Cleaned up invalid FCM tokens")
|
||||
}
|
||||
}
|
||||
|
||||
// buildDeepLink creates a deep link URL from notification data
|
||||
func buildDeepLink(data map[string]string) string {
|
||||
target := data["target"]
|
||||
baseURL := "https://gosojorn.com"
|
||||
|
||||
switch target {
|
||||
case "secure_chat":
|
||||
if convID, ok := data["conversation_id"]; ok {
|
||||
return fmt.Sprintf("%s/chat/%s", baseURL, convID)
|
||||
}
|
||||
return baseURL + "/chat"
|
||||
case "profile":
|
||||
if followerID, ok := data["follower_id"]; ok {
|
||||
return fmt.Sprintf("%s/u/%s", baseURL, followerID)
|
||||
}
|
||||
return baseURL + "/profile"
|
||||
case "beacon_map":
|
||||
return baseURL + "/beacon"
|
||||
case "quip_feed":
|
||||
return baseURL + "/quips"
|
||||
case "thread_view":
|
||||
if postID, ok := data["post_id"]; ok {
|
||||
return fmt.Sprintf("%s/p/%s", baseURL, postID)
|
||||
}
|
||||
return baseURL
|
||||
default:
|
||||
if postID, ok := data["post_id"]; ok {
|
||||
return fmt.Sprintf("%s/p/%s", baseURL, postID)
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
|
|||
5
go-backend/seed_suggested.sql
Normal file
5
go-backend/seed_suggested.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
INSERT INTO suggested_users (user_id, reason, score) VALUES
|
||||
('e40f6513-0aae-40b4-8644-3edb1fe6a4e0', 'popular', 100),
|
||||
('be03a13a-4067-4b2e-829c-811caac2b5fb', 'popular', 95),
|
||||
('f4f341e6-42eb-45ac-8ce1-44d49388016c', 'new_creator', 80)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
|
@ -4,6 +4,8 @@ class ProfilePrivacySettings {
|
|||
final String postsVisibility;
|
||||
final String savedVisibility;
|
||||
final String followRequestPolicy;
|
||||
final String defaultPostVisibility;
|
||||
final bool isPrivateProfile;
|
||||
|
||||
const ProfilePrivacySettings({
|
||||
required this.userId,
|
||||
|
|
@ -11,6 +13,8 @@ class ProfilePrivacySettings {
|
|||
required this.postsVisibility,
|
||||
required this.savedVisibility,
|
||||
required this.followRequestPolicy,
|
||||
required this.defaultPostVisibility,
|
||||
required this.isPrivateProfile,
|
||||
});
|
||||
|
||||
factory ProfilePrivacySettings.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -20,6 +24,8 @@ class ProfilePrivacySettings {
|
|||
postsVisibility: json['posts_visibility'] as String? ?? 'public',
|
||||
savedVisibility: json['saved_visibility'] as String? ?? 'private',
|
||||
followRequestPolicy: json['follow_request_policy'] as String? ?? 'everyone',
|
||||
defaultPostVisibility: json['default_post_visibility'] as String? ?? 'public',
|
||||
isPrivateProfile: json['is_private_profile'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +36,8 @@ class ProfilePrivacySettings {
|
|||
'posts_visibility': postsVisibility,
|
||||
'saved_visibility': savedVisibility,
|
||||
'follow_request_policy': followRequestPolicy,
|
||||
'default_post_visibility': defaultPostVisibility,
|
||||
'is_private_profile': isPrivateProfile,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +46,8 @@ class ProfilePrivacySettings {
|
|||
String? postsVisibility,
|
||||
String? savedVisibility,
|
||||
String? followRequestPolicy,
|
||||
String? defaultPostVisibility,
|
||||
bool? isPrivateProfile,
|
||||
}) {
|
||||
return ProfilePrivacySettings(
|
||||
userId: userId,
|
||||
|
|
@ -45,6 +55,8 @@ class ProfilePrivacySettings {
|
|||
postsVisibility: postsVisibility ?? this.postsVisibility,
|
||||
savedVisibility: savedVisibility ?? this.savedVisibility,
|
||||
followRequestPolicy: followRequestPolicy ?? this.followRequestPolicy,
|
||||
defaultPostVisibility: defaultPostVisibility ?? this.defaultPostVisibility,
|
||||
isPrivateProfile: isPrivateProfile ?? this.isPrivateProfile,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +67,8 @@ class ProfilePrivacySettings {
|
|||
postsVisibility: 'public',
|
||||
savedVisibility: 'private',
|
||||
followRequestPolicy: 'everyone',
|
||||
defaultPostVisibility: 'public',
|
||||
isPrivateProfile: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,39 @@
|
|||
class UserSettings {
|
||||
final String userId;
|
||||
final String theme;
|
||||
final String language;
|
||||
final bool notificationsEnabled;
|
||||
final bool emailNotifications;
|
||||
final bool pushNotifications;
|
||||
final String contentFilterLevel;
|
||||
final bool autoPlayVideos;
|
||||
final bool dataSaverMode;
|
||||
final int? defaultPostTtl;
|
||||
|
||||
const UserSettings({
|
||||
required this.userId,
|
||||
this.theme = 'system',
|
||||
this.language = 'en',
|
||||
this.notificationsEnabled = true,
|
||||
this.emailNotifications = true,
|
||||
this.pushNotifications = true,
|
||||
this.contentFilterLevel = 'medium',
|
||||
this.autoPlayVideos = true,
|
||||
this.dataSaverMode = false,
|
||||
this.defaultPostTtl,
|
||||
});
|
||||
|
||||
factory UserSettings.fromJson(Map<String, dynamic> json) {
|
||||
return UserSettings(
|
||||
userId: json['user_id'] as String,
|
||||
theme: json['theme'] as String? ?? 'system',
|
||||
language: json['language'] as String? ?? 'en',
|
||||
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
|
||||
emailNotifications: json['email_notifications'] as bool? ?? true,
|
||||
pushNotifications: json['push_notifications'] as bool? ?? true,
|
||||
contentFilterLevel: json['content_filter_level'] as String? ?? 'medium',
|
||||
autoPlayVideos: json['auto_play_videos'] as bool? ?? true,
|
||||
dataSaverMode: json['data_saver_mode'] as bool? ?? false,
|
||||
defaultPostTtl: _parseIntervalHours(json['default_post_ttl']),
|
||||
);
|
||||
}
|
||||
|
|
@ -17,15 +41,39 @@ class UserSettings {
|
|||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'user_id': userId,
|
||||
'default_post_ttl': defaultPostTtl != null ? '${defaultPostTtl} hours' : null,
|
||||
'theme': theme,
|
||||
'language': language,
|
||||
'notifications_enabled': notificationsEnabled,
|
||||
'email_notifications': emailNotifications,
|
||||
'push_notifications': pushNotifications,
|
||||
'content_filter_level': contentFilterLevel,
|
||||
'auto_play_videos': autoPlayVideos,
|
||||
'data_saver_mode': dataSaverMode,
|
||||
'default_post_ttl': defaultPostTtl,
|
||||
};
|
||||
}
|
||||
|
||||
UserSettings copyWith({
|
||||
String? theme,
|
||||
String? language,
|
||||
bool? notificationsEnabled,
|
||||
bool? emailNotifications,
|
||||
bool? pushNotifications,
|
||||
String? contentFilterLevel,
|
||||
bool? autoPlayVideos,
|
||||
bool? dataSaverMode,
|
||||
int? defaultPostTtl,
|
||||
}) {
|
||||
return UserSettings(
|
||||
userId: userId,
|
||||
theme: theme ?? this.theme,
|
||||
language: language ?? this.language,
|
||||
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
|
||||
emailNotifications: emailNotifications ?? this.emailNotifications,
|
||||
pushNotifications: pushNotifications ?? this.pushNotifications,
|
||||
contentFilterLevel: contentFilterLevel ?? this.contentFilterLevel,
|
||||
autoPlayVideos: autoPlayVideos ?? this.autoPlayVideos,
|
||||
dataSaverMode: dataSaverMode ?? this.dataSaverMode,
|
||||
defaultPostTtl: defaultPostTtl ?? this.defaultPostTtl,
|
||||
);
|
||||
}
|
||||
|
|
@ -38,9 +86,9 @@ class UserSettings {
|
|||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
|
||||
final dayMatch = RegExp(r'(\\d+)\\s+day').firstMatch(trimmed);
|
||||
final hourMatch = RegExp(r'(\\d+)\\s+hour').firstMatch(trimmed);
|
||||
final timeMatch = RegExp(r'(\\d{1,2}):(\\d{2}):(\\d{2})').firstMatch(trimmed);
|
||||
final dayMatch = RegExp(r'(\d+)\s+day').firstMatch(trimmed);
|
||||
final hourMatch = RegExp(r'(\d+)\s+hour').firstMatch(trimmed);
|
||||
final timeMatch = RegExp(r'(\d{1,2}):(\d{2}):(\d{2})').firstMatch(trimmed);
|
||||
|
||||
var totalHours = 0;
|
||||
if (dayMatch != null) {
|
||||
|
|
|
|||
121
sojorn_app/lib/providers/settings_provider.dart
Normal file
121
sojorn_app/lib/providers/settings_provider.dart
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/profile.dart';
|
||||
import '../models/profile_privacy_settings.dart';
|
||||
import '../models/user_settings.dart';
|
||||
import '../models/trust_state.dart';
|
||||
import '../services/api_service.dart';
|
||||
|
||||
class SettingsState {
|
||||
final Profile? profile;
|
||||
final ProfilePrivacySettings? privacy;
|
||||
final UserSettings? user;
|
||||
final TrustState? trust;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
SettingsState({
|
||||
this.profile,
|
||||
this.privacy,
|
||||
this.user,
|
||||
this.trust,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
SettingsState copyWith({
|
||||
Profile? profile,
|
||||
ProfilePrivacySettings? privacy,
|
||||
UserSettings? user,
|
||||
TrustState? trust,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return SettingsState(
|
||||
profile: profile ?? this.profile,
|
||||
privacy: privacy ?? this.privacy,
|
||||
user: user ?? this.user,
|
||||
trust: trust ?? this.trust,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsNotifier extends StateNotifier<SettingsState> {
|
||||
final ApiService _apiService;
|
||||
|
||||
SettingsNotifier(this._apiService) : super(SettingsState()) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
try {
|
||||
final profileJson = await _apiService.callGoApi('/profile', method: 'GET');
|
||||
final privacyJson = await _apiService.callGoApi('/settings/privacy', method: 'GET');
|
||||
final userJson = await _apiService.callGoApi('/settings/user', method: 'GET');
|
||||
|
||||
// Trust state might be nested or a separate call
|
||||
// Based on my knowledge, it's often fetched with the profile or /trust-state
|
||||
TrustState? trust;
|
||||
try {
|
||||
final trustJson = await _apiService.callGoApi('/profile/trust-state', method: 'GET');
|
||||
trust = TrustState.fromJson(trustJson);
|
||||
} catch (_) {
|
||||
// Fallback or ignore if not available
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
profile: Profile.fromJson(profileJson['profile']),
|
||||
privacy: ProfilePrivacySettings.fromJson(privacyJson),
|
||||
user: UserSettings.fromJson(userJson),
|
||||
trust: trust,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updatePrivacy(ProfilePrivacySettings newPrivacy) async {
|
||||
final oldPrivacy = state.privacy;
|
||||
state = state.copyWith(privacy: newPrivacy);
|
||||
try {
|
||||
await _apiService.callGoApi('/settings/privacy', method: 'PATCH', body: newPrivacy.toJson());
|
||||
} catch (e) {
|
||||
state = state.copyWith(privacy: oldPrivacy, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateUser(UserSettings newUser) async {
|
||||
final oldUser = state.user;
|
||||
state = state.copyWith(user: newUser);
|
||||
try {
|
||||
await _apiService.callGoApi('/settings/user', method: 'PATCH', body: newUser.toJson());
|
||||
} catch (e) {
|
||||
state = state.copyWith(user: oldUser, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateProfile(Profile newProfile) async {
|
||||
final oldProfile = state.profile;
|
||||
state = state.copyWith(profile: newProfile);
|
||||
try {
|
||||
// Need to map updateProfile arguments or use map
|
||||
await _apiService.callGoApi('/profile', method: 'PATCH', body: {
|
||||
'display_name': newProfile.displayName,
|
||||
'bio': newProfile.bio,
|
||||
'location': newProfile.location,
|
||||
'website': newProfile.website,
|
||||
'avatar_url': newProfile.avatarUrl,
|
||||
'cover_url': newProfile.coverUrl,
|
||||
});
|
||||
} catch (e) {
|
||||
state = state.copyWith(profile: oldProfile, error: e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = StateNotifierProvider<SettingsNotifier, SettingsState>((ref) {
|
||||
return SettingsNotifier(ApiService.instance);
|
||||
});
|
||||
39
sojorn_app/lib/providers/upload_provider.dart
Normal file
39
sojorn_app/lib/providers/upload_provider.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class UploadProgress {
|
||||
final double progress;
|
||||
final bool isUploading;
|
||||
final String? error;
|
||||
|
||||
UploadProgress({this.progress = 0, this.isUploading = false, this.error});
|
||||
}
|
||||
|
||||
class UploadNotifier extends StateNotifier<UploadProgress> {
|
||||
UploadNotifier() : super(UploadProgress());
|
||||
|
||||
void setProgress(double progress) {
|
||||
state = UploadProgress(progress: progress, isUploading = true);
|
||||
}
|
||||
|
||||
void start() {
|
||||
state = UploadProgress(progress: 0, isUploading: true);
|
||||
}
|
||||
|
||||
void complete() {
|
||||
state = UploadProgress(progress: 1, isUploading: false);
|
||||
// Reset after success
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (state.progress == 1 && !state.isUploading) {
|
||||
state = UploadProgress();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void fail(String error) {
|
||||
state = UploadProgress(progress: 0, isUploading: false, error: error);
|
||||
}
|
||||
}
|
||||
|
||||
final uploadProvider = StateNotifierProvider<UploadNotifier, UploadProgress>((ref) {
|
||||
return UploadNotifier();
|
||||
});
|
||||
|
|
@ -15,7 +15,9 @@ import '../screens/quips/create/quip_creation_flow.dart';
|
|||
import '../screens/quips/feed/quips_feed_screen.dart';
|
||||
import '../screens/profile/profile_screen.dart';
|
||||
import '../screens/profile/viewable_profile_screen.dart';
|
||||
import '../screens/profile/blocked_users_screen.dart';
|
||||
import '../screens/auth/auth_gate.dart';
|
||||
import '../screens/discover/discover_screen.dart';
|
||||
import '../screens/secure_chat/secure_chat_full_screen.dart';
|
||||
|
||||
/// App routing config (GoRouter).
|
||||
|
|
@ -79,19 +81,8 @@ class AppRoutes {
|
|||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: beaconPrefix,
|
||||
builder: (_, state) {
|
||||
final latParam = state.uri.queryParameters['lat'];
|
||||
final longParam = state.uri.queryParameters['long'];
|
||||
final lat = latParam != null ? double.tryParse(latParam) : null;
|
||||
final long = longParam != null ? double.tryParse(longParam) : null;
|
||||
|
||||
if (lat != null && long != null) {
|
||||
return BeaconScreen(initialMapCenter: LatLng(lat, long));
|
||||
}
|
||||
|
||||
return const BeaconScreen();
|
||||
},
|
||||
path: '/discover',
|
||||
builder: (_, __) => const DiscoverScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -108,6 +99,12 @@ class AppRoutes {
|
|||
GoRoute(
|
||||
path: profile,
|
||||
builder: (_, __) => const ProfileScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'blocked',
|
||||
builder: (_, __) => const BlockedUsersScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
743
sojorn_app/lib/screens/discover/discover_screen.dart
Normal file
743
sojorn_app/lib/screens/discover/discover_screen.dart
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../providers/api_provider.dart';
|
||||
import '../../models/search_results.dart';
|
||||
import '../../models/post.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/sojorn_post_card.dart';
|
||||
import '../../widgets/media/signed_media_image.dart';
|
||||
import '../profile/viewable_profile_screen.dart';
|
||||
import '../compose/compose_screen.dart';
|
||||
import '../post/post_detail_screen.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Model for discover page data
|
||||
class DiscoverData {
|
||||
final List<Hashtag> topTags;
|
||||
final List<Post> popularPosts;
|
||||
|
||||
DiscoverData({
|
||||
required this.topTags,
|
||||
required this.popularPosts,
|
||||
});
|
||||
|
||||
factory DiscoverData.fromJson(Map<String, dynamic> json) {
|
||||
return DiscoverData(
|
||||
topTags: (json['top_tags'] as List? ?? [])
|
||||
.map((e) => Hashtag.fromJson(e))
|
||||
.toList(),
|
||||
popularPosts: (json['popular_posts'] as List? ?? [])
|
||||
.map((e) => Post.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Hashtag {
|
||||
final String id;
|
||||
final String name;
|
||||
final String displayName;
|
||||
final int useCount;
|
||||
final bool isTrending;
|
||||
|
||||
Hashtag({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.useCount,
|
||||
this.isTrending = false,
|
||||
});
|
||||
|
||||
factory Hashtag.fromJson(Map<String, dynamic> json) {
|
||||
return Hashtag(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
displayName: json['display_name'] ?? json['name'] ?? '',
|
||||
useCount: json['use_count'] ?? 0,
|
||||
isTrending: json['is_trending'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DiscoverScreen extends ConsumerStatefulWidget {
|
||||
final String? initialQuery;
|
||||
|
||||
const DiscoverScreen({super.key, this.initialQuery});
|
||||
|
||||
@override
|
||||
ConsumerState<DiscoverScreen> createState() => _DiscoverScreenState();
|
||||
}
|
||||
|
||||
class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
final TextEditingController searchController = TextEditingController();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
Timer? debounceTimer;
|
||||
bool isLoadingSearch = false;
|
||||
bool isLoadingDiscover = true;
|
||||
bool hasSearched = false;
|
||||
SearchResults? searchResults;
|
||||
DiscoverData? discoverData;
|
||||
List<RecentSearch> recentSearches = [];
|
||||
int _searchEpoch = 0;
|
||||
final Map<String, Future<Post>> _postFutures = {};
|
||||
|
||||
static const Duration debounceDuration = Duration(milliseconds: 250);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
loadRecentSearches();
|
||||
loadDiscoverData();
|
||||
|
||||
if (widget.initialQuery != null) {
|
||||
final query = widget.initialQuery!;
|
||||
searchController.text = query;
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
performSearch(query);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
searchController.dispose();
|
||||
focusNode.dispose();
|
||||
debounceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> loadDiscoverData() async {
|
||||
setState(() => isLoadingDiscover = true);
|
||||
try {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
final response = await apiService.get('/discover');
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
discoverData = DiscoverData.fromJson(response);
|
||||
isLoadingDiscover = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Error loading discover data: $e');
|
||||
if (mounted) {
|
||||
setState(() => isLoadingDiscover = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadRecentSearches() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final recentJson = prefs.getStringList('recent_searches') ?? [];
|
||||
setState(() {
|
||||
recentSearches = recentJson
|
||||
.map((e) => RecentSearch.fromJson(jsonDecode(e)))
|
||||
.toList();
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Error loading recent searches: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveRecentSearch(RecentSearch search) async {
|
||||
try {
|
||||
recentSearches.removeWhere(
|
||||
(s) => s.text.toLowerCase() == search.text.toLowerCase());
|
||||
recentSearches.insert(0, search);
|
||||
if (recentSearches.length > 10) {
|
||||
recentSearches = recentSearches.sublist(0, 10);
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final recentJson =
|
||||
recentSearches.map((e) => jsonEncode(e.toJson())).toList();
|
||||
await prefs.setStringList('recent_searches', recentJson);
|
||||
|
||||
if (mounted) setState(() {});
|
||||
} catch (e) {
|
||||
debugPrint('Error saving recent search: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearRecentSearches() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('recent_searches');
|
||||
setState(() {
|
||||
recentSearches = [];
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Error clearing recent searches: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void onSearchChanged(String value) {
|
||||
debounceTimer?.cancel();
|
||||
|
||||
final query = value.trim();
|
||||
if (query.isEmpty) {
|
||||
setState(() {
|
||||
searchResults = null;
|
||||
hasSearched = false;
|
||||
isLoadingSearch = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimer = Timer(debounceDuration, () {
|
||||
performSearch(query);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> performSearch(String query) async {
|
||||
final normalizedQuery = query.trim();
|
||||
if (normalizedQuery.isEmpty) return;
|
||||
final requestId = ++_searchEpoch;
|
||||
|
||||
setState(() {
|
||||
isLoadingSearch = true;
|
||||
hasSearched = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
final results = await apiService.search(normalizedQuery);
|
||||
if (!mounted || requestId != _searchEpoch) return;
|
||||
|
||||
if (results.users.isNotEmpty) {
|
||||
await saveRecentSearch(RecentSearch(
|
||||
id: results.users.first.id,
|
||||
text: results.users.first.username,
|
||||
searchedAt: DateTime.now(),
|
||||
type: RecentSearchType.user,
|
||||
));
|
||||
} else if (results.tags.isNotEmpty) {
|
||||
await saveRecentSearch(RecentSearch(
|
||||
id: 'tag_${results.tags.first.tag}',
|
||||
text: results.tags.first.tag,
|
||||
searchedAt: DateTime.now(),
|
||||
type: RecentSearchType.tag,
|
||||
));
|
||||
}
|
||||
|
||||
if (!mounted || requestId != _searchEpoch) return;
|
||||
setState(() {
|
||||
searchResults = results;
|
||||
isLoadingSearch = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted || requestId != _searchEpoch) return;
|
||||
setState(() {
|
||||
isLoadingSearch = false;
|
||||
searchResults = SearchResults(users: [], tags: [], posts: []);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void clearSearch() {
|
||||
searchController.clear();
|
||||
setState(() {
|
||||
searchResults = null;
|
||||
hasSearched = false;
|
||||
isLoadingSearch = false;
|
||||
});
|
||||
focusNode.unfocus();
|
||||
}
|
||||
|
||||
void _navigateToHashtag(String name) {
|
||||
final query = '#$name';
|
||||
searchController.text = query;
|
||||
performSearch(query);
|
||||
}
|
||||
|
||||
void _navigateToProfile(String handle) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ViewableProfileScreen(handle: handle),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Post> _getPostFuture(String postId) {
|
||||
return _postFutures.putIfAbsent(postId, () {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
return apiService.getPostById(postId);
|
||||
});
|
||||
}
|
||||
|
||||
void _retryPostLoad(String postId) {
|
||||
_postFutures.remove(postId);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _openPostDetail(Post post) {
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PostDetailScreen(post: post),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openChainComposer(Post post) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ComposeScreen(chainParentPost: post),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.scaffoldBg,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Expanded(
|
||||
child: hasSearched ? _buildSearchResults() : _buildDiscoverContent(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _buildSearchField()),
|
||||
if (hasSearched)
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, color: AppTheme.egyptianBlue),
|
||||
onPressed: clearSearch,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchField() {
|
||||
return Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.scaffoldBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.search,
|
||||
color: AppTheme.egyptianBlue.withOpacity(0.6), size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search people, hashtags, posts...',
|
||||
hintStyle: TextStyle(color: AppTheme.egyptianBlue.withOpacity(0.5)),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
style: AppTheme.bodyMedium,
|
||||
onChanged: onSearchChanged,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (value) {
|
||||
if (value.trim().isNotEmpty) {
|
||||
performSearch(value.trim());
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiscoverContent() {
|
||||
if (isLoadingDiscover) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: loadDiscoverData,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
|
||||
child: Text(
|
||||
'Top Trending',
|
||||
style: AppTheme.labelLarge.copyWith(
|
||||
color: AppTheme.navyBlue,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: discoverData?.topTags.length ?? 0,
|
||||
itemBuilder: (context, index) {
|
||||
final tag = discoverData!.topTags[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: ActionChip(
|
||||
label: Text('#${tag.displayName}'),
|
||||
labelStyle: TextStyle(
|
||||
color: AppTheme.royalPurple,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
backgroundColor: AppTheme.royalPurple.withOpacity(0.1),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
onPressed: () => _navigateToHashtag(tag.name),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||
child: Text(
|
||||
'Popular Now',
|
||||
style: AppTheme.labelLarge.copyWith(
|
||||
color: AppTheme.navyBlue,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (discoverData?.popularPosts.isEmpty ?? true)
|
||||
const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Text('No popular posts yet.'),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final post = discoverData!.popularPosts[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: sojornPostCard(
|
||||
post: post,
|
||||
onTap: () => _openPostDetail(post),
|
||||
onChain: () => _openChainComposer(post),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: discoverData!.popularPosts.length,
|
||||
),
|
||||
),
|
||||
const SliverPadding(padding: EdgeInsets.only(bottom: 50)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResults() {
|
||||
if (isLoadingSearch) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: AppTheme.royalPurple,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Searching...',
|
||||
style: AppTheme.labelMedium.copyWith(color: AppTheme.egyptianBlue)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (searchResults == null ||
|
||||
(searchResults!.users.isEmpty &&
|
||||
searchResults!.tags.isEmpty &&
|
||||
searchResults!.posts.isEmpty)) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search_off,
|
||||
size: 64, color: AppTheme.egyptianBlue.withOpacity(0.5)),
|
||||
const SizedBox(height: 16),
|
||||
Text('No results found',
|
||||
style: AppTheme.headlineSmall
|
||||
.copyWith(color: AppTheme.navyText.withOpacity(0.7))),
|
||||
const SizedBox(height: 8),
|
||||
Text('Try a different search term',
|
||||
style: AppTheme.bodyMedium.copyWith(color: AppTheme.egyptianBlue)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
children: [
|
||||
if (searchResults!.users.isNotEmpty) ...[
|
||||
_buildSectionHeader('People', icon: Icons.people),
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: searchResults!.users.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = searchResults!.users[index];
|
||||
return _buildUserResultItem(user);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (searchResults!.tags.isNotEmpty) ...[
|
||||
_buildSectionHeader('Hashtags', icon: Icons.tag),
|
||||
...searchResults!.tags.map((tag) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _buildTagResultItem(tag),
|
||||
)),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (searchResults!.posts.isNotEmpty) ...[
|
||||
_buildSectionHeader('Posts', icon: Icons.article),
|
||||
...searchResults!.posts.map((post) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _buildPostResultItem(post),
|
||||
)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title, {IconData? icon}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 20, color: AppTheme.royalPurple),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
title,
|
||||
style: AppTheme.labelMedium.copyWith(
|
||||
color: AppTheme.navyBlue,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserResultItem(SearchUser user) {
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToProfile(user.username),
|
||||
child: Container(
|
||||
width: 80,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundColor: AppTheme.royalPurple.withOpacity(0.2),
|
||||
child: user.avatarUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
child: SignedMediaImage(
|
||||
url: user.avatarUrl!,
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
user.displayName.isNotEmpty
|
||||
? user.displayName[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: AppTheme.royalPurple,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
user.displayName,
|
||||
style: TextStyle(
|
||||
color: AppTheme.navyText,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'@${user.username}',
|
||||
style: TextStyle(
|
||||
color: AppTheme.egyptianBlue.withOpacity(0.7), fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTagResultItem(SearchTag tag) {
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToHashtag(tag.tag),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.royalPurple.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(Icons.tag, color: AppTheme.royalPurple, size: 20),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('#${tag.tag}',
|
||||
style: TextStyle(
|
||||
color: AppTheme.navyText,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15)),
|
||||
Text('${tag.count} posts',
|
||||
style: TextStyle(
|
||||
color: AppTheme.egyptianBlue.withOpacity(0.7),
|
||||
fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right,
|
||||
color: AppTheme.egyptianBlue.withOpacity(0.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPostResultItem(SearchPost post) {
|
||||
return FutureBuilder<Post>(
|
||||
future: _getPostFuture(post.id),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppTheme.royalPurple,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('Loading post...', style: AppTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.hasError || !snapshot.hasData) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
color: AppTheme.egyptianBlue.withOpacity(0.6)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Unable to load post',
|
||||
style: AppTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _retryPostLoad(post.id),
|
||||
child: Text('Retry',
|
||||
style: AppTheme.labelMedium
|
||||
.copyWith(color: AppTheme.royalPurple)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final fullPost = snapshot.data!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: sojornPostCard(
|
||||
post: fullPost,
|
||||
onTap: () => _openPostDetail(fullPost),
|
||||
onChain: () => _openChainComposer(fullPost),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,10 +6,13 @@ import '../../theme/app_theme.dart';
|
|||
import '../notifications/notifications_screen.dart';
|
||||
import '../compose/compose_screen.dart';
|
||||
import '../search/search_screen.dart';
|
||||
import '../discover/discover_screen.dart';
|
||||
import '../beacon/beacon_screen.dart';
|
||||
import '../quips/create/quip_creation_flow.dart';
|
||||
import '../secure_chat/secure_chat_full_screen.dart';
|
||||
import '../../widgets/radial_menu_overlay.dart';
|
||||
import '../../providers/quip_upload_provider.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Root shell for the main tabs. The active tab is controlled by GoRouter's
|
||||
/// [StatefulNavigationShell] so navigation state and tab selection stay in sync.
|
||||
|
|
@ -92,25 +95,53 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
|||
offset: const Offset(0, 12),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _isRadialMenuVisible = !_isRadialMenuVisible),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.navyBlue.withOpacity(0.4),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Outer Ring for Upload Progress
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final upload = ref.watch(quipUploadProvider);
|
||||
|
||||
if (!upload.isUploading && upload.progress == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: CircularProgressIndicator(
|
||||
value: upload.progress,
|
||||
strokeWidth: 4,
|
||||
backgroundColor: AppTheme.egyptianBlue.withOpacity(0.1),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
upload.progress >= 0.99 ? Colors.green : AppTheme.brightNavy
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.navyBlue.withOpacity(0.4),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -169,10 +200,13 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
|||
|
||||
PreferredSizeWidget _buildAppBar() {
|
||||
return AppBar(
|
||||
title: Image.asset(
|
||||
'assets/images/toplogo.png',
|
||||
height: 38,
|
||||
fit: BoxFit.contain,
|
||||
title: InkWell(
|
||||
onTap: () => widget.navigationShell.goBranch(0),
|
||||
child: Image.asset(
|
||||
'assets/images/toplogo.png',
|
||||
height: 38,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
centerTitle: false,
|
||||
elevation: 0,
|
||||
|
|
@ -186,11 +220,9 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
|||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.search, color: AppTheme.navyBlue),
|
||||
tooltip: 'Search',
|
||||
tooltip: 'Discover',
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SearchScreen()),
|
||||
);
|
||||
widget.navigationShell.goBranch(1);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
|
|
|
|||
216
sojorn_app/lib/screens/profile/blocked_users_screen.dart
Normal file
216
sojorn_app/lib/screens/profile/blocked_users_screen.dart
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../providers/api_provider.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/app_scaffold.dart';
|
||||
|
||||
class BlockedUsersScreen extends ConsumerStatefulWidget {
|
||||
const BlockedUsersScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BlockedUsersScreen> createState() => _BlockedUsersScreenState();
|
||||
}
|
||||
|
||||
class _BlockedUsersScreenState extends ConsumerState<BlockedUsersScreen> {
|
||||
bool _isLoading = true;
|
||||
List<Profile> _blockedUsers = [];
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadBlockedUsers();
|
||||
}
|
||||
|
||||
Future<void> _loadBlockedUsers() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
final response = await apiService.callGoApi('/users/blocked', method: 'GET');
|
||||
final List<dynamic> usersJson = response['users'] ?? [];
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_blockedUsers = usersJson.map((j) => Profile.fromJson(j)).toList();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unblockUser(Profile user) async {
|
||||
try {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
await apiService.callGoApi('/users/${user.id}/block', method: 'DELETE');
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_blockedUsers.removeWhere((u) => u.id == user.id);
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${user.handle} unblocked')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to unblock: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportBlockList() async {
|
||||
try {
|
||||
final List<String> handles = _blockedUsers.map((u) => u.handle ?? '').where((h) => h.isNotEmpty).toList();
|
||||
final String jsonStr = jsonEncode({
|
||||
'version': 1,
|
||||
'type': 'sojorn_block_list',
|
||||
'exported_at': DateTime.now().toIso8601String(),
|
||||
'handles': handles,
|
||||
});
|
||||
|
||||
final directory = await getTemporaryDirectory();
|
||||
final file = File('${directory.path}/sojorn_blocklist.json');
|
||||
await file.writeAsString(jsonStr);
|
||||
|
||||
await Share.shareXFiles([XFile(file.path)], text: 'My Sojorn Blocklist');
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Export failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importBlockList() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['json'],
|
||||
);
|
||||
|
||||
if (result == null || result.files.single.path == null) return;
|
||||
|
||||
final file = File(result.files.single.path!);
|
||||
final String content = await file.readAsString();
|
||||
final data = jsonDecode(content);
|
||||
|
||||
if (data['type'] != 'sojorn_block_list') {
|
||||
throw 'Invalid file type';
|
||||
}
|
||||
|
||||
final List<dynamic> handles = data['handles'] ?? [];
|
||||
int count = 0;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
for (final handle in handles) {
|
||||
try {
|
||||
// Note: In a production app, we'd want a bulk block endpoint.
|
||||
// For now, we block individually to reuse existing logic.
|
||||
await apiService.callGoApi('/users/block_by_handle', method: 'POST', body: {'handle': handle});
|
||||
count++;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to block $handle: $e');
|
||||
}
|
||||
}
|
||||
|
||||
await _loadBlockedUsers();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Import complete: Blocked $count users')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Import failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
title: 'Blocked Users',
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.upload_file),
|
||||
onPressed: _importBlockList,
|
||||
tooltip: 'Import Blocklist',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: _exportBlockList,
|
||||
tooltip: 'Export Blocklist',
|
||||
),
|
||||
],
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
: _blockedUsers.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.block, size: 64, color: AppTheme.egyptianBlue.withOpacity(0.3)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No blocked users',
|
||||
style: AppTheme.textTheme.titleMedium?.copyWith(
|
||||
color: AppTheme.navyText.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
||||
itemCount: _blockedUsers.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final user = _blockedUsers[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppTheme.queenPink,
|
||||
backgroundImage: user.avatarUrl != null
|
||||
? NetworkImage(user.avatarUrl!)
|
||||
: null,
|
||||
child: user.avatarUrl == null
|
||||
? Text(user.displayName?[0].toUpperCase() ?? '?')
|
||||
: null,
|
||||
),
|
||||
title: Text(user.displayName ?? user.handle ?? 'User'),
|
||||
subtitle: Text('@${user.handle}'),
|
||||
trailing: OutlinedButton(
|
||||
onPressed: () => _unblockUser(user),
|
||||
child: const Text('Unblock'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
sojorn_app/lib/screens/profile/category_settings_screen.dart
Normal file
97
sojorn_app/lib/screens/profile/category_settings_screen.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../models/category.dart';
|
||||
import '../../providers/api_provider.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/app_scaffold.dart';
|
||||
|
||||
class CategoryDiscoveryScreen extends ConsumerStatefulWidget {
|
||||
const CategoryDiscoveryScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CategoryDiscoveryScreen> createState() => _CategoryDiscoveryScreenState();
|
||||
}
|
||||
|
||||
class _CategoryDiscoveryScreenState extends ConsumerState<CategoryDiscoveryScreen> {
|
||||
bool _isLoading = true;
|
||||
List<Category> _categories = [];
|
||||
Map<String, bool> _settings = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
final catsResponse = await apiService.callGoApi('/categories', method: 'GET');
|
||||
final settingsResponse = await apiService.callGoApi('/categories/settings', method: 'GET');
|
||||
|
||||
final List<dynamic> catsJson = catsResponse['categories'] ?? [];
|
||||
final List<dynamic> settingsJson = settingsResponse['settings'] ?? [];
|
||||
|
||||
final cats = catsJson.map((j) => Category.fromJson(j)).toList();
|
||||
final settingsMap = { for (var s in settingsJson) s['category_id'] as String : s['enabled'] as bool };
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_categories = cats;
|
||||
// Default to !defaultOff if no setting exists
|
||||
_settings = { for (var c in cats) c.id : settingsMap[c.id] ?? !c.defaultOff };
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleCategory(String catId, bool enabled) async {
|
||||
final oldVal = _settings[catId];
|
||||
setState(() => _settings[catId] = enabled);
|
||||
|
||||
try {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
await apiService.callGoApi('/categories/settings', method: 'POST', body: {
|
||||
'category_id': catId,
|
||||
'enabled': enabled,
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _settings[catId] = oldVal ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
title: 'Discovery Cohorts',
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingLg),
|
||||
itemCount: _categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final cat = _categories[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.1)),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
activeColor: AppTheme.brightNavy,
|
||||
title: Text(cat.name, style: AppTheme.textTheme.labelLarge),
|
||||
subtitle: Text(cat.description, style: AppTheme.textTheme.bodySmall),
|
||||
value: _settings[cat.id] ?? false,
|
||||
onChanged: (v) => _toggleCategory(cat.id, v),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -31,14 +31,19 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
SearchResults? results;
|
||||
List<RecentSearch> recentSearches = [];
|
||||
int _searchEpoch = 0;
|
||||
|
||||
final Map<String, Future<Post>> _postFutures = {};
|
||||
|
||||
// Discovery State
|
||||
bool _isDiscoveryLoading = false;
|
||||
List<Post> _discoveryPosts = [];
|
||||
|
||||
static const Duration debounceDuration = Duration(milliseconds: 250);
|
||||
static const List<String> trendingTags = [
|
||||
'safety',
|
||||
'wellness',
|
||||
'growth',
|
||||
'mindfulness',
|
||||
'focus',
|
||||
'community',
|
||||
'insights',
|
||||
'reflection',
|
||||
|
|
@ -62,6 +67,25 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
_loadDiscoveryContent();
|
||||
}
|
||||
|
||||
Future<void> _loadDiscoveryContent() async {
|
||||
setState(() => _isDiscoveryLoading = true);
|
||||
try {
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
// Fetch feed content to use as "Popular Now" / Discovery
|
||||
final posts = await apiService.getSojornFeed(limit: 10);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_discoveryPosts = posts;
|
||||
_isDiscoveryLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _isDiscoveryLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -300,7 +324,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
if (isLoading) return buildLoadingState();
|
||||
if (hasSearched && results != null) return buildResultsState();
|
||||
if (recentSearches.isNotEmpty) return buildRecentSearchesState();
|
||||
return buildTrendingState();
|
||||
return buildDiscoveryState();
|
||||
}
|
||||
|
||||
Widget buildLoadingState() {
|
||||
|
|
@ -380,6 +404,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (results!.posts.isNotEmpty) ...[
|
||||
buildSectionHeader('Posts'),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: results!.posts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final post = results!.posts[index];
|
||||
return buildPostResultItem(post);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (results!.tags.isNotEmpty) ...[
|
||||
buildSectionHeader(isTagSearch ? 'Tag' : 'Tags'),
|
||||
ListView.builder(
|
||||
|
|
@ -407,19 +445,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (results!.posts.isNotEmpty) ...[
|
||||
buildSectionHeader('Posts'),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: results!.posts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final post = results!.posts[index];
|
||||
return buildPostResultItem(post);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -475,17 +500,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget buildTrendingState() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text('Trending Tags',
|
||||
style: AppTheme.labelMedium.copyWith(color: AppTheme.navyBlue)),
|
||||
),
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
Widget buildDiscoveryState() {
|
||||
return SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text('Top Trending',
|
||||
style: AppTheme.labelMedium.copyWith(color: AppTheme.navyBlue)),
|
||||
),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
|
|
@ -506,8 +534,43 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (_isDiscoveryLoading) ...[
|
||||
const SizedBox(height: 32),
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
] else if (_discoveryPosts.isNotEmpty) ...[
|
||||
const SizedBox(height: 32),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Text('Popular Now',
|
||||
style: AppTheme.labelMedium.copyWith(color: AppTheme.navyBlue)),
|
||||
),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _discoveryPosts.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final post = _discoveryPosts[index];
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
|
||||
),
|
||||
child: sojornPostCard(
|
||||
post: post,
|
||||
onTap: () => _openPostDetail(post),
|
||||
onChain: () => _openChainComposer(post),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -170,6 +170,12 @@ class ApiService {
|
|||
return [];
|
||||
}
|
||||
|
||||
/// Simple GET request helper
|
||||
Future<Map<String, dynamic>> get(String path, {Map<String, String>? queryParams}) async {
|
||||
return _callGoApi(path, method: 'GET', queryParams: queryParams);
|
||||
}
|
||||
|
||||
|
||||
Future<void> resendVerificationEmail(String email) async {
|
||||
await _callGoApi('/auth/resend-verification',
|
||||
method: 'POST', body: {'email': email});
|
||||
|
|
@ -714,27 +720,6 @@ class ApiService {
|
|||
}
|
||||
}
|
||||
|
||||
// Beacon voting - migrated to Go API
|
||||
Future<void> vouchBeacon(String beaconId) async {
|
||||
await _callGoApi(
|
||||
'/beacons/$beaconId/vouch',
|
||||
method: 'POST',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reportBeacon(String beaconId) async {
|
||||
await _callGoApi(
|
||||
'/beacons/$beaconId/report',
|
||||
method: 'POST',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeBeaconVote(String beaconId) async {
|
||||
await _callGoApi(
|
||||
'/beacons/$beaconId/vouch',
|
||||
method: 'DELETE',
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Social Actions
|
||||
|
|
@ -1030,7 +1015,7 @@ class ApiService {
|
|||
return posts.map((p) => Post.fromJson(p)).toList();
|
||||
}
|
||||
|
||||
Future<List<Post>> getsojornFeed({int limit = 20, int offset = 0}) async {
|
||||
Future<List<Post>> getSojornFeed({int limit = 20, int offset = 0}) async {
|
||||
return getPersonalFeed(limit: limit, offset: offset);
|
||||
}
|
||||
|
||||
|
|
@ -1095,6 +1080,31 @@ class ApiService {
|
|||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Beacon Actions
|
||||
// =========================================================================
|
||||
|
||||
Future<void> vouchBeacon(String beaconId) async {
|
||||
await callGoApi(
|
||||
'/beacons/$beaconId/vouch',
|
||||
method: 'POST',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reportBeacon(String beaconId) async {
|
||||
await callGoApi(
|
||||
'/beacons/$beaconId/report',
|
||||
method: 'POST',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeBeaconVote(String beaconId) async {
|
||||
await callGoApi(
|
||||
'/beacons/$beaconId/vouch',
|
||||
method: 'DELETE',
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Key Backup & Recovery
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../config/firebase_web_config.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
|
|
@ -8,6 +13,144 @@ import '../services/secure_chat_service.dart';
|
|||
import '../screens/secure_chat/secure_chat_screen.dart';
|
||||
import 'api_service.dart';
|
||||
|
||||
/// NotificationPreferences model
|
||||
class NotificationPreferences {
|
||||
final bool pushEnabled;
|
||||
final bool pushLikes;
|
||||
final bool pushComments;
|
||||
final bool pushReplies;
|
||||
final bool pushMentions;
|
||||
final bool pushFollows;
|
||||
final bool pushFollowRequests;
|
||||
final bool pushMessages;
|
||||
final bool pushSaves;
|
||||
final bool pushBeacons;
|
||||
final bool emailEnabled;
|
||||
final String emailDigestFrequency;
|
||||
final bool quietHoursEnabled;
|
||||
final String? quietHoursStart;
|
||||
final String? quietHoursEnd;
|
||||
final bool showBadgeCount;
|
||||
|
||||
NotificationPreferences({
|
||||
this.pushEnabled = true,
|
||||
this.pushLikes = true,
|
||||
this.pushComments = true,
|
||||
this.pushReplies = true,
|
||||
this.pushMentions = true,
|
||||
this.pushFollows = true,
|
||||
this.pushFollowRequests = true,
|
||||
this.pushMessages = true,
|
||||
this.pushSaves = true,
|
||||
this.pushBeacons = true,
|
||||
this.emailEnabled = false,
|
||||
this.emailDigestFrequency = 'never',
|
||||
this.quietHoursEnabled = false,
|
||||
this.quietHoursStart,
|
||||
this.quietHoursEnd,
|
||||
this.showBadgeCount = true,
|
||||
});
|
||||
|
||||
factory NotificationPreferences.fromJson(Map<String, dynamic> json) {
|
||||
return NotificationPreferences(
|
||||
pushEnabled: json['push_enabled'] ?? true,
|
||||
pushLikes: json['push_likes'] ?? true,
|
||||
pushComments: json['push_comments'] ?? true,
|
||||
pushReplies: json['push_replies'] ?? true,
|
||||
pushMentions: json['push_mentions'] ?? true,
|
||||
pushFollows: json['push_follows'] ?? true,
|
||||
pushFollowRequests: json['push_follow_requests'] ?? true,
|
||||
pushMessages: json['push_messages'] ?? true,
|
||||
pushSaves: json['push_saves'] ?? true,
|
||||
pushBeacons: json['push_beacons'] ?? true,
|
||||
emailEnabled: json['email_enabled'] ?? false,
|
||||
emailDigestFrequency: json['email_digest_frequency'] ?? 'never',
|
||||
quietHoursEnabled: json['quiet_hours_enabled'] ?? false,
|
||||
quietHoursStart: json['quiet_hours_start'],
|
||||
quietHoursEnd: json['quiet_hours_end'],
|
||||
showBadgeCount: json['show_badge_count'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'push_enabled': pushEnabled,
|
||||
'push_likes': pushLikes,
|
||||
'push_comments': pushComments,
|
||||
'push_replies': pushReplies,
|
||||
'push_mentions': pushMentions,
|
||||
'push_follows': pushFollows,
|
||||
'push_follow_requests': pushFollowRequests,
|
||||
'push_messages': pushMessages,
|
||||
'push_saves': pushSaves,
|
||||
'push_beacons': pushBeacons,
|
||||
'email_enabled': emailEnabled,
|
||||
'email_digest_frequency': emailDigestFrequency,
|
||||
'quiet_hours_enabled': quietHoursEnabled,
|
||||
'quiet_hours_start': quietHoursStart,
|
||||
'quiet_hours_end': quietHoursEnd,
|
||||
'show_badge_count': showBadgeCount,
|
||||
};
|
||||
|
||||
NotificationPreferences copyWith({
|
||||
bool? pushEnabled,
|
||||
bool? pushLikes,
|
||||
bool? pushComments,
|
||||
bool? pushReplies,
|
||||
bool? pushMentions,
|
||||
bool? pushFollows,
|
||||
bool? pushFollowRequests,
|
||||
bool? pushMessages,
|
||||
bool? pushSaves,
|
||||
bool? pushBeacons,
|
||||
bool? emailEnabled,
|
||||
String? emailDigestFrequency,
|
||||
bool? quietHoursEnabled,
|
||||
String? quietHoursStart,
|
||||
String? quietHoursEnd,
|
||||
bool? showBadgeCount,
|
||||
}) {
|
||||
return NotificationPreferences(
|
||||
pushEnabled: pushEnabled ?? this.pushEnabled,
|
||||
pushLikes: pushLikes ?? this.pushLikes,
|
||||
pushComments: pushComments ?? this.pushComments,
|
||||
pushReplies: pushReplies ?? this.pushReplies,
|
||||
pushMentions: pushMentions ?? this.pushMentions,
|
||||
pushFollows: pushFollows ?? this.pushFollows,
|
||||
pushFollowRequests: pushFollowRequests ?? this.pushFollowRequests,
|
||||
pushMessages: pushMessages ?? this.pushMessages,
|
||||
pushSaves: pushSaves ?? this.pushSaves,
|
||||
pushBeacons: pushBeacons ?? this.pushBeacons,
|
||||
emailEnabled: emailEnabled ?? this.emailEnabled,
|
||||
emailDigestFrequency: emailDigestFrequency ?? this.emailDigestFrequency,
|
||||
quietHoursEnabled: quietHoursEnabled ?? this.quietHoursEnabled,
|
||||
quietHoursStart: quietHoursStart ?? this.quietHoursStart,
|
||||
quietHoursEnd: quietHoursEnd ?? this.quietHoursEnd,
|
||||
showBadgeCount: showBadgeCount ?? this.showBadgeCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge count model
|
||||
class UnreadBadge {
|
||||
final int notificationCount;
|
||||
final int messageCount;
|
||||
final int totalCount;
|
||||
|
||||
UnreadBadge({
|
||||
this.notificationCount = 0,
|
||||
this.messageCount = 0,
|
||||
this.totalCount = 0,
|
||||
});
|
||||
|
||||
factory UnreadBadge.fromJson(Map<String, dynamic> json) {
|
||||
return UnreadBadge(
|
||||
notificationCount: json['notification_count'] ?? 0,
|
||||
messageCount: json['message_count'] ?? 0,
|
||||
totalCount: json['total_count'] ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationService {
|
||||
NotificationService._internal();
|
||||
|
||||
|
|
@ -19,6 +162,19 @@ class NotificationService {
|
|||
String? _currentToken;
|
||||
String? _cachedVapidKey;
|
||||
|
||||
// Badge count stream for UI updates
|
||||
final StreamController<UnreadBadge> _badgeController = StreamController<UnreadBadge>.broadcast();
|
||||
Stream<UnreadBadge> get badgeStream => _badgeController.stream;
|
||||
UnreadBadge _currentBadge = UnreadBadge();
|
||||
UnreadBadge get currentBadge => _currentBadge;
|
||||
|
||||
// Foreground notification stream for in-app banners
|
||||
final StreamController<RemoteMessage> _foregroundMessageController = StreamController<RemoteMessage>.broadcast();
|
||||
Stream<RemoteMessage> get foregroundMessages => _foregroundMessageController.stream;
|
||||
|
||||
// Global overlay entry for in-app notification banner
|
||||
OverlayEntry? _currentBannerOverlay;
|
||||
|
||||
Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
|
|
@ -26,6 +182,15 @@ class NotificationService {
|
|||
try {
|
||||
debugPrint('[FCM] Initializing for platform: ${_resolveDeviceType()}');
|
||||
|
||||
// Android 13+ requires explicit runtime permission request
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
final permissionStatus = await _requestAndroidNotificationPermission();
|
||||
if (permissionStatus != PermissionStatus.granted) {
|
||||
debugPrint('[FCM] Android notification permission not granted: $permissionStatus');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final settings = await _messaging.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
|
|
@ -52,31 +217,41 @@ class NotificationService {
|
|||
|
||||
if (token != null) {
|
||||
_currentToken = token;
|
||||
debugPrint('[FCM] Token registered (${_resolveDeviceType()}): $token');
|
||||
debugPrint('[FCM] Token registered (${_resolveDeviceType()}): ${token.substring(0, 20)}...');
|
||||
await _upsertToken(token);
|
||||
} else {
|
||||
debugPrint('[FCM] WARNING: Token is null after getToken()');
|
||||
}
|
||||
|
||||
_messaging.onTokenRefresh.listen((newToken) {
|
||||
debugPrint('[FCM] Token refreshed: $newToken');
|
||||
debugPrint('[FCM] Token refreshed');
|
||||
_currentToken = newToken;
|
||||
_upsertToken(newToken);
|
||||
});
|
||||
|
||||
// Handle messages when app is opened from notification
|
||||
FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageOpen);
|
||||
|
||||
// Handle foreground messages - show in-app banner
|
||||
FirebaseMessaging.onMessage.listen((message) {
|
||||
debugPrint('[FCM] Foreground message received: ${message.messageId}');
|
||||
debugPrint('[FCM] Message data: ${message.data}');
|
||||
debugPrint('[FCM] Notification: ${message.notification?.title}');
|
||||
debugPrint('[FCM] Foreground message received: ${message.notification?.title}');
|
||||
_foregroundMessageController.add(message);
|
||||
_refreshBadgeCount();
|
||||
});
|
||||
|
||||
// Check for initial message (app opened from terminated state)
|
||||
final initialMessage = await _messaging.getInitialMessage();
|
||||
if (initialMessage != null) {
|
||||
debugPrint('[FCM] App opened from notification: ${initialMessage.messageId}');
|
||||
await _handleMessageOpen(initialMessage);
|
||||
debugPrint('[FCM] App opened from notification');
|
||||
// Delay to allow navigation setup
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
_handleMessageOpen(initialMessage);
|
||||
});
|
||||
}
|
||||
|
||||
// Initial badge count fetch
|
||||
await _refreshBadgeCount();
|
||||
|
||||
debugPrint('[FCM] Initialization complete');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[FCM] Failed to initialize notifications: $e');
|
||||
|
|
@ -84,24 +259,52 @@ class NotificationService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Request POST_NOTIFICATIONS permission for Android 13+ (API 33+)
|
||||
Future<PermissionStatus> _requestAndroidNotificationPermission() async {
|
||||
try {
|
||||
final status = await Permission.notification.status;
|
||||
debugPrint('[FCM] Current Android permission status: $status');
|
||||
|
||||
if (status.isDenied || status.isRestricted) {
|
||||
final result = await Permission.notification.request();
|
||||
debugPrint('[FCM] Android permission request result: $result');
|
||||
return result;
|
||||
}
|
||||
|
||||
return status;
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] Error requesting Android notification permission: $e');
|
||||
return PermissionStatus.granted;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the current device's FCM token (call on logout)
|
||||
Future<void> removeToken() async {
|
||||
if (_currentToken == null) return;
|
||||
if (_currentToken == null) {
|
||||
debugPrint('[FCM] No token to revoke');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('[FCM] Revoking token...');
|
||||
debugPrint('[FCM] Revoking token from backend...');
|
||||
await ApiService.instance.callGoApi(
|
||||
'/notifications/device',
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
'fcm_token': _currentToken,
|
||||
'token': _currentToken,
|
||||
},
|
||||
);
|
||||
debugPrint('[FCM] Token revoked successfully');
|
||||
debugPrint('[FCM] Token revoked successfully from backend');
|
||||
|
||||
await _messaging.deleteToken();
|
||||
debugPrint('[FCM] Token deleted from Firebase');
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] Failed to revoke token: $e');
|
||||
} finally {
|
||||
_currentToken = null;
|
||||
_initialized = false;
|
||||
_currentBadge = UnreadBadge();
|
||||
_badgeController.add(_currentBadge);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,17 +328,9 @@ class NotificationService {
|
|||
|
||||
String _resolveDeviceType() {
|
||||
if (kIsWeb) return 'web';
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.iOS:
|
||||
return 'ios';
|
||||
case TargetPlatform.android:
|
||||
return 'android';
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.macOS:
|
||||
case TargetPlatform.windows:
|
||||
return 'desktop';
|
||||
}
|
||||
if (Platform.isAndroid) return 'android';
|
||||
if (Platform.isIOS) return 'ios';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
Future<String?> _resolveVapidKey() async {
|
||||
|
|
@ -151,19 +346,203 @@ class NotificationService {
|
|||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Badge Count Management
|
||||
// ============================================================================
|
||||
|
||||
Future<void> _refreshBadgeCount() async {
|
||||
try {
|
||||
final response = await ApiService.instance.callGoApi(
|
||||
'/notifications/badge',
|
||||
method: 'GET',
|
||||
);
|
||||
_currentBadge = UnreadBadge.fromJson(response);
|
||||
_badgeController.add(_currentBadge);
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] Failed to refresh badge count: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Call this after marking notifications as read
|
||||
Future<void> refreshBadge() => _refreshBadgeCount();
|
||||
|
||||
// ============================================================================
|
||||
// Preferences Management
|
||||
// ============================================================================
|
||||
|
||||
Future<NotificationPreferences> getPreferences() async {
|
||||
try {
|
||||
final response = await ApiService.instance.callGoApi(
|
||||
'/notifications/preferences',
|
||||
method: 'GET',
|
||||
);
|
||||
return NotificationPreferences.fromJson(response);
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] Failed to get preferences: $e');
|
||||
return NotificationPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> updatePreferences(NotificationPreferences prefs) async {
|
||||
try {
|
||||
await ApiService.instance.callGoApi(
|
||||
'/notifications/preferences',
|
||||
method: 'PUT',
|
||||
body: prefs.toJson(),
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] Failed to update preferences: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Notification Actions
|
||||
// ============================================================================
|
||||
|
||||
Future<void> markAsRead(String notificationId) async {
|
||||
try {
|
||||
await ApiService.instance.callGoApi(
|
||||
'/notifications/$notificationId/read',
|
||||
method: 'PUT',
|
||||
);
|
||||
await _refreshBadgeCount();
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] Failed to mark as read: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> markAllAsRead() async {
|
||||
try {
|
||||
await ApiService.instance.callGoApi(
|
||||
'/notifications/read-all',
|
||||
method: 'PUT',
|
||||
);
|
||||
_currentBadge = UnreadBadge();
|
||||
_badgeController.add(_currentBadge);
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] Failed to mark all as read: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// In-App Notification Banner
|
||||
// ============================================================================
|
||||
|
||||
/// Show an in-app notification banner
|
||||
void showNotificationBanner(BuildContext context, RemoteMessage message) {
|
||||
// Dismiss any existing banner
|
||||
_dismissCurrentBanner();
|
||||
|
||||
final overlay = Overlay.of(context);
|
||||
|
||||
_currentBannerOverlay = OverlayEntry(
|
||||
builder: (context) => _NotificationBanner(
|
||||
message: message,
|
||||
onDismiss: _dismissCurrentBanner,
|
||||
onTap: () {
|
||||
_dismissCurrentBanner();
|
||||
_handleMessageOpen(message);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
overlay.insert(_currentBannerOverlay!);
|
||||
|
||||
// Auto-dismiss after 4 seconds
|
||||
Future.delayed(const Duration(seconds: 4), _dismissCurrentBanner);
|
||||
}
|
||||
|
||||
void _dismissCurrentBanner() {
|
||||
_currentBannerOverlay?.remove();
|
||||
_currentBannerOverlay = null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Navigation Handling
|
||||
// ============================================================================
|
||||
|
||||
Future<void> _handleMessageOpen(RemoteMessage message) async {
|
||||
final data = message.data;
|
||||
if (data['type'] != 'chat' && data['type'] != 'new_message') return;
|
||||
final conversationId = data['conversation_id'];
|
||||
if (conversationId == null) return;
|
||||
final type = data['type'] as String?;
|
||||
|
||||
debugPrint('[FCM] Handling message open - type: $type, data: $data');
|
||||
|
||||
await _openConversation(conversationId.toString());
|
||||
final navigator = AppRoutes.rootNavigatorKey.currentState;
|
||||
if (navigator == null) {
|
||||
debugPrint('[FCM] Navigator not available');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'chat':
|
||||
case 'new_message':
|
||||
case 'message':
|
||||
final conversationId = data['conversation_id'];
|
||||
if (conversationId != null) {
|
||||
await _openConversation(conversationId.toString());
|
||||
}
|
||||
break;
|
||||
|
||||
case 'like':
|
||||
case 'save':
|
||||
case 'comment':
|
||||
case 'reply':
|
||||
case 'mention':
|
||||
final postId = data['post_id'];
|
||||
final target = data['target'];
|
||||
if (postId != null) {
|
||||
_navigateToPost(navigator, postId, target);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'new_follower':
|
||||
case 'follow':
|
||||
case 'follow_request':
|
||||
case 'follow_accepted':
|
||||
final followerId = data['follower_id'];
|
||||
if (followerId != null) {
|
||||
navigator.context.push('/u/$followerId');
|
||||
} else {
|
||||
navigator.context.go(AppRoutes.profile);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'beacon_vouch':
|
||||
case 'beacon_report':
|
||||
navigator.context.go(AppRoutes.beaconPrefix);
|
||||
break;
|
||||
|
||||
default:
|
||||
debugPrint('[FCM] Unknown notification type: $type');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToPost(NavigatorState navigator, String postId, String? target) {
|
||||
switch (target) {
|
||||
case 'beacon_map':
|
||||
navigator.context.go(AppRoutes.beaconPrefix);
|
||||
break;
|
||||
case 'quip_feed':
|
||||
navigator.context.go(AppRoutes.quips);
|
||||
break;
|
||||
case 'thread_view':
|
||||
case 'main_feed':
|
||||
default:
|
||||
navigator.context.go(AppRoutes.home);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openConversation(String conversationId) async {
|
||||
final conversation =
|
||||
await SecureChatService.instance.getConversationById(conversationId);
|
||||
if (conversation == null) return;
|
||||
if (conversation == null) {
|
||||
debugPrint('[FCM] Conversation not found: $conversationId');
|
||||
return;
|
||||
}
|
||||
|
||||
final navigator = AppRoutes.rootNavigatorKey.currentState;
|
||||
if (navigator == null) return;
|
||||
|
|
@ -174,4 +553,204 @@ class NotificationService {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_badgeController.close();
|
||||
_foregroundMessageController.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// In-App Notification Banner Widget
|
||||
// ============================================================================
|
||||
|
||||
class _NotificationBanner extends StatefulWidget {
|
||||
final RemoteMessage message;
|
||||
final VoidCallback onDismiss;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _NotificationBanner({
|
||||
required this.message,
|
||||
required this.onDismiss,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_NotificationBanner> createState() => _NotificationBannerState();
|
||||
}
|
||||
|
||||
class _NotificationBannerState extends State<_NotificationBanner>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, -1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
||||
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(_controller);
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _getNotificationIcon(String? type) {
|
||||
switch (type) {
|
||||
case 'like':
|
||||
return '❤️';
|
||||
case 'comment':
|
||||
case 'reply':
|
||||
return '💬';
|
||||
case 'mention':
|
||||
return '@';
|
||||
case 'follow':
|
||||
case 'new_follower':
|
||||
return '👤';
|
||||
case 'follow_request':
|
||||
return '🔔';
|
||||
case 'message':
|
||||
case 'chat':
|
||||
case 'new_message':
|
||||
return '✉️';
|
||||
case 'save':
|
||||
return '🔖';
|
||||
case 'beacon_vouch':
|
||||
return '✅';
|
||||
case 'beacon_report':
|
||||
return '⚠️';
|
||||
default:
|
||||
return '🔔';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notification = widget.message.notification;
|
||||
final type = widget.message.data['type'] as String?;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
return Positioned(
|
||||
top: mediaQuery.padding.top + 8,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.transparent,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (details.primaryVelocity != null &&
|
||||
details.primaryVelocity!.abs() > 500) {
|
||||
widget.onDismiss();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.grey[900]!,
|
||||
Colors.grey[850]!,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icon
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_getNotificationIcon(type),
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Content
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
notification?.title ?? 'Sojorn',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (notification?.body != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
notification!.body!,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Dismiss button
|
||||
GestureDetector(
|
||||
onTap: widget.onDismiss,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ class AppTheme {
|
|||
static TextStyle get bodyLarge => textTheme.bodyLarge!;
|
||||
static TextStyle get headlineMedium => textTheme.headlineMedium!;
|
||||
static TextStyle get headlineSmall => textTheme.headlineSmall!;
|
||||
static TextStyle get labelLarge => textTheme.labelLarge!;
|
||||
static TextStyle get labelMedium => textTheme.labelMedium!;
|
||||
static TextStyle get labelSmall => textTheme.labelSmall!;
|
||||
|
||||
|
|
|
|||
320
sojorn_app/lib/widgets/modals/sanctuary_sheet.dart
Normal file
320
sojorn_app/lib/widgets/modals/sanctuary_sheet.dart
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/post.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../services/api_service.dart';
|
||||
|
||||
class SanctuarySheet extends StatefulWidget {
|
||||
final Post post;
|
||||
|
||||
const SanctuarySheet({super.key, required this.post});
|
||||
|
||||
static Future<void> show(BuildContext context, Post post) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => SanctuarySheet(post: post),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<SanctuarySheet> createState() => _SanctuarySheetState();
|
||||
}
|
||||
|
||||
class _SanctuarySheetState extends State<SanctuarySheet> {
|
||||
int _step = 0; // 0: Options, 1: Report Type, 2: Report Description, 3: Block Confirmation
|
||||
String? _violationType;
|
||||
final TextEditingController _descriptionController = TextEditingController();
|
||||
bool _isProcessing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.scaffoldBg,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
|
||||
border: Border.all(
|
||||
color: AppTheme.egyptianBlue.withOpacity(0.1),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(24, 12, 24, MediaQuery.of(context).viewInsets.bottom + 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Handle
|
||||
Container(
|
||||
width: 40,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.egyptianBlue.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildContent(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
if (_isProcessing) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 40),
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
switch (_step) {
|
||||
case 0:
|
||||
return _buildOptions();
|
||||
case 1:
|
||||
return _buildReportTypes();
|
||||
case 2:
|
||||
return _buildReportDescription();
|
||||
case 3:
|
||||
return _buildBlockConfirmation();
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildOptions() {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
"The Sanctuary",
|
||||
style: AppTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Protect the harmony of your Circle.",
|
||||
style: AppTheme.labelSmall.copyWith(
|
||||
color: AppTheme.navyText.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildActionTile(
|
||||
icon: Icons.flag_outlined,
|
||||
title: "Report Violation",
|
||||
subtitle: "Harassment, Scam, or Misinformation detected",
|
||||
onTap: () => setState(() => _step = 1),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildActionTile(
|
||||
icon: Icons.block_flipped,
|
||||
title: "Exclude User",
|
||||
subtitle: "Stop all interactions structurally",
|
||||
color: Colors.redAccent.withOpacity(0.8),
|
||||
onTap: () => setState(() => _step = 3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportTypes() {
|
||||
final types = [
|
||||
{'id': 'harassment', 'label': 'Harassment', 'desc': 'Hostility or aggression'},
|
||||
{'id': 'scam', 'label': 'Scam / Fraud', 'desc': 'Fraudulent or manipulative content'},
|
||||
{'id': 'misinformation', 'label': 'Misinformation', 'desc': 'False or harmful ignorance'},
|
||||
];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
"Natures of Violation",
|
||||
style: AppTheme.headlineSmall.copyWith(fontSize: 20),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
...types.map((t) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildActionTile(
|
||||
title: t['label']!,
|
||||
subtitle: t['desc']!,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_violationType = t['id'];
|
||||
_step = 2;
|
||||
});
|
||||
},
|
||||
),
|
||||
)),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _step = 0),
|
||||
child: Text("Back", style: TextStyle(color: AppTheme.egyptianBlue)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportDescription() {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
"Detail the Disturbance",
|
||||
style: AppTheme.headlineSmall.copyWith(fontSize: 20),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
maxLines: 4,
|
||||
style: TextStyle(color: AppTheme.navyText),
|
||||
decoration: InputDecoration(
|
||||
hintText: "Briefly describe the violation...",
|
||||
hintStyle: TextStyle(color: AppTheme.navyText.withOpacity(0.4)),
|
||||
filled: true,
|
||||
fillColor: AppTheme.egyptianBlue.withOpacity(0.05),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
||||
),
|
||||
onPressed: _submitReport,
|
||||
child: const Text("Submit Report", style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _step = 1),
|
||||
child: Text("Back", style: TextStyle(color: AppTheme.egyptianBlue)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBlockConfirmation() {
|
||||
return Column(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: Colors.redAccent, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"Exclude from Circle?",
|
||||
style: AppTheme.headlineSmall.copyWith(fontSize: 22, color: AppTheme.error),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
"This will structurally separate you and @${widget.post.author?.handle ?? 'this user'}. You will both be invisible to each other across Sojorn.",
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTheme.bodyMedium.copyWith(color: AppTheme.navyText.withOpacity(0.7)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.redAccent,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
||||
),
|
||||
onPressed: _confirmBlock,
|
||||
child: const Text("Yes, Exclude structurally", style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text("Cancel", style: TextStyle(color: AppTheme.egyptianBlue)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionTile({
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
IconData? icon,
|
||||
Color? color,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: (color ?? AppTheme.navyText).withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(color: (color ?? AppTheme.navyText).withOpacity(0.1)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, color: color ?? AppTheme.navyText.withOpacity(0.7), size: 28),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: AppTheme.labelLarge.copyWith(color: color ?? AppTheme.navyText)),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle, style: AppTheme.labelSmall.copyWith(color: (color ?? AppTheme.navyText).withOpacity(0.6))),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: AppTheme.egyptianBlue.withOpacity(0.3)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submitReport() async {
|
||||
setState(() => _isProcessing = true);
|
||||
try {
|
||||
await ApiService.instance.callGoApi(
|
||||
'/users/report',
|
||||
method: 'POST',
|
||||
body: {
|
||||
'target_user_id': widget.post.authorId,
|
||||
'post_id': widget.post.id,
|
||||
'violation_type': _violationType,
|
||||
'description': _descriptionController.text,
|
||||
},
|
||||
);
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Report submitted. Thank you for maintaining harmony.")),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _isProcessing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmBlock() async {
|
||||
setState(() => _isProcessing = true);
|
||||
try {
|
||||
await ApiService.instance.callGoApi(
|
||||
'/users/${widget.post.authorId}/block',
|
||||
method: 'POST',
|
||||
);
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Structural exclusion complete.")),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _isProcessing = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import 'package:flutter_markdown/flutter_markdown.dart';
|
|||
import 'package:markdown/markdown.dart' as md;
|
||||
import '../../utils/external_link_controller.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../screens/search/search_screen.dart';
|
||||
import '../../screens/discover/discover_screen.dart';
|
||||
|
||||
/// Simple widget to limit max lines for any child
|
||||
class LimitedMaxLinesBox extends StatelessWidget {
|
||||
|
|
@ -162,7 +162,7 @@ class _MarkdownBodyContent extends StatelessWidget {
|
|||
final tag = href.replaceFirst('hashtag://', '');
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SearchScreen(initialQuery: '#$tag'),
|
||||
builder: (_) => DiscoverScreen(initialQuery: '#$tag'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
|
@ -194,7 +194,7 @@ class HashtagSyntax extends md.InlineSyntax {
|
|||
}
|
||||
}
|
||||
|
||||
/// Renders hashtags as clickable text that routes to SearchScreen.
|
||||
/// Renders hashtags as clickable text that routes to DiscoverScreen.
|
||||
class HashtagBuilder extends MarkdownElementBuilder {
|
||||
@override
|
||||
Widget? visitElementAfterWithContext(
|
||||
|
|
@ -218,7 +218,7 @@ class HashtagBuilder extends MarkdownElementBuilder {
|
|||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SearchScreen(initialQuery: displayText),
|
||||
builder: (_) => DiscoverScreen(initialQuery: displayText),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
|||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||
minimumSize: const Size(44, 44),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
|
|
@ -255,6 +256,7 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
|||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||
minimumSize: const Size(44, 44),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
|
|
@ -263,10 +265,8 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
|||
],
|
||||
),
|
||||
|
||||
// Right side: Reply and Reactions
|
||||
Row(
|
||||
children: [
|
||||
// Single Authority: ReactionsDisplay in compact mode for the actions row
|
||||
ReactionsDisplay(
|
||||
reactionCounts: _reactionCounts,
|
||||
myReactions: _myReactions,
|
||||
|
|
@ -283,7 +283,8 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
|||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.brightNavy,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
||||
minimumSize: const Size(0, 44),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
import '../../models/post.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../models/post.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'media/signed_media_image.dart';
|
||||
import 'video_thumbnail_widget.dart';
|
||||
import 'post/post_actions.dart';
|
||||
|
|
@ -107,6 +107,7 @@ class PostWithVideoWidget extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
|
|
|
|||
|
|
@ -125,15 +125,6 @@ class _RadialMenuOverlayState extends State<RadialMenuOverlay>
|
|||
},
|
||||
angle: startAngle,
|
||||
),
|
||||
_MenuItem(
|
||||
icon: Icons.videocam_outlined,
|
||||
label: 'Quip',
|
||||
onTap: () {
|
||||
widget.onDismiss();
|
||||
widget.onQuipTap();
|
||||
},
|
||||
angle: (startAngle + endAngle) / 2, // Middle (top)
|
||||
),
|
||||
_MenuItem(
|
||||
icon: Icons.location_on_outlined,
|
||||
label: 'Beacon',
|
||||
|
|
@ -141,6 +132,15 @@ class _RadialMenuOverlayState extends State<RadialMenuOverlay>
|
|||
widget.onDismiss();
|
||||
widget.onBeaconTap();
|
||||
},
|
||||
angle: (startAngle + endAngle) / 2, // Middle (top)
|
||||
),
|
||||
_MenuItem(
|
||||
icon: Icons.videocam_outlined,
|
||||
label: 'Quip',
|
||||
onTap: () {
|
||||
widget.onDismiss();
|
||||
widget.onQuipTap();
|
||||
},
|
||||
angle: endAngle,
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -51,10 +51,19 @@ class ReactionsDisplay extends StatelessWidget {
|
|||
}
|
||||
|
||||
Widget _buildCompactView() {
|
||||
if (reactionCounts.isEmpty) {
|
||||
return _ReactionAddButton(onTap: onAddReaction ?? () {});
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (onAddReaction != null) ...[
|
||||
_ReactionAddButton(onTap: onAddReaction!),
|
||||
if (reactionCounts.isNotEmpty) const SizedBox(width: 8),
|
||||
],
|
||||
if (reactionCounts.isNotEmpty) _buildTopReactionChip(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopReactionChip() {
|
||||
// Priority: User's reaction > Top reaction
|
||||
String? displayEmoji;
|
||||
if (myReactions.isNotEmpty) {
|
||||
|
|
@ -71,6 +80,7 @@ class ReactionsDisplay extends StatelessWidget {
|
|||
isSelected: myReactions.contains(displayEmoji),
|
||||
tooltipNames: reactionUsers?[displayEmoji],
|
||||
onTap: () => onToggleReaction?.call(displayEmoji!),
|
||||
onLongPress: onAddReaction,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +102,7 @@ class ReactionsDisplay extends StatelessWidget {
|
|||
isSelected: myReactions.contains(entry.key),
|
||||
tooltipNames: reactionUsers?[entry.key],
|
||||
onTap: () => onToggleReaction?.call(entry.key),
|
||||
onLongPress: onAddReaction,
|
||||
);
|
||||
}),
|
||||
if (onAddReaction != null)
|
||||
|
|
@ -108,12 +119,14 @@ class _ReactionChip extends StatefulWidget {
|
|||
final bool isSelected;
|
||||
final List<String>? tooltipNames;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
const _ReactionChip({
|
||||
required this.reactionId,
|
||||
required this.count,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.onLongPress,
|
||||
this.tooltipNames,
|
||||
});
|
||||
|
||||
|
|
@ -136,9 +149,11 @@ class _ReactionChipState extends State<_ReactionChip> {
|
|||
|
||||
final chip = GestureDetector(
|
||||
onTap: _handleTap,
|
||||
onLongPress: widget.onLongPress,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isMyReaction
|
||||
? AppTheme.brightNavy.withValues(alpha: 0.15)
|
||||
|
|
@ -201,7 +216,8 @@ class _ReactionAddButton extends StatelessWidget {
|
|||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import 'post/post_menu.dart';
|
|||
import 'post/post_view_mode.dart';
|
||||
import 'chain_quote_widget.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
import 'modals/sanctuary_sheet.dart';
|
||||
import '../theme/sojorn_feed_palette.dart';
|
||||
|
||||
/// Unified Post Card - Single Source of Truth for post display.
|
||||
///
|
||||
|
|
@ -141,6 +143,22 @@ class sojornPostCard extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => SanctuarySheet.show(context, post),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.ksuPurple.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("!", style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppTheme.royalPurple.withOpacity(0.7),
|
||||
)),
|
||||
),
|
||||
),
|
||||
PostMenu(
|
||||
post: post,
|
||||
onPostDeleted: onPostChanged,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:flutter/gestures.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/link_handler.dart';
|
||||
import '../screens/search/search_screen.dart';
|
||||
import '../screens/discover/discover_screen.dart';
|
||||
|
||||
/// Rich text widget that automatically detects and styles URLs and mentions.
|
||||
///
|
||||
|
|
@ -100,7 +100,7 @@ class sojornRichText extends StatelessWidget {
|
|||
// Navigate to search with hashtag query
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SearchScreen(initialQuery: matchText),
|
||||
builder: (_) => DiscoverScreen(initialQuery: matchText),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../models/post.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../media/signed_media_image.dart';
|
||||
import '../models/post.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'media/signed_media_image.dart';
|
||||
|
||||
/// Widget for displaying video thumbnails on regular posts (Twitter-style)
|
||||
/// Clicking opens the Quips feed with the full video
|
||||
|
|
|
|||
221
sojorn_docs/features/fcm-implementation.md
Normal file
221
sojorn_docs/features/fcm-implementation.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# FCM (Firebase Cloud Messaging) Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the complete FCM push notification implementation for Sojorn, covering both the Go backend and Flutter client. The system supports Android, iOS, and Web platforms.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Flutter App │────▶│ Go Backend │────▶│ Firebase FCM │
|
||||
│ (Android/Web) │ │ Push Service │ │ Cloud Messaging │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
│ POST /notifications/device │
|
||||
│ (Register FCM Token) │
|
||||
│ │ │
|
||||
│ │ SendPush() │
|
||||
│ │──────────────────────▶│
|
||||
│ │ │
|
||||
│◀──────────────────────│◀──────────────────────│
|
||||
│ Push Notification │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Configuration
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
```env
|
||||
# Firebase Cloud Messaging (FCM)
|
||||
FIREBASE_CREDENTIALS_FILE=/opt/sojorn/firebase-service-account.json
|
||||
FIREBASE_WEB_VAPID_KEY=BNxS7_your_actual_vapid_key_here
|
||||
```
|
||||
|
||||
### Firebase Service Account JSON
|
||||
|
||||
Download from Firebase Console > Project Settings > Service Accounts > Generate New Private Key
|
||||
|
||||
The JSON file should contain:
|
||||
- `project_id`
|
||||
- `private_key`
|
||||
- `client_email`
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### `user_fcm_tokens` Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS user_fcm_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL,
|
||||
device_type TEXT, -- 'web', 'android', 'ios', 'desktop'
|
||||
last_updated TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, token) -- Prevents duplicate tokens per device
|
||||
);
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- Composite unique constraint on `(user_id, token)` prevents duplicate registrations
|
||||
- `device_type` column supports platform-specific logic
|
||||
- `ON DELETE CASCADE` ensures tokens are cleaned up when user is deleted
|
||||
|
||||
---
|
||||
|
||||
## Push Notification Triggers
|
||||
|
||||
| Event | Handler | Recipient | Data Payload |
|
||||
|-------|---------|-----------|--------------|
|
||||
| New Follower | `user_handler.go:Follow` | Followed user | `type`, `follower_id` |
|
||||
| Follow Request | `user_handler.go:Follow` | Target user | `type`, `follower_id` |
|
||||
| Follow Accepted | `user_handler.go:AcceptFollowRequest` | Requester | `type`, `follower_id` |
|
||||
| New Chat Message | `chat_handler.go:SendMessage` | Receiver | `type`, `conversation_id`, `encrypted` |
|
||||
| Comment on Post | `post_handler.go:CreateComment` | Post author | `type`, `post_id`, `post_type`, `target` |
|
||||
| Post Saved | `post_handler.go:SavePost` | Post author | `type`, `post_id`, `post_type`, `target` |
|
||||
| Beacon Vouched | `post_handler.go:VouchBeacon` | Beacon author | `type`, `beacon_id`, `target` |
|
||||
| Beacon Reported | `post_handler.go:ReportBeacon` | Beacon author | `type`, `beacon_id`, `target` |
|
||||
|
||||
---
|
||||
|
||||
## Data Payload Structure
|
||||
|
||||
All push notifications include a `data` payload for deep linking:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "comment|save|reply|chat|new_follower|follow_request|beacon_vouch",
|
||||
"post_id": "uuid", // For post-related notifications
|
||||
"conversation_id": "uuid", // For chat notifications
|
||||
"follower_id": "uuid", // For follow notifications
|
||||
"beacon_id": "uuid", // For beacon notifications
|
||||
"target": "main_feed|quip_feed|beacon_map|secure_chat|profile|thread_view"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flutter Client Implementation
|
||||
|
||||
### Initialization Flow
|
||||
|
||||
1. **Android 13+ Permission Check**: Explicitly request `POST_NOTIFICATIONS` permission
|
||||
2. **Firebase Permission Request**: Request FCM permissions via SDK
|
||||
3. **Token Retrieval**: Get FCM token (with VAPID key for web)
|
||||
4. **Backend Registration**: POST token to `/notifications/device`
|
||||
5. **Set up Listeners**: Handle token refresh and message callbacks
|
||||
|
||||
### Deep Linking (Message Open Handling)
|
||||
|
||||
The Flutter client handles these notification types:
|
||||
|
||||
| Type | Navigation Target |
|
||||
|------|-------------------|
|
||||
| `chat`, `new_message` | SecureChatScreen with conversation |
|
||||
| `save`, `comment`, `reply` | Based on `target` field (home/quips/beacon) |
|
||||
| `new_follower`, `follow_request` | Profile screen of follower |
|
||||
| `beacon_vouch`, `beacon_report` | Beacon map |
|
||||
|
||||
### Logout Flow
|
||||
|
||||
On logout, the client:
|
||||
1. Calls `DELETE /notifications/device` with the current token
|
||||
2. Deletes the token from Firebase locally
|
||||
3. Resets initialization state
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Register Device Token
|
||||
|
||||
```http
|
||||
POST /api/v1/notifications/device
|
||||
Authorization: Bearer <JWT>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"fcm_token": "device_token_here",
|
||||
"platform": "android|ios|web|desktop"
|
||||
}
|
||||
```
|
||||
|
||||
### Unregister Device Token
|
||||
|
||||
```http
|
||||
DELETE /api/v1/notifications/device
|
||||
Authorization: Bearer <JWT>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"fcm_token": "device_token_here"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Token Not Registering
|
||||
|
||||
1. Check Firebase is initialized properly
|
||||
2. Verify `FIREBASE_CREDENTIALS_FILE` path is correct
|
||||
3. For web, ensure `FIREBASE_WEB_VAPID_KEY` is set
|
||||
4. Check network connectivity to Go backend
|
||||
|
||||
### Notifications Not Arriving
|
||||
|
||||
1. Verify token exists in `user_fcm_tokens` table
|
||||
2. Check Firebase Console for delivery reports
|
||||
3. Ensure app hasn't restricted background data
|
||||
4. On Android 13+, verify POST_NOTIFICATIONS permission
|
||||
|
||||
### Invalid Token Cleanup
|
||||
|
||||
The `PushService` automatically removes invalid tokens when FCM returns `messaging.IsRegistrationTokenNotRegistered` error.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Token Verification
|
||||
|
||||
```sql
|
||||
SELECT user_id, token, device_type, last_updated
|
||||
FROM user_fcm_tokens
|
||||
WHERE user_id = 'your-user-uuid';
|
||||
```
|
||||
|
||||
### Send Test Notification
|
||||
|
||||
Use Firebase Console > Cloud Messaging > Send test message with the device token.
|
||||
|
||||
---
|
||||
|
||||
## Platform-Specific Notes
|
||||
|
||||
### Android
|
||||
|
||||
- Target SDK 33+ requires `POST_NOTIFICATIONS` runtime permission
|
||||
- Add to `AndroidManifest.xml`:
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
```
|
||||
|
||||
### Web
|
||||
|
||||
- Requires valid VAPID key from Firebase Console
|
||||
- Service worker must be properly configured
|
||||
- HTTPS required (except localhost)
|
||||
|
||||
### iOS
|
||||
|
||||
- Requires APNs configuration in Firebase
|
||||
- Provisioning profile must include push notification capability
|
||||
|
|
@ -1,33 +1,111 @@
|
|||
# Notifications Troubleshooting and Fix
|
||||
# Notifications Troubleshooting (Go Backend)
|
||||
|
||||
## Symptoms
|
||||
- Notifications screen fails to load and logs show a `GET | 401` response from
|
||||
`supabase/functions/v1/notifications`.
|
||||
- Edge function logs show `Unauthorized` even though the client is signed in.
|
||||
> **Note**: This document has been updated for the 100% Go backend migration. Legacy Supabase edge function references are no longer applicable.
|
||||
|
||||
## Root Cause
|
||||
The notifications edge function relied on `supabaseClient.auth.getUser()` without
|
||||
explicitly passing the bearer token from the request. In some cases, the global
|
||||
headers were not applied as expected, so `getUser()` could not resolve the user
|
||||
and returned 401.
|
||||
## Current Architecture
|
||||
|
||||
## Fix
|
||||
Explicitly read the `Authorization` header and pass the token to
|
||||
`supabaseClient.auth.getUser(token)`. This ensures the function authenticates the
|
||||
user consistently even if the SDK does not automatically inject the header.
|
||||
All notification APIs now use the Go backend with JWT authentication:
|
||||
- **Register Token**: `POST /api/v1/notifications/device`
|
||||
- **Unregister Token**: `DELETE /api/v1/notifications/device`
|
||||
- **Get Notifications**: `GET /api/v1/notifications`
|
||||
|
||||
## Code Change
|
||||
File: `supabase/functions/notifications/index.ts`
|
||||
Authentication uses `Authorization: Bearer <token>` header with Go-issued JWTs.
|
||||
|
||||
Key update:
|
||||
- Parse `Authorization` header.
|
||||
- Extract bearer token.
|
||||
- Call `getUser(token)` instead of `getUser()` without arguments.
|
||||
---
|
||||
|
||||
## Deployment Step
|
||||
Redeploy the `notifications` edge function so the new auth flow is used.
|
||||
## Common Issues
|
||||
|
||||
## Verification
|
||||
- Open the notifications screen.
|
||||
- Confirm the request returns 200 and notifications render.
|
||||
- If it still fails, check edge function logs for missing or empty auth headers.
|
||||
### 1. Token Not Syncing to Backend
|
||||
|
||||
**Symptoms:**
|
||||
- FCM token obtained successfully but not stored in database
|
||||
- Debug logs show `[FCM] Token synced with Go Backend successfully` not appearing
|
||||
|
||||
**Solutions:**
|
||||
1. Verify user is authenticated before calling `NotificationService.init()`
|
||||
2. Check API endpoint responds (network tab / logs)
|
||||
3. Ensure JWT token is valid and not expired
|
||||
|
||||
### 2. Push Notifications Not Received
|
||||
|
||||
**Symptoms:**
|
||||
- Token exists in database
|
||||
- No push received on device
|
||||
|
||||
**Diagnosis:**
|
||||
```sql
|
||||
-- Check if token exists for user
|
||||
SELECT * FROM user_fcm_tokens WHERE user_id = 'your-uuid';
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
1. Verify `FIREBASE_CREDENTIALS_FILE` path is correct in backend `.env`
|
||||
2. Check Firebase Console for delivery reports
|
||||
3. For web, verify `FIREBASE_WEB_VAPID_KEY` matches Firebase Console
|
||||
4. On Android 13+, check `POST_NOTIFICATIONS` permission granted
|
||||
|
||||
### 3. Android 13+ Permission Denied
|
||||
|
||||
**Symptoms:**
|
||||
- `[FCM] Android notification permission not granted: denied`
|
||||
|
||||
**Solution:**
|
||||
The app now properly requests `POST_NOTIFICATIONS` at runtime. If user denied:
|
||||
1. Guide them to Settings > Apps > Sojorn > Notifications
|
||||
2. Enable notifications manually
|
||||
|
||||
### 4. Web Push Not Working
|
||||
|
||||
**Symptoms:**
|
||||
- Token is null on web platform
|
||||
- `[FCM] Web push is missing FIREBASE_WEB_VAPID_KEY`
|
||||
|
||||
**Solutions:**
|
||||
1. Verify VAPID key in `lib/config/firebase_web_config.dart`
|
||||
2. Key must match Firebase Console > Cloud Messaging > Web Push certificates
|
||||
3. Must be served over HTTPS (except localhost)
|
||||
|
||||
### 5. Duplicate Tokens in Database
|
||||
|
||||
The schema now has a unique constraint on `(user_id, token)`:
|
||||
```sql
|
||||
UNIQUE(user_id, token)
|
||||
```
|
||||
|
||||
This prevents duplicates via upsert logic:
|
||||
```sql
|
||||
ON CONFLICT (user_id, token) DO UPDATE SET last_updated = ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logout Cleanup
|
||||
|
||||
On logout, the Flutter client now:
|
||||
1. Calls backend to delete token from `user_fcm_tokens`
|
||||
2. Deletes token from Firebase locally via `deleteToken()`
|
||||
3. Resets initialization state
|
||||
|
||||
This ensures the device no longer receives notifications for the logged-out user.
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Token registration works on Android
|
||||
- [ ] Token registration works on Web
|
||||
- [ ] Token appears in `user_fcm_tokens` table with correct `device_type`
|
||||
- [ ] New message triggers push to recipient
|
||||
- [ ] Post save triggers push to author
|
||||
- [ ] Comment triggers push to post author
|
||||
- [ ] Follow triggers push to followed user
|
||||
- [ ] Tapping notification navigates correctly
|
||||
- [ ] Logout removes token from database
|
||||
- [ ] Re-login registers new token
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [FCM Implementation Guide](./fcm-implementation.md) - Complete implementation details
|
||||
- [Backend API Documentation](../api/) - All API endpoints
|
||||
|
|
|
|||
Loading…
Reference in a new issue