Skip to main content

Database Schema Overview

YLP-SAPOT (SAPOT Server) uses MariaDB as its database engine via SQLModel (an SQLAlchemy wrapper). The schema is applied by Alembic at deploy time (alembic upgrade head in server/runserver.sh), not created at application startup; create_db_and_tables() was removed from the FastAPI lifespan. See migrations.md for the workflow and ADR 0007 for the decision.


SyncableModel Base Class

Several messaging and call tables inherit from SyncableModel (defined in message.py). It adds three columns to every inheriting table:

ColumnTypeNotes
created_atBIGINT (ms epoch)Set once at insert
updated_atBIGINT (ms epoch, index)Auto-updated via onupdate trigger
is_deletedBOOLEAN (index)Soft-delete flag; default False

Timestamps are stored as milliseconds since Unix epoch (not ISO datetimes), which is the format WatermelonDB sync expects on the mobile client.

Tables that extend SyncableModel: conversation, conversationparticipant, message, messagereceipt, call, callparticipant.


Entity Groups

1. Users and Roles

The user table is the central identity record. Role membership is expressed through satellite tables that hold a foreign key back to user.id with a unique constraint — each user can hold at most one record per role table.

TableClassPurpose
userUserCore identity: credentials, contact info, verification flags
rescuerRescuerMarks a user as a rescue-role member
adminAdminMarks a user as an administrator
guestGuestMarks a user as a guest-tier member
banneduserBannedUserTracks bans with an expiry timestamp
userprofilepictureUserProfilePictureProfile photo filename; is_active allows historical retention
userlocationUserLocationGPS pings per user; one-to-many history

2. Authentication and Verification

Covers OTP flows, password reset, JWT blacklist, and login rate-limiting.

TableClassPurpose
email_verificationsEmailVerification6-digit email OTP with 10-minute expiry
phone_verificationPhoneVerification6-digit SMS OTP with attempt counter
phone_verifiedPhoneVerifiedPresence record indicating phone is confirmed
passwordresetcodePasswordResetCodeEmail-based password reset code
phone_password_reset_codePhonePasswordResetCodeSMS-based password reset code
email_recovery_tokenEmailRecoveryTokenEmail recovery link token (hashed)
passwordresettokenPasswordResetTokenGeneric password reset token (hashed)
blacklistedtokenBlacklistedTokenJWT JTI blacklist for logout invalidation
usersecurityquestionUserSecurityQuestionOne security question + hashed answer per user
login_attemptLoginAttemptPer-(user, device) login attempt and lockout tracking
recovery_attemptRecoveryAttemptPer-(user, device, method) recovery attempt tracking

3. Messaging

Conversations hold messages between participants, which carry attachments and are tracked per-recipient.

TableClassPurpose
conversationConversationChat channel; types: direct, solo, sms
conversationparticipantConversationParticipantJoin table linking users to conversations
messageMessageIndividual message
messagereceiptMessageReceiptPer-(message, user) delivery/read status
attachmentAttachmentFile attachment metadata for a message
queueQueueServer-side delivery queue for offline users

4. Calls

Voice and video calls are modelled as records on a conversation. Call participants track join/leave times.

TableClassPurpose
callCallCall record with type, status, and timing
callparticipantCallParticipantPer-user join/leave record for a call session

Note: callparticipant.call_id carries a foreign key to conversation.id, not call.id. This is how the code is written; it effectively identifies the conversation context of the call rather than the individual call record.

5. Keys and Encryption

ECDH-based E2E encryption for peer-to-peer channels. The server stores opaque encrypted blobs and public keys.

TableClassPurpose
peer_keyPeerKeyPer-user ECDH public key with expiry and server signature
contact_keyContactKeyEncrypted public keys for non-registered (guest) peers
device_keyDeviceKeyPublic key bound to a device fingerprint
wrapped_keyWrappedKeyUser's master key wrapped (encrypted) for server storage
wrapped_key_recoveryWrappedKeyRecoveryRecovery copies of the wrapped master key, one per method
recoverykeyRecoveryKeyHashed recovery key for account recovery
recovery_sessionRecoverySessionTime-limited recovery session token (hashed)

6. Activity and Admin

TableClassPurpose
user_activityUserActivityLatest online status and IP per user (one-to-one)
activity_logsActivityLogAppend-only audit log of mutating API actions
announcementAnnouncementAdmin-published announcements with audience targeting

7. Router and Network Metrics

Populated by a background thread (collect_metrics_loop) that polls the MikroTik router via its API.

TableClassPurpose
routerhealthRouterHealthCPU load, memory, and uptime snapshots
interfacetrafficInterfaceTrafficPer-interface Rx/Tx bandwidth snapshots

8. Captive Portal

Standalone table used by the MikroTik hotspot captive portal integration. It is not linked to the user table.

TableClassPurpose
guest_sessionsGuestSessionWalk-in guest login sessions from hotspot

9. Devices (dead code — no table is created)

server/app/models/devices.py declares a Device SQLModel with table=True, but it is never imported — not by app/models/__init__.py, not by any router. Because SQLModel only registers metadata for imported modules, no device table exists in SQLModel.metadata, so Alembic autogenerate never emitted one and it correctly does not appear in the generated tables.md. app/models/__init__.py carries a commented-out import for it with the reason: its id field lacks primary_key=True, so SQLAlchemy cannot map it at all.

Two further signs the model was abandoned mid-implementation: its id field has no primary_key=True, and its Relationship(back_populates="devices") points at a User.devices attribute that does not exist — so importing it as-is would likely fail to map.

Treat Device as dead code, not as schema. Per-device public keys are handled by the device_key table (DeviceKey, see group 7) instead.


Key Relationships

user 1──* rescuer (role badge)
user 1──1 admin (role badge)
user 1──1 guest (role badge)
user 1──* conversationparticipant ──* conversation
user 1──* message
user 1──* messagereceipt
user 1──* call (initiator)
user 1──* callparticipant
user 1──* userlocation
user 1──1 user_activity
user 1──* activity_logs
user 1──1 wrapped_key
user 1──* wrapped_key_recovery
user 1──1 peer_key
conversation 1──* message
conversation 1──* call
conversation 1──* callparticipant
message 1──1 attachment
message 1──1 messagereceipt

Mobile App Schema Overview

The mobile app (mobile-app/sapot-mobile-app/) uses WatermelonDB (SQLite-backed) as its on-device store, defined in features/shared/core/database/schema.ts — currently version 11. Unlike the server, mobile schema changes are applied via versioned migrations; see migrations.md.

The mobile schema is deliberately narrower than the server's: it holds only what's needed for local chat/call state, presence, and offline-first sync — not auth, admin, or router-metric tables (those stay server-side and are fetched over REST).

1. Local Identity

TablePurpose
guest_userLocal-only profile for the current guest (unauthenticated) user: first_name, last_name, username
peersEvery known peer (contact), plus the current authenticated user's own profile mirror. Holds presence (is_online, last_seen_at), verification flags (email_verified, phone_number_verified), role (added v9, mirrors server-side role) and is_guest (added v10)

2. Messaging

TablePurpose
conversationsLocal mirror of a chat channel (type, title)
conversation_participantsJoin table linking peers to conversations
messagesIndividual message; is_encrypted flag (added v9) marks NaCl-box-encrypted content; linked_message_id (added v8, paired a P2P message with its SMS duplicate) is retained unused since the dual-send UX was removed — see migrations.md
message_receiptsPer-(message, peer) delivery/read status

3. Calls

TablePurpose
callsLocal call record: call_type, status, start_time, end_time
call_participantsPer-peer join/leave record for a call session

Sync-tracked tables

messages, calls, call_participants, message_receipts, conversations, conversation_participants all carry created_at, updated_at, is_deleted columns and participate in the pull/push sync flow described in the mobile app sync documentation and data-flow.md. peers and guest_user are local-only and not synced to the server through this mechanism.