feat: social graph features

This commit is contained in:
Krispire Admin
2026-06-04 19:29:48 +05:30
parent d9678ebb3b
commit efa1eaf23b
28 changed files with 1882 additions and 78 deletions
+107 -8
View File
@@ -35,28 +35,127 @@ func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService
// `handle` is normalized to the project's slug format before being passed
// to the DB so users can't sneak in spaces or punctuation. Empty after
// normalization → 400.
func (s *UserService) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) {
if name != nil {
trimmed := strings.TrimSpace(*name)
// UpdateProfileInput is the partial profile patch a handler hands the
// service. Every field is a pointer so nil means "leave unchanged". The
// service validates/normalizes each present field, then maps to a
// repository.ProfileUpdate.
type UpdateProfileInput struct {
Name *string
AvatarURL *string
Handle *string
Country *string
Sampradaya *string
Gender *string
Initiated *bool
InitiatedAt *string
DailyChantingRounds *int
PreferredChantingTime *string
PeakActiveTime *string
}
// validGenders is the accepted set; "" clears the field.
var validGenders = map[string]bool{"male": true, "female": true, "other": true}
// validSampradayas is the accepted lineage set. Currently just Gaudiya
// (the default); add codes here as more lineages are supported.
var validSampradayas = map[string]bool{"gaudiya": true}
func (s *UserService) UpdateProfile(ctx context.Context, id string, in UpdateProfileInput) (*model.User, error) {
p := repository.ProfileUpdate{
AvatarURL: in.AvatarURL, // passthrough: nil=leave, ""=clear, x=set
Initiated: in.Initiated,
DailyChantingRounds: in.DailyChantingRounds,
}
if in.Name != nil {
trimmed := strings.TrimSpace(*in.Name)
if trimmed == "" {
return nil, errors.New("name cannot be empty")
}
if len(trimmed) > 120 {
return nil, errors.New("name too long (max 120)")
}
name = &trimmed
p.Name = &trimmed
}
if handle != nil {
clean := normalizeHandle(*handle)
if in.Handle != nil {
clean := normalizeHandle(*in.Handle)
if len(clean) < 3 {
return nil, errors.New("handle must be at least 3 characters (letters, digits, dashes)")
}
if len(clean) > 32 {
return nil, errors.New("handle too long (max 32)")
}
handle = &clean
p.Handle = &clean
}
return s.users.UpdateProfile(ctx, id, name, avatarURL, handle)
if in.Country != nil {
// "" clears; otherwise a 2-letter ISO-3166-1 alpha-2 code (the
// frontend dropdown only ever sends valid codes).
cc := strings.ToUpper(strings.TrimSpace(*in.Country))
if cc != "" && len(cc) != 2 {
return nil, errors.New("country must be a 2-letter code")
}
p.Country = &cc
}
if in.Sampradaya != nil {
// "" clears (optional field); otherwise must be a known lineage.
s := strings.ToLower(strings.TrimSpace(*in.Sampradaya))
if s != "" && !validSampradayas[s] {
return nil, errors.New("unknown sampradaya")
}
p.Sampradaya = &s
}
if in.Gender != nil {
g := strings.ToLower(strings.TrimSpace(*in.Gender))
if g != "" && !validGenders[g] {
return nil, errors.New("gender must be male, female, or other")
}
p.Gender = &g
}
if in.InitiatedAt != nil {
d := strings.TrimSpace(*in.InitiatedAt)
if d != "" {
if _, err := time.Parse("2006-01-02", d); err != nil {
return nil, errors.New("initiated_at must be a date (YYYY-MM-DD)")
}
}
p.InitiatedAt = &d
}
if in.DailyChantingRounds != nil {
// 0 is the clear sentinel; real values are 1–1008 (DB enforces too).
n := *in.DailyChantingRounds
if n != 0 && (n < 1 || n > 1008) {
return nil, errors.New("daily_chanting_rounds must be between 1 and 1008")
}
}
if in.PreferredChantingTime != nil {
t, err := normalizeClock(*in.PreferredChantingTime)
if err != nil {
return nil, errors.New("preferred_chanting_time must be HH:MM")
}
p.PreferredChantingTime = &t
}
if in.PeakActiveTime != nil {
t, err := normalizeClock(*in.PeakActiveTime)
if err != nil {
return nil, errors.New("peak_active_time must be HH:MM")
}
p.PeakActiveTime = &t
}
return s.users.UpdateProfile(ctx, id, p)
}
// normalizeClock validates an "HH:MM" 24-hour clock time, returning "" for
// a blank input (clear). The system-managed notification fields use this.
func normalizeClock(raw string) (string, error) {
t := strings.TrimSpace(raw)
if t == "" {
return "", nil
}
if _, err := time.Parse("15:04", t); err != nil {
return "", err
}
return t, nil
}
// Search returns users matching the query (handle or name substring).