initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:14:50 +02:00
commit 1253955070
75 changed files with 13972 additions and 0 deletions

View file

@ -0,0 +1,392 @@
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")