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_atis 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_atcolumn is stamped; reads filterWHERE 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'sdefault_tagged_moment_visibilityonuser_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.CreateManyuses ON CONFLICT DO NOTHING;MarkUntaggedsoft-marks;CountActiveTagsbacks the orphan check.FriendChecker.AreFriends— gates the create-time companion fan-out.LocationReader—GetByID(country snapshot for the travel-log entry) andRecordRating.MomentHandler.CreateMomentalso callslocation.LocationService.Resolveto turn raw coords into a persistedLocation.ProfileReader.Get— seeds each companion'smoment_tags.visibilityfrom theirdefault_tagged_moment_visibility.TravelRecorder.Record(passport) — one entry per companion (TravelLogSourceTaggedMoment) plus one for the author (TravelLogSourceMoment).assets.Service—AssertUploadedat create;EnqueueDeletefor the async S3 cascade at purge.notify.Notifier— emitsmoment.taggedper companion andmoment.likedto 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 theCanViewgate inGetMomentDetail.
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).
ToggleLikeflipping to liked -> creator getsmoment.liked(inbox + WS, no push; new likers fold onto the open grouped row until the creator marks it read).CreateMomentwith companions -> each tagged user getsmoment.tagged(inbox + push + WS) plus one tag-notice DM.
Where to look¶
backend/internal/services/moment/service.go,handler.go,store.go,tag_store.go,types.go,deps.gobackend/internal/models/moment.gobackend/internal/async/scheduler.go—TaskMomentCleanup(@every 5m),TaskMomentPurge(@every 24h)backend/internal/async/handlers/cron.go—HandleMomentCleanup,HandleMomentPurge(PurgeSoftDeleted(ctx, 7))frontend/services/momentService.ts