package service import ( "context" "errors" "fmt" "strings" "time" "github.com/google/uuid" "go-places-api/internal/domain" "go-places-api/internal/events" "go-places-api/internal/geo" "go-places-api/internal/store" ) // maxTitleLength and maxDescriptionLength bound what a client can store, which // keeps a single request from writing an unbounded blob of text. const ( maxTitleLength = 200 maxDescriptionLength = 5000 maxCommentLength = 2000 ) // CreatePlace stores a new place and publishes an event for it. func (s *Service) CreatePlace(ctx context.Context, author domain.User, in domain.PlaceInput) (domain.Place, error) { in.Title = strings.TrimSpace(in.Title) in.Description = strings.TrimSpace(in.Description) if in.Title == "" { return domain.Place{}, fmt.Errorf("%w: title is required", ErrInvalidInput) } if len(in.Title) > maxTitleLength { return domain.Place{}, fmt.Errorf("%w: title exceeds %d characters", ErrInvalidInput, maxTitleLength) } if len(in.Description) > maxDescriptionLength { return domain.Place{}, fmt.Errorf("%w: description exceeds %d characters", ErrInvalidInput, maxDescriptionLength) } radius := s.cfg.DefaultLinkRadiusMeters if in.LinkRadiusMeters != nil { radius = *in.LinkRadiusMeters } if radius <= 0 { return domain.Place{}, fmt.Errorf("%w: link radius must be positive", ErrInvalidInput) } if radius > s.cfg.MaxLinkRadiusMeters { return domain.Place{}, fmt.Errorf("%w: link radius must not exceed %g metres", ErrInvalidInput, s.cfg.MaxLinkRadiusMeters) } place := domain.Place{ Title: in.Title, Description: in.Description, Coordinate: in.Coordinate(), LinkRadiusMeters: radius, Creator: author, } if err := s.store.UpsertUser(ctx, author); err != nil { return domain.Place{}, mapStoreError(err, "user") } created, err := s.store.CreatePlace(ctx, place) if err != nil { return domain.Place{}, mapStoreError(err, "place") } s.publish(ctx, author, events.TopicPlaceCreated, map[string]any{ "placeId": created.ID, "title": created.Title, "creator": author.ID, }) return created, nil } // GetPlace returns one place with its pictures and comments. // // The pictures' public URLs are filled in here because the URL is derived from // the storage key rather than stored, and a caller rendering this place should // not have to know that. func (s *Service) GetPlace(ctx context.Context, id string) (domain.Place, error) { if strings.TrimSpace(id) == "" { return domain.Place{}, fmt.Errorf("%w: place id is required", ErrInvalidInput) } place, err := s.store.GetPlace(ctx, id) if err != nil { return domain.Place{}, mapStoreError(err, "place") } place.Images = s.withImageURLs(place.Images) // The collections are serialised to JSON, where a nil slice becomes null. // Callers iterate them directly, so an empty result is normalised to an // empty slice here rather than at each read site. if place.Images == nil { place.Images = []domain.Image{} } if place.Comments == nil { place.Comments = []domain.Comment{} } if place.RelatedPlaces == nil { place.RelatedPlaces = []domain.RelatedPlace{} } return place, nil } // withImageURLs returns a copy of images with each public URL filled in. func (s *Service) withImageURLs(images []domain.Image) []domain.Image { if len(images) == 0 { return images } out := make([]domain.Image, 0, len(images)) for _, img := range images { out = append(out, decorate(img, s.ImageURL(img.StorageKey))) } return out } // ListPlaces returns places newest first. The listing is a summary, so its // pictures and comments are left empty; a client that needs them reads the place // individually. func (s *Service) ListPlaces(ctx context.Context, limit, offset int) ([]domain.Place, error) { limit, offset = clampPage(limit, offset) places, err := s.store.ListPlaces(ctx, limit, offset) if err != nil { return nil, mapStoreError(err, "place") } // An empty result is returned as an empty slice rather than nil, so it // serialises as [] and clients can iterate without a null check. if places == nil { return []domain.Place{}, nil } for i := range places { places[i].Images = []domain.Image{} places[i].Comments = []domain.Comment{} places[i].RelatedPlaces = []domain.RelatedPlace{} } return places, nil } // DeletePlace removes a place together with its pictures, deleting their blobs // so storage does not leak. func (s *Service) DeletePlace(ctx context.Context, id string) error { place, err := s.store.GetPlace(ctx, id) if err != nil { return mapStoreError(err, "place") } if err := s.store.DeletePlace(ctx, id); err != nil { return mapStoreError(err, "place") } for _, img := range place.Images { if img.StorageKey == "" { continue } if err := s.blobs.Delete(ctx, img.StorageKey); err != nil { // The row is already gone; leaking the blob is worth a warning but // not worth failing the request. s.logger.Warn("failed to delete image blob", "imageId", img.ID, "key", img.StorageKey, "error", err) } } return nil } // AddComment posts a comment on a place. func (s *Service) AddComment(ctx context.Context, author domain.User, placeID, content string) (domain.Comment, error) { content = strings.TrimSpace(content) if placeID == "" { return domain.Comment{}, fmt.Errorf("%w: place id is required", ErrInvalidInput) } if content == "" { return domain.Comment{}, fmt.Errorf("%w: comment content is required", ErrInvalidInput) } if len(content) > maxCommentLength { return domain.Comment{}, fmt.Errorf("%w: comment exceeds %d characters", ErrInvalidInput, maxCommentLength) } // Confirm the place exists so a comment cannot dangle. if _, err := s.store.GetPlace(ctx, placeID); err != nil { return domain.Comment{}, mapStoreError(err, "place") } if err := s.store.UpsertUser(ctx, author); err != nil { return domain.Comment{}, mapStoreError(err, "user") } comment, err := s.store.AddComment(ctx, domain.Comment{ PlaceID: placeID, Content: content, Creator: author, }) if err != nil { return domain.Comment{}, mapStoreError(err, "comment") } // Find the place owner so the notification carries a real recipient rather // than the placeholder the original scaffold used. recipient := "" if place, err := s.store.GetPlace(ctx, placeID); err == nil { recipient = place.Creator.ID } s.publish(ctx, author, events.TopicCommentAdded, map[string]any{ "commentId": comment.ID, "placeId": placeID, "commenterId": author.ID, "placeOwnerId": recipient, "notifyOwner": recipient != "" && recipient != author.ID, }) return comment, nil } // RelatedPlaces finds other places whose pictures overlap the given one, which // is the place-level view of the same-place linking. func (s *Service) RelatedPlaces(ctx context.Context, placeID string) ([]domain.RelatedPlace, error) { place, err := s.store.GetPlace(ctx, placeID) if err != nil { return nil, mapStoreError(err, "place") } if len(place.Images) == 0 { return nil, nil } radius := place.LinkRadiusMeters if radius <= 0 { radius = s.cfg.DefaultLinkRadiusMeters } // Anchor the search on each picture's own coordinates when it has them, // otherwise on the place coordinate. var ( related = map[string]*domain.RelatedPlace{} ownImages = map[string]bool{} ) for _, img := range place.Images { ownImages[img.ID] = true } for _, img := range place.Images { center := effectiveCoordinate(img, place.Coordinate) if center == nil { continue } neighbours, err := s.store.ImagesWithin(ctx, store.NearbyQuery{ Center: *center, RadiusMeters: radius, }) if err != nil { return nil, mapStoreError(err, "image") } for _, other := range neighbours { otherCenter := effectiveCoordinate(other, nil) if otherCenter == nil { continue } distance := geo.DistanceMeters(*center, *otherCenter) if !geo.WithinRadius(*center, *otherCenter, radius) { continue } if ownImages[other.ID] || other.ID == img.ID { continue } entry, ok := related[other.PlaceID] if !ok { entry = &domain.RelatedPlace{DistanceMeters: distance} related[other.PlaceID] = entry } if distance < entry.DistanceMeters { entry.DistanceMeters = distance } entry.SharedImageIDs = append(entry.SharedImageIDs, other.ID) } } // Load the full related places so the caller receives usable records. out := make([]domain.RelatedPlace, 0, len(related)) for relPlaceID, rel := range related { loaded, err := s.GetPlace(ctx, relPlaceID) if err != nil { s.logger.Warn("failed to load related place", "placeId", relPlaceID, "error", err) continue } // A related place listing its own related places would recurse without // end, so the nested copy carries pictures but no further relations. loaded.RelatedPlaces = nil rel.Place = loaded out = append(out, *rel) } sortRelated(out) return out, nil } // UpdateImageCaption changes the description attached to a picture. func (s *Service) UpdateImageCaption(ctx context.Context, imageID, caption string) (domain.Image, error) { caption = strings.TrimSpace(caption) if len(caption) > maxDescriptionLength { return domain.Image{}, fmt.Errorf("%w: caption exceeds %d characters", ErrInvalidInput, maxDescriptionLength) } img, err := s.store.GetImage(ctx, imageID) if err != nil { return domain.Image{}, mapStoreError(err, "image") } img.Caption = caption updated, err := s.store.UpdateImage(ctx, img) if err != nil { return domain.Image{}, mapStoreError(err, "image") } return updated, nil } // ListImages returns pictures matching a filter, with each public URL filled in. func (s *Service) ListImages(ctx context.Context, f store.ImageFilter) ([]domain.Image, error) { f.Limit, f.Offset = clampPage(f.Limit, f.Offset) images, err := s.store.ListImages(ctx, f) if err != nil { return nil, mapStoreError(err, "image") } return s.withImageURLs(images), nil } // DeleteImage removes a picture and its stored bytes. func (s *Service) DeleteImage(ctx context.Context, id string) error { img, err := s.store.GetImage(ctx, id) if err != nil { return mapStoreError(err, "image") } if err := s.store.DeleteImage(ctx, id); err != nil { return mapStoreError(err, "image") } if img.StorageKey != "" { if err := s.blobs.Delete(ctx, img.StorageKey); err != nil { s.logger.Warn("failed to delete image blob", "imageId", id, "error", err) } } return nil } // ImageURL builds the public URL for a stored picture. func (s *Service) ImageURL(storageKey string) string { if storageKey == "" { return "" } if s.cfg.PublicBaseURL != "" { return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/media/" + storageKey } return "/media/" + storageKey } // publish sends an event, treating delivery as best effort so a broker problem // never fails the request that produced it. func (s *Service) publish(ctx context.Context, actor domain.User, topic events.Topic, payload map[string]any) { body, err := marshalPayload(payload) if err != nil { s.logger.Warn("failed to encode event", "topic", string(topic), "error", err) return } ev := events.Event{ ID: uuid.NewString(), Topic: topic, OccurredAt: time.Now().UTC(), ActorID: actor.ID, Payload: body, } if err := s.events.Publish(ctx, ev); err != nil { s.logger.Warn("failed to publish event", "topic", string(topic), "error", err) } } // effectiveCoordinate returns the picture's own capture point when it has one, // falling back to the place it belongs to. func effectiveCoordinate(img domain.Image, place *domain.Coordinate) *domain.Coordinate { if img.Coordinate != nil && img.Coordinate.Valid() { return img.Coordinate } if place != nil && place.Valid() { return place } return nil } func clampPage(limit, offset int) (int, int) { if limit < 0 { limit = 0 } if offset < 0 { offset = 0 } return limit, offset } var errNoCoordinate = errors.New("no coordinate")