Skip to content

Moments

Purpose

The moment domain owns the friend-shared photo-drop format: short-lived (or "kept") photos pinned to a location or to raw coordinates. A moment is co-owned by its creator and each tagged companion. Each side holds an independent reference; the row is only physically removed once every reference is released.

Package moment (backend/internal/services/moment/) holds the vertical slice: store.go + tag_store.go (persistence), service.go (the Service interface), handler.go (HTTP), routes.go (mount), types.go (response DTOs), deps.go (ports on other domains), wire.go.

Ownership model

Every moment is authored by one user (user_id, the creator) and has zero-or-more active companions in moment_tags (rows with untagged_at IS NULL). The creator's reference is tracked on the moment itself via creator_deleted_at; each companion's reference lives on their moment_tags row.

  • Creator credit persists after the creator releases their reference. When creator_deleted_at is set the row still exists (and still names the original creator) as long as at least one companion holds an active tag.
  • Companions are auto-tagged at create-time from tagged_user_ids; either the creator or the companion themselves can call the untag endpoint to release that companion's reference.
  • When the creator has released AND every companion has untagged, the row soft-deletes (its deleted_at column is stamped; reads filter WHERE deleted_at IS NULL). The two-phase cleanup then purges media and hard-deletes 7 days later.

The orphan check (maybeCascadeDelete) runs after any release-of-reference call (creator delete OR companion untag). If creator_deleted_at IS NOT NULL AND CountActiveTags returns zero, the row soft-deletes immediately; otherwise it stays.

Visibility

Two independent visibility layers apply:

  • Creator visibility (moments.visibility) governs the creator's own profile / feed / public discovery surfaces.
  • Companion visibility (moment_tags.visibility, one row per companion) governs whether the companion's profile / feed shows the moment to a given viewer. It is seeded at tag-time from the companion's default_tagged_moment_visibility on user_profiles.

Public discovery (map + explore) only consults the creator's layer. The companion layer only affects the companion's own profile and their feed contribution.

HTTP endpoints

All mount under the JWT-verified group. Detail, like, and the mutation routes run inside MomentService, which enforces the visibility gate and ownership.

Method Path Description
POST /api/v1/moments Create from a freshly-uploaded key (the client already PUT the media to S3 via POST /api/v1/uploads/moment; assets.AssertUploaded confirms the object landed and belongs to the caller). Body includes optional tagged_user_ids: string[] (each must be an accepted friend; a non-friend short-circuits with 403), optional rating: 1.0-5.0 (folded into the parent location's aggregate; out-of-range returns 400), optional visibility, and optional duration (hours; omit = 24h, 0 = keep forever).
GET /api/v1/moments/:id Viewer-projected MomentDetailPayload. Runs through the privacy gate. The four "not visible" reasons (soft-deleted / expired / hidden / missing) collapse to 404 so existence isn't leaked.
POST /api/v1/moments/:id/like Toggle like; returns {is_liked, likes_count}.
DELETE /api/v1/moments/:id/tags/:userID Release a companion's reference. :userID is a friend's UUID or the literal me. Creator can release anyone; companion can release themselves; third parties get 403. 204 on success, 404 if not tagged.
DELETE /api/v1/moments/:id Release the creator's reference. Creator only; others get 403. Cascades to soft-delete if no active companion tags remain. 204 on success.
PATCH /api/v1/moments/:id/visibility Creator-only. {visibility: "public"\|"friends"\|"close_friends"\|"private"}. 204.
PATCH /api/v1/moments/:id/my-tag-visibility Companion-only. Same body; sets the caller's own moment_tags.visibility. 204.

Spatial and location-attached moment reads live on the discovery domain: GET /discovery/map (moments show as a count badge on the location marker, or as their own standalone_moment marker when they carry no location_id) and GET /discovery/locations/:id. See Discovery.

Key types

// backend/internal/services/moment/service.go
type Service interface {
    CreateMoment(ctx context.Context, moment *models.Moment, taggedUserIDs []uuid.UUID, rating float64) error
    UntagUser(ctx context.Context, momentID, callerID, targetUserID uuid.UUID) (bool, error)
    DeleteAsCreator(ctx context.Context, momentID, creatorID uuid.UUID) error
    UpdateVisibility(ctx context.Context, momentID, callerID uuid.UUID, vis models.Visibility) error
    UpdateMyTagVisibility(ctx context.Context, momentID, callerID uuid.UUID, vis models.Visibility) error
    GetMomentByID(ctx context.Context, id uuid.UUID) (*models.Moment, error)
    GetMomentDetail(ctx context.Context, viewerID, momentID uuid.UUID) (*MomentDetailPayload, error)
    ToggleLike(ctx context.Context, userID, momentID uuid.UUID) (liked bool, newCount int, err error)
    CleanupExpired(ctx context.Context) (int64, error)
    PurgeSoftDeleted(ctx context.Context, olderThanDays int) (int64, error)
}

MomentDetailPayload, LocationBrief, and UserBrief (the per-domain cross-cutting brief) live in types.go.

Data model

Table Notes
moments coordinates (PostGIS), expires_at (indexed), is_journaled, visibility, likes_count, creator_deleted_at (nullable, indexed; creator's reference marker), deleted_at (soft delete), location_id (nullable). BeforeCreate stamps expires_at = now + duration unless is_journaled is true.
moment_likes Composite uniqueness on (moment_id, user_id). FK to moments ON DELETE CASCADE.
moment_tags Composite PK (moment_id, user_id) + tagged_at + visibility + untagged_at (nullable, indexed). List queries filter untagged_at IS NULL. FKs to moments and users cascade. Indexed by user_id for the tagged-feed read path.

Dependencies

Ports declared in deps.go and satisfied by other domains at wiring time:

  • Store / TagStore — moment + moment-tag persistence. CreateMany uses ON CONFLICT DO NOTHING; MarkUntagged soft-marks; CountActiveTags backs the orphan check.
  • FriendChecker.AreFriends — gates the create-time companion fan-out.
  • LocationReaderGetByID (country snapshot for the travel-log entry) and RecordRating. MomentHandler.CreateMoment also calls location.LocationService.Resolve to turn raw coords into a persisted Location.
  • ProfileReader.Get — seeds each companion's moment_tags.visibility from their default_tagged_moment_visibility.
  • TravelRecorder.Record (passport) — one entry per companion (TravelLogSourceTaggedMoment) plus one for the author (TravelLogSourceMoment).
  • assets.ServiceAssertUploaded at create; EnqueueDelete for the async S3 cascade at purge.
  • notify.Notifier — emits moment.tagged per companion and moment.liked to the creator.
  • TagNotifier.SendTagNotice — fires the tag-notice DM at each companion. Share URL is {shareBaseURL}/m/<id>. See Chat -> tag-notice DMs.
  • privacy.Loader — backs the CanView gate in GetMomentDetail.

Notable behavior

Reference-counted co-ownership

A moment lives as long as any reference to it does. DeleteAsCreator sets creator_deleted_at; UntagUser sets that companion's untagged_at. Each release triggers the orphan check; the row physically soft-deletes only when the creator has released AND no active companion remains.

Two-phase deletion

Timed moments aren't blown away the moment they expire. CleanupExpired (via Store.DeleteExpired) soft-deletes non-journaled rows past expires_at and clears their likes, batched 500/run and capped at 20 batches so a busy backlog never holds long locks. PurgeSoftDeleted(olderThanDays) then hard-deletes rows soft-deleted more than 7 days ago; it captures each asset_key in-transaction and fans out an async S3 delete (assets.EnqueueDelete) per key. The async cascade keeps the cron fast and isolates S3 hiccups; a failed object delete lands in the archived asynq queue and surfaces via the admin DLQ inspector.

Kept moments never expire

duration = 0 marks the moment journaled: BeforeCreate skips the expires_at stamp, and both cleanup jobs ignore rows with expires_at IS NULL. They stay until the ownership refs are released.

Off-grid moments skip the travel log

A moment without a location_id (or whose location resolves to a blank country) records no travel-log entry for the author or any companion: there is no country/city anchor to count by. Tags and notifications still fire.

Companion fan-out is best-effort, not transactional

Each per-companion travel_log write, notification, and tag-notice DM happens outside the moments insert and ignores its error: a companion briefly missing a log row is better than failing the publish. The friend-check DOES short-circuit the whole create (the moment row isn't written) when any tagged user isn't an accepted friend.

Untag leaves the audit trail

MarkUntagged sets untagged_at rather than deleting the row, and the tag-time travel_log entry stays. The engine has already observed the presence and may have granted stamps or moved challenge progress; clawing it back would create spurious "you lost a stamp" events. Every list query filters untagged_at IS NULL, so the row is invisible in feeds and profiles even though it lingers for audit.

Realtime and emits

Moments have no dedicated WebSocket; their live signal rides the notification pipeline's /ws/client bus (see Notifications).

  • ToggleLike flipping to liked -> creator gets moment.liked (inbox + WS, no push; new likers fold onto the open grouped row until the creator marks it read).
  • CreateMoment with companions -> each tagged user gets moment.tagged (inbox + push + WS) plus one tag-notice DM.

Where to look