392 lines
12 KiB
Go
392 lines
12 KiB
Go
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")
|