package repository import ( "context" "errors" "math" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/yuxingu/digital-psychology/apps/api/internal/model" ) // ProfileRepo persists profiles. type ProfileRepo struct { Pool *pgxpool.Pool } const profileCols = ` id, user_id, relation, display_name, birth_date, relation_type, CASE WHEN birth_time IS NULL THEN NULL ELSE to_char(birth_time, 'HH24:MI') END, birth_place, geo_lat, geo_lng, COALESCE(geo_visible, false), created_at` func scanProfile(scan func(dest ...any) error) (*model.Profile, error) { p := &model.Profile{} var bt *string err := scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.RelationType, &bt, &p.BirthPlace, &p.GeoLat, &p.GeoLng, &p.GeoVisible, &p.CreatedAt) if err != nil { return nil, err } p.BirthTime = bt return p, nil } // CountActiveSelf returns non-deleted self profiles for the user. func (r *ProfileRepo) CountActiveSelf(ctx context.Context, userID uuid.UUID) (int, error) { var n int err := r.Pool.QueryRow(ctx, ` SELECT COUNT(*) FROM profiles WHERE user_id=$1 AND relation='self' AND deleted_at IS NULL`, userID).Scan(&n) return n, err } // Create inserts a profile. func (r *ProfileRepo) Create(ctx context.Context, userID uuid.UUID, relation, name string, birth time.Time, relationType *string, birthTime *string, birthPlace *string) (*model.Profile, error) { row := r.Pool.QueryRow(ctx, ` INSERT INTO profiles(user_id, relation, display_name, birth_date, relation_type, birth_time, birth_place) VALUES ($1,$2,$3,$4,$5, CASE WHEN $6::text IS NULL OR $6::text = '' THEN NULL ELSE $6::time END, NULLIF(TRIM($7::text), '')) RETURNING `+profileCols, userID, relation, name, birth, relationType, birthTime, birthPlace, ) return scanProfile(row.Scan) } // ListByUser returns non-deleted profiles. func (r *ProfileRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]model.Profile, error) { rows, err := r.Pool.Query(ctx, ` SELECT `+profileCols+` FROM profiles WHERE user_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC`, userID) if err != nil { return nil, err } defer rows.Close() var out []model.Profile for rows.Next() { p, err := scanProfile(rows.Scan) if err != nil { return nil, err } out = append(out, *p) } return out, rows.Err() } // GetForUser loads a profile owned by user. func (r *ProfileRepo) GetForUser(ctx context.Context, userID, profileID uuid.UUID) (*model.Profile, error) { row := r.Pool.QueryRow(ctx, ` SELECT `+profileCols+` FROM profiles WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, profileID, userID, ) return scanProfile(row.Scan) } // GetByID loads any non-deleted profile (for invite host lookup). func (r *ProfileRepo) GetByID(ctx context.Context, profileID uuid.UUID) (*model.Profile, error) { row := r.Pool.QueryRow(ctx, ` SELECT `+profileCols+` FROM profiles WHERE id=$1 AND deleted_at IS NULL`, profileID) return scanProfile(row.Scan) } // UpdateForUser patches display name / birth / geo. func (r *ProfileRepo) UpdateForUser(ctx context.Context, userID, profileID uuid.UUID, name string, birth time.Time, relationType *string, birthTime *string, birthPlace *string, geoLat, geoLng *float64, geoVisible *bool) (*model.Profile, error) { row := r.Pool.QueryRow(ctx, ` UPDATE profiles SET display_name=$3, birth_date=$4, relation_type=$5, birth_time=CASE WHEN $6::text IS NULL OR $6::text = '' THEN birth_time ELSE $6::time END, birth_place=CASE WHEN $7::text IS NULL THEN birth_place ELSE NULLIF(TRIM($7::text), '') END, geo_lat=CASE WHEN $8::float8 IS NULL THEN geo_lat ELSE $8 END, geo_lng=CASE WHEN $9::float8 IS NULL THEN geo_lng ELSE $9 END, geo_visible=CASE WHEN $10::bool IS NULL THEN geo_visible ELSE $10 END, updated_at=now() WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL RETURNING `+profileCols, profileID, userID, name, birth, relationType, birthTime, birthPlace, geoLat, geoLng, geoVisible, ) return scanProfile(row.Scan) } // NearbyItem is a visible profile with distance. type NearbyItem struct { Profile model.Profile `json:"profile"` Distance float64 `json:"distance_km"` } // ListNearby returns other users' self profiles with geo_visible within radius. func (r *ProfileRepo) ListNearby(ctx context.Context, excludeUserID uuid.UUID, lat, lng, radiusKm float64, limit int) ([]NearbyItem, error) { if limit <= 0 || limit > 50 { limit = 20 } rows, err := r.Pool.Query(ctx, ` SELECT `+profileCols+` FROM profiles WHERE deleted_at IS NULL AND geo_visible = true AND relation = 'self' AND user_id <> $1 AND geo_lat IS NOT NULL AND geo_lng IS NOT NULL LIMIT 200`, excludeUserID) if err != nil { return nil, err } defer rows.Close() var out []NearbyItem for rows.Next() { p, err := scanProfile(rows.Scan) if err != nil { return nil, err } if p.GeoLat == nil || p.GeoLng == nil { continue } d := haversineKm(lat, lng, *p.GeoLat, *p.GeoLng) if d <= radiusKm { out = append(out, NearbyItem{Profile: *p, Distance: math.Round(d*10) / 10}) } } if err := rows.Err(); err != nil { return nil, err } // sort by distance for i := 0; i < len(out); i++ { for j := i + 1; j < len(out); j++ { if out[j].Distance < out[i].Distance { out[i], out[j] = out[j], out[i] } } } if len(out) > limit { out = out[:limit] } return out, nil } func haversineKm(lat1, lng1, lat2, lng2 float64) float64 { const R = 6371.0 toR := func(d float64) float64 { return d * math.Pi / 180 } dLat := toR(lat2 - lat1) dLng := toR(lng2 - lng1) a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(toR(lat1))*math.Cos(toR(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2) return 2 * R * math.Asin(math.Sqrt(a)) } // SoftDeleteForUser marks a profile deleted. func (r *ProfileRepo) SoftDeleteForUser(ctx context.Context, userID, profileID uuid.UUID) error { tag, err := r.Pool.Exec(ctx, ` UPDATE profiles SET deleted_at=now(), updated_at=now() WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, profileID, userID, ) if err != nil { return err } if tag.RowsAffected() == 0 { return errors.New("profile not found") } return nil }