Skip to main content

Data model

Status: Scaffolded 2026-09-24, ingest fields added 2026-09-29 per the ingest contract; place_checkins and book fields added 2026-10-24

Every collection is a Mongoose schema in vegan-grove-api/src/models, with timestamps: true, every reference an ObjectId, and every index declared in the schema. That last sentence is the whole lesson from The Trick Book, where 38 collections were shaped by whichever insertOne ran first and userId was an ObjectId, a string, or a DBRef depending on the file.

The field-level privacy view is the data inventory. This page is the structural view.

Relationships​

1 to many

sorted pair

joins

hosts

hosts

may locate

makes

writes

confirms

keeps

records

has

users

sessions

friendships

grove_members

groves

events

organizations

places

event_rsvps

posts

comments

reactions

place_reviews

place_checkins

place_lists

conversations

messages

action_log

companion_conversations

Collections by domain​

Identity​

CollectionKey fieldsIndexes
usersemail (lowercase), emailVerifiedAt, passwordHash?, handle, avatarKey?, homeArea, discoverable, publicPostsEnabled, role, providers[], interests[], deletedAt?unique email, unique handle, providers.provider + providers.subject
sessionsuserId, tokenHash, expiresAt, lastSeenAt, clientunique tokenHash, TTL expiresAt, userId
magic_linksemail, tokenHash, expiresAt, usedAt?unique tokenHash, TTL expiresAt

Social graph​

CollectionKey fieldsIndexes
friendshipsuserA, userB (sorted so the pair is canonical), status, requestedByunique userA + userB, userB
friend_invitesuserId, code, expiresAt, usesLeftunique code, TTL expiresAt
grovesname, slug, area, description, memberCountunique slug, area
grove_membersgroveId, userId, roleunique groveId + userId, userId
organizationsname, slug, type, description, website?, socials (handles only), area?, verified, adminUserIds[], plus provenanceunique slug, unique source + sourceId

Places and events​

CollectionKey fieldsIndexes
placesname, slug, type (including garden; a farmers market is grocery plus the tag market:farmers), veganLevel, location (GeoJSON Point), address, city, postcode, area (derived from coordinates), website, phone, hours, tags[], description, chain, photoKeys[], approvalStatus, submittedBy?, ratingAvg, reviewCount, checkInCount, latestCheckInMonth, lastAgreeingCheckInAt (not serialized; it drives verification), plus provenanceunique slug, 2dsphere location, approvalStatus + type, approvalStatus + veganLevel + chain, unique source + sourceId, sparse unique osmId (legacy, equal to sourceId on OSM rows)
place_reviewsplaceId, userId, rating, content, visitedMonth, showHandle, statusplaceId + status + _id, sparse userId, unique placeId + userId while the review is active and userId is set
place_checkinsplaceId, userId, month (server-assigned UTC YYYY-MM), veganLevel, rating?. One row per member per place per month. Public routes return the aggregates on places onlyunique placeId + userId + month while userId is set, sparse userId
place_listsuserId, name, placeIds[], isPublic (stored for future friend sharing; reads stay owner-only)userId + _id
eventstitle, slug, type, startsAt, endsAt, location? (absent on an ingested event with no coordinates), placeId?, venueName, address, detailsAfterRsvp, hostType, hostId, description, coverKey?, visibility, rsvpCount, status (draft, pending, published, cancelled; pending is the ingest landing state), createdBy? (absent on ingested events), plus provenanceunique slug, 2dsphere location, startsAt + status, hostType + hostId, unique source + sourceId
event_rsvpseventId, userId, statusunique eventId + userId, userId

Feed​

CollectionKey fieldsIndexes
postsuserId, mediaType, imageKeys[], bunnyVideoId?, caption, visibility, groveId?, placeId?, eventId?, stats, statususerId + createdAt, visibility + createdAt, groveId + createdAt
commentspostId, userId, parentId?, content, statuspostId + createdAt
reactionspostId, userId, typeunique postId + userId
saved_postspostId, userIdunique postId + userId
reportstargetType, targetId, reportedBy, reason, statusstatus + createdAt

Messages​

CollectionKey fieldsIndexes
conversationsparticipantIds[] (sorted), lastMessageAt, unreadunique participantIds, participantIds + lastMessageAt
messagesconversationId, senderId, ciphertext, iv, keyId, type, expiresAtconversationId + createdAt, TTL expiresAt

Content and companion​

CollectionKey fieldsIndexes
media_itemstitle, slug, kind (documentary, film, series, talk, short, book), year (1000 through 2100, so a pre-1900 book can keep its year), releaseDate?, synopsis, tagline?, posterKey?, backdropKey?, runtimeMinutes?, directors[], authors[] (catalogue names, not members), featuring[], genres[], rating?, ratingCount?, contentRating?, originalLanguage?, watchLinks[] { provider, url, access }, trailerYoutubeId?, officialSite?, tags[], contentWarnings[], actions[] { label, url, type, org? }, stats { saves, moved, acted }, externalIds { tmdb, wikidata, imdb, isbn, openLibrary }, featured, status, plus provenanceunique slug, status + kind + _id, status + featured + _id, status + year + _id, status + tags, status + genres, status + watchLinks.access, unique source + sourceId
media_collectionsslug, name, description, order, published, itemIds[] (ordered)unique slug, published + order
saved_mediamediaId, userIdunique mediaId + userId, userId + _id
media_reactionsmediaId, userId, type (moved, acted)unique mediaId + userId + type
creators (proposed)name, slug, alsoKnownAs[], kind, members[], bio, topics[], country, languages[], website?, podcast?, socials { youtube, instagram, tiktok, x, bluesky, mastodon }, externalIds { wikidata }, veganSource { url, label }, relatedGuideIds[], relatedMediaIds[], status, reviewedAt?, plus provenance; no count fields (Creators)unique slug, status + name, status + topics, status + languages, unique sparse externalIds.wikidata, unique source + sourceId
guidestitle, slug, category, summary, body, sources[] { title, url, license }, status, plus provenanceunique slug, category, unique source + sourceId
action_loguserId, type, eventId?, hours?, note?, occurredAtuserId + occurredAt
companion_conversationsuserId, messages[], pinned, expiresAt?userId, TTL expiresAt

Notifications​

CollectionKey fieldsIndexes
push_tokensuserId, token, platform, deadAt?unique token, TTL deadAt
notification_preferencesuserId, eventReminders, friendRequests, messages, quietHours?unique userId
scheduled_notificationsuserId, kind, payload, scheduledFor, status, idempotencyKeyunique idempotencyKey, scheduledFor + status

Provenance on ingested collections​

places, events, organizations, media_items, and guides can be written by the ingest endpoint. Each carries the same five fields: source (a string id such as osm, curated, ics:<org-slug>, wikidata, bot:grokbot), sourceId (stable within the source), sourceUrl?, lastSeenAt, and adminEdited: string[], the field paths an admin changed through the admin routes. The unique index on source + sourceId is the upsert key. It is a partial index over rows that have a sourceId, so member-submitted places (source: user) are unaffected. An ingest write sets only fields absent from adminEdited and never lowers a moderation state. None of the five fields references a member.

Conventions​

  • Derived counters (memberCount, rsvpCount, ratingAvg, reviewCount, checkInCount, stats) are maintained by the service that owns the write and recomputed by a nightly worker. They are never trusted for permission checks.
  • TTL indexes carry the retention policy: sessions, magic links, invites, messages, unpinned companion conversations, dead push tokens.
  • Soft delete exists only on users (deletedAt) so that a deleted account's handle is released after a cooling period. Place lists are hard-deleted with the account. Reviews and check-ins stay, with the member reference cleared, so a public count can stay after the person is gone (ADR-0010).
  • Geo queries are bounding-box $geoWithin on the 2dsphere index. There is no $near query anywhere, because $near needs a point, and the client never sends one.