Data model
Status: Scaffolded 2026-09-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
Collections by domain
Identity
| Collection | Key fields | Indexes |
|---|---|---|
users | email (lowercase), emailVerifiedAt, passwordHash?, handle, avatarKey?, homeArea, discoverable, publicPostsEnabled, role, providers[], interests[], deletedAt? | unique email, unique handle, providers.provider + providers.subject |
sessions | userId, tokenHash, expiresAt, lastSeenAt, client | unique tokenHash, TTL expiresAt, userId |
magic_links | email, tokenHash, expiresAt, usedAt? | unique tokenHash, TTL expiresAt |
Social graph
| Collection | Key fields | Indexes |
|---|---|---|
friendships | userA, userB (sorted so the pair is canonical), status, requestedBy | unique userA + userB, userB |
friend_invites | userId, code, expiresAt, usesLeft | unique code, TTL expiresAt |
groves | name, slug, area, description, memberCount | unique slug, area |
grove_members | groveId, userId, role | unique groveId + userId, userId |
organizations | name, slug, type, description, website?, verified, adminUserIds[] | unique slug |
Places and events
| Collection | Key fields | Indexes |
|---|---|---|
places | name, slug, type, veganLevel, location (GeoJSON Point), address, city, area, tags[], photoKeys[], approvalStatus, submittedBy?, source, osmId?, ratingAvg, reviewCount | unique slug, 2dsphere location, approvalStatus + type, sparse unique osmId |
place_reviews | placeId, userId, rating, content, visitedMonth, showHandle, status | placeId + createdAt, userId |
place_lists | userId, name, placeIds[], isPublic | userId |
events | title, slug, type, startsAt, endsAt, location, placeId?, venueName, address, detailsAfterRsvp, hostType, hostId, description, coverKey?, visibility, rsvpCount, status, createdBy | unique slug, 2dsphere location, startsAt + status, hostType + hostId |
event_rsvps | eventId, userId, status | unique eventId + userId, userId |
Feed
| Collection | Key fields | Indexes |
|---|---|---|
posts | userId, mediaType, imageKeys[], bunnyVideoId?, caption, visibility, groveId?, placeId?, eventId?, stats, status | userId + createdAt, visibility + createdAt, groveId + createdAt |
comments | postId, userId, parentId?, content, status | postId + createdAt |
reactions | postId, userId, type | unique postId + userId |
saved_posts | postId, userId | unique postId + userId |
reports | targetType, targetId, reportedBy, reason, status | status + createdAt |
Messages
| Collection | Key fields | Indexes |
|---|---|---|
conversations | participantIds[] (sorted), lastMessageAt, unread | unique participantIds, participantIds + lastMessageAt |
messages | conversationId, senderId, ciphertext, iv, keyId, type, expiresAt | conversationId + createdAt, TTL expiresAt |
Content and companion
| Collection | Key fields | Indexes |
|---|---|---|
media_items | title, slug, kind, year, synopsis, posterKey?, watchLinks[], trailerYoutubeId?, tags[], featured, status | unique slug, status + featured |
guides | title, slug, category, body, status | unique slug, category |
action_log | userId, type, eventId?, hours?, note?, occurredAt | userId + occurredAt |
companion_conversations | userId, messages[], pinned, expiresAt? | userId, TTL expiresAt |
Notifications
| Collection | Key fields | Indexes |
|---|---|---|
push_tokens | userId, token, platform, deadAt? | unique token, TTL deadAt |
notification_preferences | userId, eventReminders, friendRequests, messages, quietHours? | unique userId |
scheduled_notifications | userId, kind, payload, scheduledFor, status, idempotencyKey | unique idempotencyKey, scheduledFor + status |
Conventions
- Derived counters (
memberCount,rsvpCount,ratingAvg,reviewCount,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. Every other deletion is hard. - Geo queries are bounding-box
$geoWithinon the 2dsphere index. There is no$nearquery anywhere, because$nearneeds a point, and the client never sends one.