sojorn/go-backend/internal/repository/user_repository.go

1496 lines
51 KiB
Go

package repository
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/patbritton/sojorn-backend/internal/models"
)
type UserRepository struct {
pool *pgxpool.Pool
}
func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
return &UserRepository{pool: pool}
}
func (r *UserRepository) Pool() *pgxpool.Pool {
return r.pool
}
func (r *UserRepository) CreateProfile(ctx context.Context, profile *models.Profile) error {
query := `
INSERT INTO public.profiles (id, handle, display_name, bio, origin_country, birth_month, birth_year)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err := r.pool.Exec(ctx, query, profile.ID, profile.Handle, profile.DisplayName, profile.Bio, profile.OriginCountry, profile.BirthMonth, profile.BirthYear)
if err != nil {
return fmt.Errorf("failed to create profile: %w", err)
}
// Initialize trust state (mimicking the trigger if we want to do it in Go)
trustQuery := `
INSERT INTO public.trust_state (user_id, harmony_score, tier)
VALUES ($1, 50, 'new')
`
_, err = r.pool.Exec(ctx, trustQuery, profile.ID)
if err != nil {
return fmt.Errorf("failed to initialize trust state: %w", err)
}
return nil
}
func (r *UserRepository) GetProfileByID(ctx context.Context, id string) (*models.Profile, error) {
query := `SELECT id, handle, display_name, bio, avatar_url, origin_country, has_completed_onboarding, is_official, is_private, role, created_at, COALESCE(birth_month, 0), COALESCE(birth_year, 0) FROM public.profiles WHERE id = $1::uuid`
var p models.Profile
err := r.pool.QueryRow(ctx, query, id).Scan(
&p.ID, &p.Handle, &p.DisplayName, &p.Bio, &p.AvatarURL, &p.OriginCountry, &p.HasCompletedOnboarding, &p.IsOfficial, &p.IsPrivate, &p.Role, &p.CreatedAt, &p.BirthMonth, &p.BirthYear,
)
if err != nil {
return nil, err
}
return &p, nil
}
func (r *UserRepository) GetProfileByHandle(ctx context.Context, handle string) (*models.Profile, error) {
query := `
SELECT id, handle, display_name, bio, avatar_url, origin_country,
has_completed_onboarding, is_official, is_private, role, created_at,
COALESCE(birth_month, 0), COALESCE(birth_year, 0)
FROM public.profiles
WHERE handle = $1
`
var p models.Profile
err := r.pool.QueryRow(ctx, query, handle).Scan(
&p.ID, &p.Handle, &p.DisplayName, &p.Bio, &p.AvatarURL, &p.OriginCountry,
&p.HasCompletedOnboarding, &p.IsOfficial, &p.IsPrivate, &p.Role, &p.CreatedAt,
&p.BirthMonth, &p.BirthYear,
)
if err != nil {
return nil, err
}
return &p, nil
}
func (r *UserRepository) UpdateProfile(ctx context.Context, profile *models.Profile) error {
query := `
UPDATE public.profiles SET
handle = COALESCE($1, handle),
display_name = COALESCE($2, display_name),
bio = COALESCE($3, bio),
avatar_url = COALESCE($4, avatar_url),
cover_url = COALESCE($5, cover_url),
location = COALESCE($6, location),
website = COALESCE($7, website),
interests = COALESCE($8, interests),
identity_key = COALESCE($9, identity_key),
registration_id = COALESCE($10, registration_id),
encrypted_private_key = COALESCE($11, encrypted_private_key),
is_private = COALESCE($12, is_private),
is_official = COALESCE($13, is_official),
updated_at = NOW()
WHERE id = $14::uuid
`
_, err := r.pool.Exec(ctx, query,
profile.Handle, profile.DisplayName, profile.Bio, profile.AvatarURL,
profile.CoverURL, profile.Location, profile.Website, profile.Interests,
profile.IdentityKey, profile.RegistrationID, profile.EncryptedPrivateKey,
profile.IsPrivate, profile.IsOfficial,
profile.ID,
)
return err
}
func (r *UserRepository) MarkOnboardingComplete(ctx context.Context, userID string) error {
query := `UPDATE public.profiles SET has_completed_onboarding = TRUE, updated_at = NOW() WHERE id = $1::uuid`
_, err := r.pool.Exec(ctx, query, userID)
return err
}
func (r *UserRepository) SearchUsers(ctx context.Context, query string, viewerID string, limit int) ([]models.Profile, error) {
// The % operator uses pg_trgm for fuzzy matching
sql := `
SELECT
p.id, p.handle, p.display_name, p.bio, p.avatar_url, p.origin_country, p.has_completed_onboarding, p.created_at
FROM public.profiles p
LEFT JOIN public.trust_state t ON p.id = t.user_id
WHERE (
p.handle % $1 OR p.handle ILIKE '%' || $1 || '%'
OR p.display_name % $1 OR p.display_name ILIKE '%' || $1 || '%'
)
AND (
p.is_private = FALSE
OR ($2 != '' AND EXISTS (
SELECT 1 FROM public.follows f
WHERE f.follower_id = $2::uuid AND f.following_id = p.id AND f.status = 'accepted'
))
OR ($2 != '' AND p.id = $2::uuid)
)
AND NOT public.has_block_between(p.id, CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END)
ORDER BY
(similarity(p.handle, $1) + CASE WHEN p.handle ILIKE $1 || '%' THEN 0.5 ELSE 0 END + CASE WHEN COALESCE(t.harmony_score, 0) > 80 THEN 0.3 ELSE 0 END) DESC,
p.created_at DESC
LIMIT $3
`
rows, err := r.pool.Query(ctx, sql, query, viewerID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var profiles []models.Profile
for rows.Next() {
var p models.Profile
err := rows.Scan(
&p.ID, &p.Handle, &p.DisplayName, &p.Bio, &p.AvatarURL, &p.OriginCountry, &p.HasCompletedOnboarding, &p.CreatedAt,
)
if err != nil {
return nil, err
}
profiles = append(profiles, p)
}
return profiles, nil
}
func (r *UserRepository) GetTrustState(ctx context.Context, userID string) (*models.TrustState, error) {
query := `SELECT user_id, harmony_score, tier, posts_today FROM public.trust_state WHERE user_id = $1::uuid`
var ts models.TrustState
err := r.pool.QueryRow(ctx, query, userID).Scan(
&ts.UserID, &ts.HarmonyScore, &ts.Tier, &ts.PostsToday,
)
if err != nil {
return nil, err
}
return &ts, nil
}
func (r *UserRepository) CreateUser(ctx context.Context, user *models.User) error {
query := `
INSERT INTO public.users (id, email, encrypted_password, status, mfa_enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err := r.pool.Exec(ctx, query, user.ID, user.Email, user.PasswordHash, user.Status, user.MFAEnabled, user.CreatedAt, user.UpdatedAt)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
return nil
}
func (r *UserRepository) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
query := `SELECT id, email, encrypted_password, status, mfa_enabled, last_login, created_at FROM public.users WHERE email = $1`
var u models.User
err := r.pool.QueryRow(ctx, query, email).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Status, &u.MFAEnabled, &u.LastLogin, &u.CreatedAt)
if err != nil {
return nil, err
}
return &u, nil
}
func (r *UserRepository) GetUserByID(ctx context.Context, id string) (*models.User, error) {
query := `SELECT id, email, encrypted_password, status, mfa_enabled, last_login, created_at FROM public.users WHERE id = $1::uuid`
var u models.User
err := r.pool.QueryRow(ctx, query, id).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Status, &u.MFAEnabled, &u.LastLogin, &u.CreatedAt)
if err != nil {
return nil, err
}
return &u, nil
}
func (r *UserRepository) FollowUser(ctx context.Context, followerID, followingID string) (string, error) {
if followerID == followingID {
return "", fmt.Errorf("cannot follow self")
}
// 1. Check if follow record already exists
var currentStatus string
err := r.pool.QueryRow(ctx,
"SELECT status FROM public.follows WHERE follower_id = $1::uuid AND following_id = $2::uuid",
followerID, followingID).Scan(&currentStatus)
if err == nil {
return currentStatus, nil // Return existing status if found
}
// 2. Check target user's privacy settings
var isPrivate, isOfficial bool
err = r.pool.QueryRow(ctx,
"SELECT is_private, is_official FROM public.profiles WHERE id = $1::uuid",
followingID).Scan(&isPrivate, &isOfficial)
if err != nil {
// Fallback: If profile missing, assume public (or return error)
isPrivate = false
}
// 3. Determine status: Official/Public -> Accepted, Private -> Pending
newStatus := "pending"
if isOfficial || !isPrivate {
newStatus = "accepted"
}
// 4. Insert the follow
query := `
INSERT INTO public.follows (follower_id, following_id, status)
VALUES ($1::uuid, $2::uuid, $3)
ON CONFLICT (follower_id, following_id) DO UPDATE SET status = EXCLUDED.status
RETURNING status
`
var status string
err = r.pool.QueryRow(ctx, query, followerID, followingID, newStatus).Scan(&status)
if err != nil {
return "", fmt.Errorf("failed to follow user: %w", err)
}
return status, nil
}
func (r *UserRepository) AcceptFollowRequest(ctx context.Context, userID, requesterID string) error {
query := `
UPDATE public.follows
SET status = 'accepted'
WHERE following_id = $1::uuid AND follower_id = $2::uuid AND status = 'pending'
`
commandTag, err := r.pool.Exec(ctx, query, userID, requesterID)
if err != nil {
return err
}
if commandTag.RowsAffected() == 0 {
return fmt.Errorf("no pending request found")
}
return nil
}
func (r *UserRepository) GetPendingFollowRequests(ctx context.Context, userID string) ([]map[string]any, error) {
query := `
SELECT p.id as follower_id, p.handle, p.display_name, p.avatar_url, f.created_at as requested_at
FROM public.follows f
JOIN public.profiles p ON p.id = f.follower_id
WHERE f.following_id = $1::uuid AND f.status = 'pending'
ORDER BY f.created_at DESC
`
rows, err := r.pool.Query(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var requests []map[string]any
for rows.Next() {
var id, handle, displayName, avatarURL string
var requestedAt time.Time
if err := rows.Scan(&id, &handle, &displayName, &avatarURL, &requestedAt); err != nil {
return nil, err
}
requests = append(requests, map[string]any{
"follower_id": id,
"handle": handle,
"display_name": displayName,
"avatar_url": avatarURL,
"requested_at": requestedAt,
})
}
return requests, nil
}
func (r *UserRepository) UpdateHarmonyScore(ctx context.Context, userID string, delta int) error {
query := `
UPDATE public.trust_state
SET harmony_score = GREATEST(0, LEAST(harmony_score + $1, 100)),
updated_at = NOW(),
last_harmony_calc_at = NOW()
WHERE user_id = $2::uuid
`
_, err := r.pool.Exec(ctx, query, delta, userID)
return err
}
func (r *UserRepository) RejectFollowRequest(ctx context.Context, userID, requesterID string) error {
query := `DELETE FROM public.follows WHERE follower_id = $1::uuid AND following_id = $2::uuid AND status = 'pending'`
_, err := r.pool.Exec(ctx, query, requesterID, userID)
return err
}
func (r *UserRepository) UnfollowUser(ctx context.Context, followerID, followingID string) error {
query := `
DELETE FROM public.follows WHERE follower_id = $1::uuid AND following_id = $2::uuid
`
_, err := r.pool.Exec(ctx, query, followerID, followingID)
if err != nil {
return fmt.Errorf("failed to unfollow user: %w", err)
}
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"`
FollowingCount int `json:"following_count"`
}
func (r *UserRepository) GetProfileStats(ctx context.Context, userID string) (*ProfileStats, error) {
stats := &ProfileStats{}
query := `
SELECT
(
SELECT COUNT(*) FROM public.posts p
WHERE p.author_id = $1::uuid AND p.deleted_at IS NULL
) as post_count,
(
SELECT COUNT(*) FROM public.follows
WHERE following_id = $1::uuid AND status = 'accepted'
) as follower_count,
(
SELECT COUNT(*) FROM public.follows
WHERE follower_id = $1::uuid AND status = 'accepted'
) as following_count
`
err := r.pool.QueryRow(ctx, query, userID).Scan(
&stats.PostCount,
&stats.FollowerCount,
&stats.FollowingCount,
)
if err != nil {
return nil, err
}
return stats, nil
}
func (r *UserRepository) IsFollowing(ctx context.Context, followerID, followingID string) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM public.follows WHERE follower_id = $1::uuid AND following_id = $2::uuid AND status = 'accepted')`
err := r.pool.QueryRow(ctx, query, followerID, followingID).Scan(&exists)
if err != nil {
return false, err
}
return exists, nil
}
func (r *UserRepository) IsMutualFollow(ctx context.Context, userA, userB string) (bool, error) {
if userA == userB {
return true, nil
}
var exists bool
query := `
SELECT EXISTS (
SELECT 1 FROM public.follows
WHERE follower_id = $1::uuid AND following_id = $2::uuid AND status = 'accepted'
) AND EXISTS (
SELECT 1 FROM public.follows
WHERE follower_id = $2::uuid AND following_id = $1::uuid AND status = 'accepted'
)`
err := r.pool.QueryRow(ctx, query, userA, userB).Scan(&exists)
if err != nil {
return false, err
}
return exists, nil
}
func (r *UserRepository) GetFollowStatus(ctx context.Context, followerID, followingID string) (string, error) {
var status string
query := `SELECT status FROM public.follows WHERE follower_id = $1::uuid AND following_id = $2::uuid`
err := r.pool.QueryRow(ctx, query, followerID, followingID).Scan(&status)
if err != nil {
if err == pgx.ErrNoRows {
return "", nil
}
return "", err
}
return status, nil
}
type SignalKeysInput struct {
IdentityKeyPublic string
SignedPrekeyPublic string
SignedPrekeyID int
SignedPrekeySignature string
OneTimePrekeys []byte
RegistrationID int
}
func (r *UserRepository) UpsertKeys(ctx context.Context, userID string, keys SignalKeysInput) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// Step 1: Update identity and registration
_, err = tx.Exec(ctx, `
UPDATE public.profiles
SET identity_key = $1, registration_id = $2, updated_at = NOW()
WHERE id = $3::uuid
`, keys.IdentityKeyPublic, keys.RegistrationID, userID)
if err != nil {
return err
}
// Step 2: Extract and Upsert Signed Prekey
_, err = tx.Exec(ctx, `
INSERT INTO public.signed_prekeys (user_id, key_id, public_key, signature)
VALUES ($1::uuid, $2, $3, $4)
ON CONFLICT (user_id, key_id) DO UPDATE SET
public_key = EXCLUDED.public_key,
signature = EXCLUDED.signature,
created_at = NOW()
`, userID, keys.SignedPrekeyID, keys.SignedPrekeyPublic, keys.SignedPrekeySignature)
if err != nil {
return err
}
// Step 3: Clear and Bulk Insert One-Time Prekeys
_, err = tx.Exec(ctx, `DELETE FROM public.one_time_prekeys WHERE user_id = $1::uuid`, userID)
if err != nil {
return err
}
type OTKEntry struct {
KeyID int `json:"key_id"`
PublicKey string `json:"public_key"`
}
var otks []OTKEntry
if len(keys.OneTimePrekeys) > 0 {
if err := json.Unmarshal(keys.OneTimePrekeys, &otks); err != nil {
return fmt.Errorf("failed to unmarshal one_time_prekeys: %w", err)
}
for _, otk := range otks {
_, err = tx.Exec(ctx, `
INSERT INTO public.one_time_prekeys (user_id, key_id, public_key)
VALUES ($1::uuid, $2, $3)
`, userID, otk.KeyID, otk.PublicKey)
if err != nil {
return err
}
}
}
return tx.Commit(ctx)
}
// DeleteUsedOTK removes a one-time prekey after it's been used for encryption
func (r *UserRepository) DeleteUsedOTK(ctx context.Context, userID string, keyID int) error {
_, err := r.pool.Exec(ctx, `
DELETE FROM public.one_time_prekeys
WHERE user_id = $1::uuid AND key_id = $2
`, userID, keyID)
if err != nil {
return fmt.Errorf("failed to delete used OTK: %w", err)
}
// OTK deleted successfully
return nil
}
func (r *UserRepository) GetSignalKeyBundle(ctx context.Context, userID string) (map[string]interface{}, error) {
var ikPub, spkPub, spkSig string
var regID sql.NullInt64
var spkID sql.NullInt64
err := r.pool.QueryRow(ctx, `
SELECT
p.identity_key as identity_key_public,
p.registration_id,
sp.key_id as signed_prekey_id,
sp.public_key as signed_prekey_public,
sp.signature as signed_prekey_signature
FROM public.profiles p
LEFT JOIN public.signed_prekeys sp ON p.id = sp.user_id
WHERE p.id = $1::uuid
`, userID).Scan(
&ikPub,
&regID,
&spkID,
&spkPub,
&spkSig,
)
if err != nil {
return nil, fmt.Errorf("failed to get signal key bundle: %w", err)
}
// Get available OTKs for this user
rows, err := r.pool.Query(ctx, `
SELECT key_id, public_key
FROM public.one_time_prekeys
WHERE user_id = $1::uuid
ORDER BY created_at ASC
LIMIT 1
`, userID)
if err != nil {
return nil, fmt.Errorf("failed to get OTKs: %w", err)
}
defer rows.Close()
var otkID int
var otkPub string
var otk map[string]interface{}
if rows.Next() {
if err := rows.Scan(&otkID, &otkPub); err != nil {
return nil, fmt.Errorf("failed to scan OTK: %w", err)
}
otk = map[string]interface{}{
"key_id": otkID,
"public_key": otkPub,
}
// OTK retrieved - not logging user ID for security
}
// Handle NULL values properly
var regIDValue int
if regID.Valid {
regIDValue = int(regID.Int64)
} else {
regIDValue = 1 // Default value
}
var spkIDValue int
if spkID.Valid {
spkIDValue = int(spkID.Int64)
} else {
spkIDValue = 1 // Default value
}
bundle := map[string]interface{}{
"identity_key": map[string]interface{}{
"key_id": 1,
"public_key": ikPub,
},
"registration_id": regIDValue,
"signed_prekey": map[string]interface{}{
"key_id": spkIDValue,
"public_key": spkPub,
"signature": spkSig,
},
}
// Add OTK if available
if otk != nil {
bundle["one_time_prekey"] = otk
}
return bundle, nil
}
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,
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.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,
DefaultPostVisibility: &pub,
IsPrivateProfile: &f,
UpdatedAt: time.Now(),
}, nil
}
return nil, err
}
return &ps, nil
}
func (r *UserRepository) UpdatePrivacySettings(ctx context.Context, ps *models.PrivacySettings) error {
query := `
INSERT INTO public.profile_privacy_settings (
user_id, show_location, show_interests, profile_visibility,
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),
profile_visibility = COALESCE(EXCLUDED.profile_visibility, profile_privacy_settings.profile_visibility),
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
}
func (r *UserRepository) GetUserSettings(ctx context.Context, userID string) (*models.UserSettings, error) {
query := `
SELECT user_id, theme, language, notifications_enabled, email_notifications,
push_notifications, content_filter_level, auto_play_videos, data_saver_mode,
default_post_ttl, COALESCE(nsfw_enabled, FALSE), COALESCE(nsfw_blur_enabled, TRUE), updated_at
FROM public.user_settings
WHERE user_id = $1::uuid
`
var us models.UserSettings
err := r.pool.QueryRow(ctx, query, userID).Scan(
&us.UserID, &us.Theme, &us.Language, &us.NotificationsEnabled, &us.EmailNotifications,
&us.PushNotifications, &us.ContentFilterLevel, &us.AutoPlayVideos, &us.DataSaverMode,
&us.DefaultPostTtl, &us.NSFWEnabled, &us.NSFWBlurEnabled, &us.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)
sys := "system"
en := "en"
med := "medium"
t := true
f := false
return &models.UserSettings{
UserID: uid,
Theme: &sys,
Language: &en,
NotificationsEnabled: &t,
EmailNotifications: &t,
PushNotifications: &t,
ContentFilterLevel: &med,
AutoPlayVideos: &t,
DataSaverMode: &f,
NSFWEnabled: &f,
NSFWBlurEnabled: &t,
UpdatedAt: time.Now(),
}, nil
}
return nil, err
}
return &us, nil
}
func (r *UserRepository) UpdateUserSettings(ctx context.Context, us *models.UserSettings) error {
query := `
INSERT INTO public.user_settings (
user_id, theme, language, notifications_enabled, email_notifications,
push_notifications, content_filter_level, auto_play_videos, data_saver_mode,
default_post_ttl, nsfw_enabled, nsfw_blur_enabled, updated_at
) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW())
ON CONFLICT (user_id) DO UPDATE SET
theme = COALESCE(EXCLUDED.theme, user_settings.theme),
language = COALESCE(EXCLUDED.language, user_settings.language),
notifications_enabled = COALESCE(EXCLUDED.notifications_enabled, user_settings.notifications_enabled),
email_notifications = COALESCE(EXCLUDED.email_notifications, user_settings.email_notifications),
push_notifications = COALESCE(EXCLUDED.push_notifications, user_settings.push_notifications),
content_filter_level = COALESCE(EXCLUDED.content_filter_level, user_settings.content_filter_level),
auto_play_videos = COALESCE(EXCLUDED.auto_play_videos, user_settings.auto_play_videos),
data_saver_mode = COALESCE(EXCLUDED.data_saver_mode, user_settings.data_saver_mode),
default_post_ttl = COALESCE(EXCLUDED.default_post_ttl, user_settings.default_post_ttl),
nsfw_enabled = COALESCE(EXCLUDED.nsfw_enabled, user_settings.nsfw_enabled),
nsfw_blur_enabled = COALESCE(EXCLUDED.nsfw_blur_enabled, user_settings.nsfw_blur_enabled),
updated_at = NOW()
`
_, err := r.pool.Exec(ctx, query,
us.UserID, us.Theme, us.Language, us.NotificationsEnabled, us.EmailNotifications,
us.PushNotifications, us.ContentFilterLevel, us.AutoPlayVideos, us.DataSaverMode,
us.DefaultPostTtl, us.NSFWEnabled, us.NSFWBlurEnabled,
)
return err
}
func (r *UserRepository) UpsertFCMToken(ctx context.Context, userID string, token string, platform string) error {
query := `
INSERT INTO public.user_fcm_tokens (user_id, token, device_type, last_updated)
VALUES ($1::uuid, $2, $3, NOW())
ON CONFLICT (user_id, token) DO UPDATE SET
last_updated = NOW(),
device_type = EXCLUDED.device_type
`
_, err := r.pool.Exec(ctx, query, userID, token, platform)
return err
}
func (r *UserRepository) GetFCMTokens(ctx context.Context, userID string) ([]string, error) {
query := `SELECT token FROM public.user_fcm_tokens WHERE user_id = $1::uuid`
rows, err := r.pool.Query(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var tokens []string
for rows.Next() {
var token string
if err := rows.Scan(&token); err != nil {
return nil, err
}
tokens = append(tokens, token)
}
return tokens, nil
}
func (r *UserRepository) DeleteFCMToken(ctx context.Context, userID string, token string) error {
_, err := r.pool.Exec(ctx, `
DELETE FROM public.user_fcm_tokens
WHERE user_id = $1::uuid AND token = $2
`, userID, token)
return err
}
func (r *UserRepository) StoreRefreshToken(ctx context.Context, userID string, tokenString string, duration time.Duration) error {
hash := sha256.Sum256([]byte(tokenString))
hashString := hex.EncodeToString(hash[:])
query := `
INSERT INTO refresh_tokens (token_hash, user_id, expires_at)
VALUES ($1, $2::uuid, $3)
`
_, err := r.pool.Exec(ctx, query, hashString, userID, time.Now().Add(duration))
return err
}
func (r *UserRepository) ValidateRefreshToken(ctx context.Context, tokenString string) (*models.RefreshToken, error) {
hash := sha256.Sum256([]byte(tokenString))
hashString := hex.EncodeToString(hash[:])
var rt models.RefreshToken
query := `
SELECT token_hash, user_id, expires_at, revoked, created_at
FROM refresh_tokens
WHERE token_hash = $1
`
err := r.pool.QueryRow(ctx, query, hashString).Scan(
&rt.TokenHash, &rt.UserID, &rt.ExpiresAt, &rt.Revoked, &rt.CreatedAt,
)
if err != nil {
return nil, err
}
if rt.Revoked {
return nil, fmt.Errorf("token revoked")
}
if time.Now().After(rt.ExpiresAt) {
return nil, fmt.Errorf("token expired")
}
return &rt, nil
}
func (r *UserRepository) RevokeRefreshToken(ctx context.Context, tokenString string) error {
hash := sha256.Sum256([]byte(tokenString))
hashString := hex.EncodeToString(hash[:])
query := `UPDATE refresh_tokens SET revoked = true WHERE token_hash = $1`
_, err := r.pool.Exec(ctx, query, hashString)
return err
}
func (r *UserRepository) RevokeAllUserTokens(ctx context.Context, userID string) error {
query := `UPDATE refresh_tokens SET revoked = true WHERE user_id = $1::uuid`
_, err := r.pool.Exec(ctx, query, userID)
return err
}
func (r *UserRepository) CreateAuthToken(ctx context.Context, token *models.AuthToken) error {
query := `INSERT INTO public.auth_tokens (token, user_id, type, expires_at, created_at) VALUES ($1, $2, $3, $4, $5)`
_, err := r.pool.Exec(ctx, query, token.Token, token.UserID, token.Type, token.ExpiresAt, token.CreatedAt)
return err
}
func (r *UserRepository) GetAuthToken(ctx context.Context, tokenStr string) (*models.AuthToken, error) {
query := `SELECT token, user_id, type, expires_at, created_at FROM public.auth_tokens WHERE token = $1`
var t models.AuthToken
err := r.pool.QueryRow(ctx, query, tokenStr).Scan(&t.Token, &t.UserID, &t.Type, &t.ExpiresAt, &t.CreatedAt)
if err != nil {
return nil, err
}
return &t, nil
}
func (r *UserRepository) DeleteAuthToken(ctx context.Context, tokenStr string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM public.auth_tokens WHERE token = $1`, tokenStr)
return err
}
func (r *UserRepository) UpdateUserStatus(ctx context.Context, userID string, status models.UserStatus) error {
_, err := r.pool.Exec(ctx, `UPDATE public.users SET status = $1, updated_at = NOW() WHERE id = $2::uuid`, status, userID)
return err
}
func (r *UserRepository) UpdateLastLogin(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `UPDATE public.users SET last_login = NOW() WHERE id = $1::uuid`, userID)
return err
}
func (r *UserRepository) UpsertMFASecret(ctx context.Context, secret *models.MFASecret) error {
query := `
INSERT INTO public.user_mfa_secrets (user_id, secret_key, recovery_codes, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id) DO UPDATE SET
secret_key = EXCLUDED.secret_key,
recovery_codes = EXCLUDED.recovery_codes,
updated_at = NOW()
`
_, err := r.pool.Exec(ctx, query, secret.UserID, secret.Secret, secret.RecoveryCodes)
return err
}
func (r *UserRepository) GetMFASecret(ctx context.Context, userID string) (*models.MFASecret, error) {
query := `SELECT user_id, secret_key, recovery_codes FROM public.user_mfa_secrets WHERE user_id = $1::uuid`
var s models.MFASecret
err := r.pool.QueryRow(ctx, query, userID).Scan(&s.UserID, &s.Secret, &s.RecoveryCodes)
if err != nil {
return nil, err
}
return &s, nil
}
// Verification Tokens (New Table)
func (r *UserRepository) CreateVerificationToken(ctx context.Context, tokenHash string, userID string, duration time.Duration) error {
query := `INSERT INTO public.verification_tokens (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`
_, err := r.pool.Exec(ctx, query, tokenHash, userID, time.Now().Add(duration))
return err
}
func (r *UserRepository) GetVerificationToken(ctx context.Context, tokenHash string) (string, time.Time, error) {
query := `SELECT user_id, expires_at FROM public.verification_tokens WHERE token_hash = $1`
var userID string
var expiresAt time.Time
err := r.pool.QueryRow(ctx, query, tokenHash).Scan(&userID, &expiresAt)
if err != nil {
return "", time.Time{}, err
}
return userID, expiresAt, nil
}
func (r *UserRepository) DeleteVerificationToken(ctx context.Context, tokenHash string) error {
query := `DELETE FROM public.verification_tokens WHERE token_hash = $1`
_, err := r.pool.Exec(ctx, query, tokenHash)
return err
}
// Password Reset Tokens
func (r *UserRepository) CreatePasswordResetToken(ctx context.Context, tokenHash string, userID string, duration time.Duration) error {
query := `INSERT INTO public.password_reset_tokens (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`
_, err := r.pool.Exec(ctx, query, tokenHash, userID, time.Now().Add(duration))
return err
}
func (r *UserRepository) GetPasswordResetToken(ctx context.Context, tokenHash string) (string, time.Time, error) {
query := `SELECT user_id, expires_at FROM public.password_reset_tokens WHERE token_hash = $1`
var userID string
var expiresAt time.Time
err := r.pool.QueryRow(ctx, query, tokenHash).Scan(&userID, &expiresAt)
if err != nil {
return "", time.Time{}, err
}
return userID, expiresAt, nil
}
func (r *UserRepository) DeletePasswordResetToken(ctx context.Context, tokenHash string) error {
query := `DELETE FROM public.password_reset_tokens WHERE token_hash = $1`
_, err := r.pool.Exec(ctx, query, tokenHash)
return err
}
func (r *UserRepository) UpdateUserPassword(ctx context.Context, userID string, passwordHash string) error {
query := `UPDATE public.users SET encrypted_password = $1, updated_at = NOW() WHERE id = $2::uuid`
_, err := r.pool.Exec(ctx, query, passwordHash, userID)
return err
}
// WebAuthn Credentials (placeholder comment if more methods follow, or strictly implement the requested method)
func (r *UserRepository) FetchAndConsumeOneTimeKey(ctx context.Context, userID string) (*models.OneTimePrekey, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var otk models.OneTimePrekey
// Logic: Execute SELECT ... FROM one_time_prekeys WHERE user_id = $1 ORDER BY created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED
query := `
SELECT key_id, public_key
FROM public.one_time_prekeys
WHERE user_id = $1::uuid
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
err = tx.QueryRow(ctx, query, userID).Scan(&otk.KeyID, &otk.PublicKey)
if err != nil {
if err == pgx.ErrNoRows {
// If not found: Return nil (this is valid protocol behavior)
return nil, tx.Commit(ctx)
}
return nil, err
}
// If found: DELETE the row immediately and return the key data.
_, err = tx.Exec(ctx, `DELETE FROM public.one_time_prekeys WHERE user_id = $1::uuid AND key_id = $2`, userID, otk.KeyID)
if err != nil {
return nil, fmt.Errorf("failed to delete one-time key: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &otk, nil
}
func (r *UserRepository) CreateWebAuthnCredential(ctx context.Context, cred *models.WebAuthnCredential) error {
query := `
INSERT INTO public.webauthn_credentials (user_id, credential_id, public_key, attestation_type, aaguid, sign_count, last_used_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err := r.pool.Exec(ctx, query, cred.UserID, cred.ID, cred.PublicKey, cred.AttestationType, cred.AAGUID, cred.SignCount, cred.LastUsedAt)
return err
}
func (r *UserRepository) GetWebAuthnCredentials(ctx context.Context, userID string) ([]models.WebAuthnCredential, error) {
query := `SELECT id, user_id, credential_id, public_key, attestation_type, aaguid, sign_count, last_used_at FROM public.webauthn_credentials WHERE user_id = $1::uuid`
rows, err := r.pool.Query(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var creds []models.WebAuthnCredential
for rows.Next() {
var c models.WebAuthnCredential
var idStr string
err := rows.Scan(&idStr, &c.UserID, &c.ID, &c.PublicKey, &c.AttestationType, &c.AAGUID, &c.SignCount, &c.LastUsedAt)
if err != nil {
return nil, err
}
creds = append(creds, c)
}
return creds, nil
}
// DeleteUser removes a user by ID. SHOULD BE USED WITH CAUTION (e.g. rollback)
func (r *UserRepository) DeleteUser(ctx context.Context, userID uuid.UUID) error {
query := `DELETE FROM public.users WHERE id = $1`
_, 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)
}
// ========================================================================
// Social Graph: Followers & Following Lists
// ========================================================================
type FollowerUser struct {
ID uuid.UUID `json:"id"`
Handle string `json:"handle"`
DisplayName string `json:"display_name"`
AvatarURL *string `json:"avatar_url"`
HarmonyScore int `json:"harmony_score"`
Tier string `json:"tier"`
FollowedAt time.Time `json:"followed_at"`
}
// GetFollowers returns a list of users following the specified user
func (r *UserRepository) GetFollowers(ctx context.Context, userID string, limit, offset int) ([]FollowerUser, error) {
query := `
SELECT
p.id, p.handle, p.display_name, p.avatar_url,
COALESCE(t.harmony_score, 50) as harmony_score,
COALESCE(t.tier, 'new') as tier,
f.created_at as followed_at
FROM public.follows f
JOIN public.profiles p ON p.id = f.follower_id
LEFT JOIN public.trust_state t ON t.user_id = p.id
WHERE f.following_id = $1::uuid AND f.status = 'accepted'
ORDER BY f.created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var followers []FollowerUser
for rows.Next() {
var f FollowerUser
if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &f.HarmonyScore, &f.Tier, &f.FollowedAt); err != nil {
return nil, err
}
followers = append(followers, f)
}
return followers, nil
}
// GetFollowing returns a list of users the specified user is following
func (r *UserRepository) GetFollowing(ctx context.Context, userID string, limit, offset int) ([]FollowerUser, error) {
query := `
SELECT
p.id, p.handle, p.display_name, p.avatar_url,
COALESCE(t.harmony_score, 50) as harmony_score,
COALESCE(t.tier, 'new') as tier,
f.created_at as followed_at
FROM public.follows f
JOIN public.profiles p ON p.id = f.following_id
LEFT JOIN public.trust_state t ON t.user_id = p.id
WHERE f.follower_id = $1::uuid AND f.status = 'accepted'
ORDER BY f.created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var following []FollowerUser
for rows.Next() {
var f FollowerUser
if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &f.HarmonyScore, &f.Tier, &f.FollowedAt); err != nil {
return nil, err
}
following = append(following, f)
}
return following, nil
}
// ========================================================================
// Circle (Close Friends) Management
// ========================================================================
// AddToCircle adds a user to the current user's circle
func (r *UserRepository) AddToCircle(ctx context.Context, userID, memberID string) error {
// Verify that the user follows the member first
isFollowing, err := r.IsFollowing(ctx, userID, memberID)
if err != nil {
return err
}
if !isFollowing {
return fmt.Errorf("can only add users you follow to your circle")
}
query := `
INSERT INTO public.circle_members (user_id, member_id)
VALUES ($1::uuid, $2::uuid)
ON CONFLICT DO NOTHING
`
_, err = r.pool.Exec(ctx, query, userID, memberID)
return err
}
// RemoveFromCircle removes a user from the current user's circle
func (r *UserRepository) RemoveFromCircle(ctx context.Context, userID, memberID string) error {
query := `DELETE FROM public.circle_members WHERE user_id = $1::uuid AND member_id = $2::uuid`
_, err := r.pool.Exec(ctx, query, userID, memberID)
return err
}
// GetCircleMembers returns all users in the current user's circle
func (r *UserRepository) GetCircleMembers(ctx context.Context, userID string) ([]FollowerUser, error) {
query := `
SELECT
p.id, p.handle, p.display_name, p.avatar_url,
COALESCE(t.harmony_score, 50) as harmony_score,
COALESCE(t.tier, 'new') as tier,
c.created_at as followed_at
FROM public.circle_members c
JOIN public.profiles p ON p.id = c.member_id
LEFT JOIN public.trust_state t ON t.user_id = p.id
WHERE c.user_id = $1::uuid
ORDER BY c.created_at DESC
`
rows, err := r.pool.Query(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var members []FollowerUser
for rows.Next() {
var m FollowerUser
if err := rows.Scan(&m.ID, &m.Handle, &m.DisplayName, &m.AvatarURL, &m.HarmonyScore, &m.Tier, &m.FollowedAt); err != nil {
return nil, err
}
members = append(members, m)
}
return members, nil
}
// IsInCircle checks if a user is in another user's circle
func (r *UserRepository) IsInCircle(ctx context.Context, ownerID, userID string) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM public.circle_members WHERE user_id = $1::uuid AND member_id = $2::uuid)`
err := r.pool.QueryRow(ctx, query, ownerID, userID).Scan(&exists)
return exists, err
}
// ========================================================================
// Data Export (Portability)
// ========================================================================
type UserExportData struct {
Profile *models.Profile `json:"profile"`
Posts []ExportedPost `json:"posts"`
Following []ExportedFollow `json:"following"`
}
type ExportedPost struct {
ID uuid.UUID `json:"id"`
Body string `json:"body"`
ImageURL *string `json:"image_url,omitempty"`
VideoURL *string `json:"video_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ExportedFollow struct {
Handle string `json:"handle"`
DisplayName string `json:"display_name"`
FollowedAt time.Time `json:"followed_at"`
}
// ExportUserData generates complete user data export for portability
func (r *UserRepository) ExportUserData(ctx context.Context, userID string) (*UserExportData, error) {
export := &UserExportData{}
// 1. Get Profile
profile, err := r.GetProfileByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get profile: %w", err)
}
export.Profile = profile
// 2. Get All Posts
postQuery := `
SELECT id, body, image_url, video_url, created_at
FROM public.posts
WHERE author_id = $1::uuid AND deleted_at IS NULL
ORDER BY created_at DESC
`
rows, err := r.pool.Query(ctx, postQuery, userID)
if err != nil {
return nil, fmt.Errorf("failed to get posts: %w", err)
}
defer rows.Close()
for rows.Next() {
var p ExportedPost
if err := rows.Scan(&p.ID, &p.Body, &p.ImageURL, &p.VideoURL, &p.CreatedAt); err != nil {
return nil, err
}
export.Posts = append(export.Posts, p)
}
// 3. Get Following List
followQuery := `
SELECT p.handle, p.display_name, f.created_at
FROM public.follows f
JOIN public.profiles p ON p.id = f.following_id
WHERE f.follower_id = $1::uuid AND f.status = 'accepted'
ORDER BY f.created_at DESC
`
rows, err = r.pool.Query(ctx, followQuery, userID)
if err != nil {
return nil, fmt.Errorf("failed to get following list: %w", err)
}
defer rows.Close()
for rows.Next() {
var f ExportedFollow
if err := rows.Scan(&f.Handle, &f.DisplayName, &f.FollowedAt); err != nil {
return nil, err
}
export.Following = append(export.Following, f)
}
return export, nil
}
// BanIP records an IP address as banned (used when a user is banned to prevent evasion)
func (r *UserRepository) BanIP(ctx context.Context, ipAddress string, userID string, reason string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO banned_ips (ip_address, user_id, reason, banned_at)
VALUES ($1, $2::uuid, $3, NOW())
`, ipAddress, userID, reason)
return err
}
// IsIPBanned checks if an IP address has been banned
func (r *UserRepository) IsIPBanned(ctx context.Context, ipAddress string) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM banned_ips WHERE ip_address = $1)
`, ipAddress).Scan(&exists)
return exists, err
}
// ========================================================================
// Account Lifecycle: Deactivate, Delete, Destroy
// ========================================================================
// DeactivateUser sets user status to deactivated, preserves all data
func (r *UserRepository) DeactivateUser(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE public.users
SET status = 'deactivated', updated_at = NOW()
WHERE id = $1::uuid
`, userID)
return err
}
// ReactivateUser sets a deactivated user back to active
func (r *UserRepository) ReactivateUser(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE public.users
SET status = 'active', deleted_at = NULL, updated_at = NOW()
WHERE id = $1::uuid AND status IN ('deactivated', 'pending_deletion')
`, userID)
return err
}
// ScheduleDeletion marks account for deletion after 14 days
func (r *UserRepository) ScheduleDeletion(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE public.users
SET status = 'pending_deletion', deleted_at = NOW() + INTERVAL '14 days', updated_at = NOW()
WHERE id = $1::uuid
`, userID)
return err
}
// CancelDeletion reverts a pending deletion back to active
func (r *UserRepository) CancelDeletion(ctx context.Context, userID string) error {
return r.ReactivateUser(ctx, userID)
}
// GetAccountsPendingPurge returns user IDs whose deletion grace period has expired
func (r *UserRepository) GetAccountsPendingPurge(ctx context.Context) ([]string, error) {
rows, err := r.pool.Query(ctx, `
SELECT id::text FROM public.users
WHERE status = 'pending_deletion' AND deleted_at IS NOT NULL AND deleted_at <= NOW()
`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, nil
}
// CascadePurgeUser permanently deletes ALL user data from every table. Irreversible.
func (r *UserRepository) CascadePurgeUser(ctx context.Context, userID string) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
// Order matters: delete from leaf tables first, then parent tables
purgeQueries := []string{
// Post-related (leaf tables first)
`DELETE FROM public.post_reactions WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
`DELETE FROM public.post_likes WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
`DELETE FROM public.post_saves WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
`DELETE FROM public.post_mentions WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
`DELETE FROM public.post_interactions WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
`DELETE FROM public.post_metrics WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
`DELETE FROM public.post_hashtags WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
`DELETE FROM public.post_categories WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
// Also remove this user's likes/saves/reactions on OTHER people's posts
`DELETE FROM public.post_reactions WHERE user_id = $1::uuid`,
`DELETE FROM public.post_likes WHERE user_id = $1::uuid`,
`DELETE FROM public.post_saves WHERE user_id = $1::uuid`,
`DELETE FROM public.post_interactions WHERE user_id = $1::uuid`,
// Beacon votes
`DELETE FROM public.beacon_votes WHERE user_id = $1::uuid`,
// Comments on user's posts
`DELETE FROM public.comments WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
// User's own comments
`DELETE FROM public.comments WHERE author_id = $1::uuid`,
// Feed engagement
`DELETE FROM public.feed_engagement WHERE user_id = $1::uuid`,
// Posts themselves
`DELETE FROM public.posts WHERE author_id = $1::uuid`,
// Messaging / E2EE
`DELETE FROM public.secure_messages WHERE sender_id = $1::uuid OR receiver_id = $1::uuid`,
`DELETE FROM public.encrypted_messages WHERE sender_id = $1::uuid OR receiver_id = $1::uuid`,
`DELETE FROM public.e2ee_session_state WHERE session_id IN (SELECT id FROM public.e2ee_sessions WHERE user_a = $1::uuid OR user_b = $1::uuid)`,
`DELETE FROM public.e2ee_sessions WHERE user_a = $1::uuid OR user_b = $1::uuid`,
`DELETE FROM public.encrypted_conversations WHERE participant_a = $1::uuid OR participant_b = $1::uuid`,
// Signal keys
`DELETE FROM public.one_time_prekeys WHERE user_id = $1::uuid`,
`DELETE FROM public.signed_prekeys WHERE user_id = $1::uuid`,
`DELETE FROM public.signal_keys WHERE user_id = $1::uuid`,
// Social graph
`DELETE FROM public.circle_members WHERE user_id = $1::uuid OR member_id = $1::uuid`,
`DELETE FROM public.follows WHERE follower_id = $1::uuid OR following_id = $1::uuid`,
`DELETE FROM public.blocks WHERE blocker_id = $1::uuid OR blocked_id = $1::uuid`,
// Notifications
`DELETE FROM public.notifications WHERE user_id = $1::uuid OR actor_id = $1::uuid`,
`DELETE FROM public.notification_preferences WHERE user_id = $1::uuid`,
`DELETE FROM public.user_fcm_tokens WHERE user_id = $1::uuid`,
// Moderation & violations
`DELETE FROM public.moderation_flags WHERE user_id = $1::uuid`,
`DELETE FROM public.pending_moderation WHERE user_id = $1::uuid`,
`DELETE FROM public.user_violation_history WHERE user_id = $1::uuid`,
`DELETE FROM public.user_violations WHERE user_id = $1::uuid`,
`DELETE FROM public.user_appeals WHERE user_id = $1::uuid`,
`DELETE FROM public.content_strikes WHERE user_id = $1::uuid`,
`DELETE FROM public.reports WHERE reporter_id = $1::uuid`,
`DELETE FROM public.abuse_logs WHERE user_id = $1::uuid`,
`DELETE FROM public.user_status_history WHERE user_id = $1::uuid`,
// Categories & hashtags
`DELETE FROM public.user_category_preferences WHERE user_id = $1::uuid`,
`DELETE FROM public.user_category_settings WHERE user_id = $1::uuid`,
`DELETE FROM public.hashtag_follows WHERE user_id = $1::uuid`,
// Backup & recovery
`DELETE FROM public.recovery_shard_submissions WHERE session_id IN (SELECT id FROM public.recovery_sessions WHERE user_id = $1::uuid)`,
`DELETE FROM public.recovery_sessions WHERE user_id = $1::uuid`,
`DELETE FROM public.recovery_guardians WHERE user_id = $1::uuid OR guardian_id = $1::uuid`,
`DELETE FROM public.cloud_backups WHERE user_id = $1::uuid`,
`DELETE FROM public.backup_preferences WHERE user_id = $1::uuid`,
`DELETE FROM public.sync_codes WHERE user_id = $1::uuid`,
`DELETE FROM public.user_devices WHERE user_id = $1::uuid`,
// Auth & tokens
`DELETE FROM public.auth_tokens WHERE user_id = $1::uuid`,
`DELETE FROM public.refresh_tokens WHERE user_id = $1::uuid`,
`DELETE FROM public.verification_tokens WHERE user_id = $1::uuid`,
`DELETE FROM public.password_reset_tokens WHERE user_id = $1::uuid`,
`DELETE FROM public.webauthn_credentials WHERE user_id = $1::uuid`,
`DELETE FROM public.user_mfa_secrets WHERE user_id = $1::uuid`,
// Settings
`DELETE FROM public.user_settings WHERE user_id = $1::uuid`,
`DELETE FROM public.profile_privacy_settings WHERE user_id = $1::uuid`,
// Trust
`DELETE FROM public.trust_state WHERE user_id = $1::uuid`,
// Username claims
`DELETE FROM public.username_claim_requests WHERE user_id = $1::uuid`,
// Profile & user (last)
`DELETE FROM public.profiles WHERE id = $1::uuid`,
`DELETE FROM public.users WHERE id = $1::uuid`,
}
for _, q := range purgeQueries {
if _, err := tx.Exec(ctx, q, userID); err != nil {
return fmt.Errorf("purge query failed: %s: %w", q[:60], err)
}
}
return tx.Commit(ctx)
}