initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
392
api/internal/service/place.go
Normal file
392
api/internal/service/place.go
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue