Skip to content

Commit aa75bf3

Browse files
committed
fix(profile): preserve and collect profile location
1 parent 64985f6 commit aa75bf3

16 files changed

Lines changed: 625 additions & 20 deletions

File tree

backend/internal/adapters/http/profile/dto.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package profile
22

33
type UpdateProfileRequest struct {
4-
Bio string `json:"bio" validate:"max=500"`
5-
Interests []string `json:"interests" validate:"max=20,dive,max=40"`
6-
City string `json:"city" validate:"max=120"`
4+
Bio string `json:"bio" validate:"max=500"`
5+
Interests []string `json:"interests" validate:"max=20,dive,max=40"`
6+
City string `json:"city" validate:"max=120"`
7+
// Omitting both coordinates preserves the stored location. ClearLocation
8+
// is the only way to remove it.
79
Latitude *float64 `json:"latitude" validate:"omitempty,min=-90,max=90"`
810
Longitude *float64 `json:"longitude" validate:"omitempty,min=-180,max=180"`
11+
ClearLocation bool `json:"clear_location"`
912
Questionnaire map[string]any `json:"questionnaire"`
1013
OnboardingCompleted bool `json:"onboarding_completed"`
1114
}

backend/internal/adapters/http/profile/handler.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,21 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
8888
City: h.sanitize(req.City),
8989
Latitude: req.Latitude,
9090
Longitude: req.Longitude,
91+
ClearLocation: req.ClearLocation,
9192
Questionnaire: h.sanitizeQuestionnaire(req.Questionnaire),
9293
OnboardingCompleted: req.OnboardingCompleted,
9394
})
9495
if err != nil {
95-
if errors.Is(err, applicationprofile.ErrOnboardingIncomplete) {
96+
switch {
97+
case errors.Is(err, applicationprofile.ErrOnboardingIncomplete):
9698
writeError(w, r, http.StatusUnprocessableEntity, "ONBOARDING_INCOMPLETE", "add a bio and at least one photo before completing onboarding")
97-
return
99+
case errors.Is(err, domainprofile.ErrIncompleteCoordinates):
100+
writeError(w, r, http.StatusBadRequest, "INCOMPLETE_COORDINATES", "latitude and longitude must be provided together and be within range")
101+
case errors.Is(err, domainprofile.ErrConflictingLocation):
102+
writeError(w, r, http.StatusBadRequest, "CONFLICTING_LOCATION", "coordinates and clear_location cannot be combined")
103+
default:
104+
writeError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "could not update profile")
98105
}
99-
writeError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "could not update profile")
100106
return
101107
}
102108
writeJSON(w, http.StatusOK, profileResponse(*updated))

backend/internal/adapters/postgres/queries/profiles.sql

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ ON CONFLICT (user_id) DO UPDATE SET
1212
bio = EXCLUDED.bio,
1313
interests = EXCLUDED.interests,
1414
city = EXCLUDED.city,
15-
location = EXCLUDED.location,
15+
-- An update that carries no coordinates must preserve the stored ones;
16+
-- only an explicit clear_location drops them.
17+
location = CASE
18+
WHEN sqlc.arg(clear_location)::boolean THEN NULL
19+
WHEN EXCLUDED.location IS NULL THEN profiles.location
20+
ELSE EXCLUDED.location
21+
END,
1622
questionnaire = EXCLUDED.questionnaire,
1723
onboarding_completed = EXCLUDED.onboarding_completed,
1824
updated_at = now();

backend/internal/adapters/postgres/repositories/profile_repository.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ func (r *ProfileRepository) UpsertProfile(ctx context.Context, p *domainprofile.
7373
Latitude: latitude,
7474
Questionnaire: questionnaire,
7575
OnboardingCompleted: p.OnboardingCompleted,
76+
ClearLocation: p.ClearLocation,
7677
})
7778
}
7879

backend/internal/adapters/postgres/sqlc/profiles.sql.go

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/internal/application/profile/service.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@ var (
1919
)
2020

2121
type UpdateProfileInput struct {
22-
Bio string
23-
Interests []string
24-
City string
22+
Bio string
23+
Interests []string
24+
City string
25+
// Latitude/Longitude must both be set or both be nil. Leaving them nil
26+
// preserves whatever is stored; ClearLocation is the only way to remove
27+
// it, so a caller that simply does not know the coordinates can never
28+
// erase them by accident.
2529
Latitude *float64
2630
Longitude *float64
31+
ClearLocation bool
2732
Questionnaire map[string]any
2833
OnboardingCompleted bool
2934
}
@@ -67,6 +72,13 @@ func (s *Service) GetProfile(ctx context.Context, userID uuid.UUID) (*domainprof
6772
}
6873

6974
func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, in UpdateProfileInput) (*domainprofile.Profile, error) {
75+
if err := validateLocationIntent(in); err != nil {
76+
return nil, err
77+
}
78+
79+
// Onboarding deliberately does not require a location: forcing a browser
80+
// geolocation grant to finish signing up is hostile and denial is common.
81+
// A profile without coordinates simply stays out of discovery.
7082
if in.OnboardingCompleted {
7183
photoCount, err := s.profiles.CountActivePhotos(ctx, userID)
7284
if err != nil {
@@ -82,6 +94,7 @@ func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, in Update
8294
Bio: in.Bio,
8395
Interests: in.Interests,
8496
City: in.City,
97+
ClearLocation: in.ClearLocation,
8598
Questionnaire: in.Questionnaire,
8699
OnboardingCompleted: in.OnboardingCompleted,
87100
}
@@ -94,6 +107,22 @@ func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, in Update
94107
return s.profiles.GetProfile(ctx, userID)
95108
}
96109

110+
func validateLocationIntent(in UpdateProfileInput) error {
111+
if (in.Latitude == nil) != (in.Longitude == nil) {
112+
return domainprofile.ErrIncompleteCoordinates
113+
}
114+
if in.ClearLocation && in.Latitude != nil {
115+
return domainprofile.ErrConflictingLocation
116+
}
117+
if in.Latitude != nil && (*in.Latitude < -90 || *in.Latitude > 90) {
118+
return domainprofile.ErrIncompleteCoordinates
119+
}
120+
if in.Longitude != nil && (*in.Longitude < -180 || *in.Longitude > 180) {
121+
return domainprofile.ErrIncompleteCoordinates
122+
}
123+
return nil
124+
}
125+
97126
func (s *Service) GetPreferences(ctx context.Context, userID uuid.UUID) (*domainprofile.Preferences, error) {
98127
return s.profiles.GetPreferences(ctx, userID)
99128
}

backend/internal/application/profile/service_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,21 @@ func (r *fakeProfileRepo) GetProfile(_ context.Context, userID uuid.UUID) (*doma
4343
return &copied, nil
4444
}
4545

46+
// UpsertProfile mirrors the location semantics of the real SQL: an explicit
47+
// clear drops the coordinates, new coordinates replace them, and a write that
48+
// carries neither leaves whatever was stored untouched.
4649
func (r *fakeProfileRepo) UpsertProfile(_ context.Context, p *domainprofile.Profile) error {
4750
copied := *p
51+
switch {
52+
case p.ClearLocation:
53+
copied.HasLocation = false
54+
case p.Location != nil:
55+
copied.HasLocation = true
56+
default:
57+
if existing, ok := r.profiles[p.UserID]; ok {
58+
copied.HasLocation = existing.HasLocation
59+
}
60+
}
4861
r.profiles[p.UserID] = &copied
4962
return nil
5063
}
@@ -298,6 +311,105 @@ func TestUpdateProfileRequiresBioAndPhotoToCompleteOnboarding(t *testing.T) {
298311
require.ErrorIs(t, err, applicationprofile.ErrOnboardingIncomplete)
299312
}
300313

314+
// --- location intent ------------------------------------------------------
315+
316+
func coord(value float64) *float64 { return &value }
317+
318+
// TestUpdateProfileWithoutCoordinatesPreservesStoredLocation is the
319+
// regression test for the bug that made every profile undiscoverable: saving
320+
// the profile from the UI carried no coordinates and wiped the stored ones.
321+
func TestUpdateProfileWithoutCoordinatesPreservesStoredLocation(t *testing.T) {
322+
repo := newFakeProfileRepo()
323+
svc := newService(repo, newFakeStorage(), fakeConsentChecker{})
324+
userID := uuid.New()
325+
326+
stored, err := svc.UpdateProfile(context.Background(), userID, applicationprofile.UpdateProfileInput{
327+
Bio: "con ubicación", Latitude: coord(40.4168), Longitude: coord(-3.7038),
328+
})
329+
require.NoError(t, err)
330+
require.True(t, stored.HasLocation)
331+
332+
// A later save that only edits the bio must not erase the location.
333+
updated, err := svc.UpdateProfile(context.Background(), userID, applicationprofile.UpdateProfileInput{
334+
Bio: "bio editada sin tocar la ubicación",
335+
})
336+
require.NoError(t, err)
337+
require.True(t, updated.HasLocation, "omitting coordinates must preserve the stored location")
338+
}
339+
340+
func TestUpdateProfileClearLocationRemovesIt(t *testing.T) {
341+
repo := newFakeProfileRepo()
342+
svc := newService(repo, newFakeStorage(), fakeConsentChecker{})
343+
userID := uuid.New()
344+
345+
_, err := svc.UpdateProfile(context.Background(), userID, applicationprofile.UpdateProfileInput{
346+
Bio: "con ubicación", Latitude: coord(40.4168), Longitude: coord(-3.7038),
347+
})
348+
require.NoError(t, err)
349+
350+
cleared, err := svc.UpdateProfile(context.Background(), userID, applicationprofile.UpdateProfileInput{
351+
Bio: "sin ubicación", ClearLocation: true,
352+
})
353+
require.NoError(t, err)
354+
require.False(t, cleared.HasLocation)
355+
}
356+
357+
func TestUpdateProfileRejectsHalfCoordinates(t *testing.T) {
358+
repo := newFakeProfileRepo()
359+
svc := newService(repo, newFakeStorage(), fakeConsentChecker{})
360+
361+
_, err := svc.UpdateProfile(context.Background(), uuid.New(), applicationprofile.UpdateProfileInput{
362+
Bio: "solo latitud", Latitude: coord(40.4168),
363+
})
364+
require.ErrorIs(t, err, domainprofile.ErrIncompleteCoordinates)
365+
366+
_, err = svc.UpdateProfile(context.Background(), uuid.New(), applicationprofile.UpdateProfileInput{
367+
Bio: "solo longitud", Longitude: coord(-3.7038),
368+
})
369+
require.ErrorIs(t, err, domainprofile.ErrIncompleteCoordinates)
370+
}
371+
372+
func TestUpdateProfileRejectsOutOfRangeCoordinates(t *testing.T) {
373+
repo := newFakeProfileRepo()
374+
svc := newService(repo, newFakeStorage(), fakeConsentChecker{})
375+
376+
_, err := svc.UpdateProfile(context.Background(), uuid.New(), applicationprofile.UpdateProfileInput{
377+
Bio: "latitud imposible", Latitude: coord(91), Longitude: coord(0),
378+
})
379+
require.ErrorIs(t, err, domainprofile.ErrIncompleteCoordinates)
380+
}
381+
382+
func TestUpdateProfileRejectsCoordinatesCombinedWithClear(t *testing.T) {
383+
repo := newFakeProfileRepo()
384+
svc := newService(repo, newFakeStorage(), fakeConsentChecker{})
385+
386+
_, err := svc.UpdateProfile(context.Background(), uuid.New(), applicationprofile.UpdateProfileInput{
387+
Bio: "contradictorio", Latitude: coord(40.4168), Longitude: coord(-3.7038), ClearLocation: true,
388+
})
389+
require.ErrorIs(t, err, domainprofile.ErrConflictingLocation)
390+
}
391+
392+
// TestUpdateProfileCompletesOnboardingWithoutLocation pins the deliberate
393+
// decision that a location is not required to finish onboarding.
394+
func TestUpdateProfileCompletesOnboardingWithoutLocation(t *testing.T) {
395+
repo := newFakeProfileRepo()
396+
store := newFakeStorage()
397+
svc := newService(repo, store, fakeConsentChecker{})
398+
userID := uuid.New()
399+
400+
_, err := svc.CreatePhoto(context.Background(), userID, applicationprofile.NewPhotoInput{
401+
MimeType: "image/png", Width: 10, Height: 10, ByteSize: 1, Data: []byte("x"),
402+
})
403+
require.NoError(t, err)
404+
405+
updated, err := svc.UpdateProfile(context.Background(), userID, applicationprofile.UpdateProfileInput{
406+
Bio: "sin ubicación pero completo", OnboardingCompleted: true,
407+
})
408+
require.NoError(t, err)
409+
require.True(t, updated.OnboardingCompleted)
410+
require.False(t, updated.HasLocation)
411+
}
412+
301413
func TestCreatePhotoAssignsFirstPhotoAsPrimary(t *testing.T) {
302414
repo := newFakeProfileRepo()
303415
store := newFakeStorage()

backend/internal/domain/profile/entity.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@ type Profile struct {
1818
Bio string
1919
Interests []string
2020
City string
21-
// Location carries coordinates to persist on a write. Reads never
22-
// resolve it back (HasLocation is used instead) so a lossy PostGIS
23-
// NULL-geography round trip can't produce a scan error.
24-
Location *Coordinates
21+
// Location and ClearLocation express the write intent for the stored
22+
// coordinates, which has three states: Location set replaces them,
23+
// ClearLocation drops them, and neither leaves them untouched. A profile
24+
// update that simply omits coordinates must never erase them.
25+
Location *Coordinates
26+
ClearLocation bool
27+
// HasLocation is read-only: reads never resolve Location back, because a
28+
// lossy PostGIS NULL-geography round trip can't be scanned safely.
2529
HasLocation bool
2630
Questionnaire map[string]any
2731
OnboardingCompleted bool

backend/internal/domain/profile/errors.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,9 @@ var (
1313
ErrConsentRequired = errors.New("explicit consent is required before saving this preference")
1414
ErrInvalidAgeRange = errors.New("invalid age range preference")
1515
ErrInvalidDistance = errors.New("invalid max distance preference")
16+
// ErrIncompleteCoordinates guards against a half-applied location: a
17+
// latitude without its longitude (or the reverse) is rejected instead of
18+
// being silently dropped.
19+
ErrIncompleteCoordinates = errors.New("latitude and longitude must be provided together")
20+
ErrConflictingLocation = errors.New("coordinates and clear location cannot be combined")
1621
)

0 commit comments

Comments
 (0)