Skip to content

Database Schema Documentation

Status: Current Last Updated: 2026-01-01 Database: SQLite (development) / PostgreSQL (production) ORM: Prisma 6.16.1

Complete reference for the SocleStack database schema, including all models, relationships, indexes, and usage patterns.


Table of Contents


Overview

Schema Statistics

MetricCount
Total Models10
Total Enums3
Total Relations11
Total Indexes14
Cascade Deletes7

Database Providers

  • Development: SQLite (file-based, zero-config)
  • Production: PostgreSQL (recommended) or SQLite

Key Features

  • Multi-tenancy: Organization-based data isolation
  • Audit Trail: Comprehensive security event logging
  • Two-Factor Auth: TOTP + backup codes
  • OAuth Integration: Google, GitHub providers
  • Session Management: Multiple concurrent sessions + "Remember Me"
  • API Keys: Token-based API authentication
  • Password Security: History tracking, reset tokens
  • Account Security: Lockout, failed login tracking

Entity Relationship Diagram


Models

User

The central user model with authentication, authorization, and profile information.

Table: users

Fields

FieldTypeRequiredDefaultDescription
idStringYescuid()Primary key
emailStringYes-Unique email address
usernameStringNo-Optional unique username
passwordStringNo-Hashed password (null for OAuth-only users)
firstNameStringNo-User's first name
lastNameStringNo-User's last name
roleRoleYesUSERSystem-wide role (USER, ADMIN, MODERATOR)
isActiveBooleanYestrueAccount active status
emailVerifiedBooleanYesfalseEmail verification status
emailVerifiedAtDateTimeNo-When email was verified
lastLoginAtDateTimeNo-Last successful login
passwordResetTokenStringNo-Password reset token (hashed)
passwordResetExpiresDateTimeNo-Reset token expiration
passwordChangedAtDateTimeNo-Last password change
createdAtDateTimeYesnow()Account creation timestamp
updatedAtDateTimeYesAutoLast update timestamp

Account Lockout Fields

FieldTypeRequiredDefaultDescription
failedLoginAttemptsIntYes0Counter for failed logins
lockedUntilDateTimeNo-Account locked until this time

Lockout Logic: After 5 failed attempts, account locked for 15 minutes.

Two-Factor Authentication Fields

FieldTypeRequiredDefaultDescription
twoFactorSecretStringNo-TOTP secret (base32 encoded)
twoFactorEnabledBooleanYesfalse2FA enabled status
twoFactorVerifiedBooleanYesfalse2FA setup verification status

Notification Preferences

FieldTypeRequiredDefaultDescription
notifyNewDeviceBooleanYestrueEmail alert for new device login
notifyPasswordChangeBooleanYestrueEmail alert for password change
notifyLoginAlertBooleanYestrueEmail alert for suspicious login
notify2FAChangeBooleanYestrueEmail alert for 2FA changes

Organization Fields

FieldTypeRequiredDefaultDescription
organizationIdStringNo-FK to Organization
organizationRoleOrganizationRoleYesMEMBERRole within organization

Relations

  • organization: Belongs to one Organization (optional)
  • sessions: Has many UserSessions
  • passwordHistory: Has many PasswordHistory records
  • auditLogs: Has many AuditLogs
  • rememberMeTokens: Has many RememberMeTokens
  • backupCodes: Has many BackupCodes
  • sentInvites: Has many OrganizationInvites (as inviter)
  • oauthAccounts: Has many OAuthAccounts
  • apiKeys: Has many ApiKeys

Indexes

  • email (unique)
  • username (unique)
  • organizationId (for filtering by organization)

Usage Example

typescript
// Create new user
const user = await prisma.user.create({
  data: {
    email: 'user@example.com',
    password: await hashPassword('SecurePassword123!'),
    firstName: 'John',
    lastName: 'Doe',
    role: 'USER',
  },
});

// Get user with all relations
const userWithRelations = await prisma.user.findUnique({
  where: { id: userId },
  include: {
    organization: true,
    sessions: { where: { isActive: true } },
    auditLogs: { take: 10, orderBy: { createdAt: 'desc' } },
    oauthAccounts: true,
  },
});

// Update notification preferences
await prisma.user.update({
  where: { id: userId },
  data: {
    notifyNewDevice: false,
    notifyLoginAlert: true,
  },
});

UserSession

Tracks active user sessions with device and location information.

Table: user_sessions

Fields

FieldTypeRequiredDefaultDescription
idStringYescuid()Primary key
userIdStringYes-FK to User
tokenHashStringYes-Hashed session token
expiresAtDateTimeYes-Session expiration
createdAtDateTimeYesnow()Session creation
ipAddressStringNo-Client IP address
userAgentStringNo-Client user agent
isActiveBooleanYestrueSession active status

Relations

  • user: Belongs to one User (cascade delete)

Usage Example

typescript
// Create session
const session = await prisma.userSession.create({
  data: {
    userId: user.id,
    tokenHash: await hashSessionToken(token),
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
    ipAddress: req.ip,
    userAgent: req.headers['user-agent'],
  },
});

// Get active sessions for user
const activeSessions = await prisma.userSession.findMany({
  where: {
    userId: user.id,
    isActive: true,
    expiresAt: { gt: new Date() },
  },
  orderBy: { createdAt: 'desc' },
});

// Revoke session
await prisma.userSession.update({
  where: { id: sessionId },
  data: { isActive: false },
});

PasswordHistory

Tracks password changes to prevent password reuse.

Table: password_history

Fields

FieldTypeRequiredDefaultDescription
idStringYescuid()Primary key
userIdStringYes-FK to User
passwordStringYes-Hashed password
createdAtDateTimeYesnow()When password was set

Relations

  • user: Belongs to one User (cascade delete)

Usage Example

typescript
// Store password in history
await prisma.passwordHistory.create({
  data: {
    userId: user.id,
    password: hashedPassword,
  },
});

// Check if password was used recently (prevent reuse)
const recentPasswords = await prisma.passwordHistory.findMany({
  where: { userId: user.id },
  orderBy: { createdAt: 'desc' },
  take: 5, // Check last 5 passwords
});

const isPasswordReused = await Promise.all(
  recentPasswords.map(async (ph) => await verifyPassword(newPassword, ph.password))
).then((results) => results.some((match) => match));

AuditLog

Comprehensive security event logging for compliance and forensics.

Table: audit_logs

Fields

FieldTypeRequiredDefaultDescription
idStringYescuid()Primary key
userIdStringNo-FK to User (null for system events)
actionStringYes-Action performed (e.g., "login", "password_change")
categoryStringYes-Category (e.g., "auth", "user", "admin")
ipAddressStringNo-Client IP address
userAgentStringNo-Client user agent
metadataStringNo-JSON string with additional data
createdAtDateTimeYesnow()When event occurred

Relations

  • user: Belongs to one User (optional, nullable)

Indexes

  • userId (for user-specific logs)
  • action (for filtering by action type)
  • createdAt (for time-based queries)

Common Actions

ActionCategoryDescription
loginauthUser logged in
logoutauthUser logged out
login_failedauthFailed login attempt
password_changeauthPassword changed
account_lockedsecurityAccount locked due to failed attempts
account_unlockedsecurityAccount unlocked
2fa_enabledsecurity2FA enabled
2fa_disabledsecurity2FA disabled
role_changedadminUser role changed
user_createduserNew user created
user_deleteduserUser deleted

Usage Example

typescript
// Log successful login
await prisma.auditLog.create({
  data: {
    userId: user.id,
    action: 'login',
    category: 'auth',
    ipAddress: req.ip,
    userAgent: req.headers['user-agent'],
    metadata: JSON.stringify({
      method: '2fa',
      newDevice: true,
    }),
  },
});

// Query audit logs for user
const userAuditLogs = await prisma.auditLog.findMany({
  where: {
    userId: user.id,
    category: 'security',
  },
  orderBy: { createdAt: 'desc' },
  take: 50,
});

// Export audit logs (admin)
const exportLogs = await prisma.auditLog.findMany({
  where: {
    createdAt: {
      gte: startDate,
      lte: endDate,
    },
  },
  include: { user: { select: { email: true } } },
});

RememberMeToken

Implements "Remember Me" functionality with advanced token theft detection.

Table: remember_me_tokens

Fields

FieldTypeRequiredDefaultDescription
idStringYescuid()Primary key
userIdStringYes-FK to User
tokenHashStringYes-Hashed token value
seriesStringYes-Token series identifier (unique)
expiresAtDateTimeYes-Token expiration (30 days)
ipAddressStringNo-Client IP address
userAgentStringNo-Client user agent
lastUsedAtDateTimeYesnow()Last time token was used
createdAtDateTimeYesnow()Token creation

Relations

  • user: Belongs to one User (cascade delete)

Indexes

  • userId (for user's tokens)
  • series (unique, for token lookup)
  • expiresAt (for cleanup)

Token Theft Detection

The series field enables token theft detection:

  1. Each "Remember Me" creates a unique series
  2. Token is rotated on each use (new tokenHash, same series)
  3. If an old token is reused → token theft detected → all series revoked

Usage Example

typescript
// Create Remember Me token
const series = crypto.randomUUID();
const token = crypto.randomBytes(32).toString('base64');

await prisma.rememberMeToken.create({
  data: {
    userId: user.id,
    series,
    tokenHash: await hashToken(token),
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
    ipAddress: req.ip,
    userAgent: req.headers['user-agent'],
  },
});

// Rotate token on use
await prisma.rememberMeToken.update({
  where: { series },
  data: {
    tokenHash: await hashToken(newToken),
    lastUsedAt: new Date(),
  },
});

// Revoke all user's Remember Me tokens
await prisma.rememberMeToken.deleteMany({
  where: { userId: user.id },
});

BackupCode

Stores hashed 2FA backup codes for account recovery.

Table: backup_codes

Fields

FieldTypeRequiredDefaultDescription
idStringYescuid()Primary key
userIdStringYes-FK to User
codeHashStringYes-Hashed backup code
usedAtDateTimeNo-When code was used (null if unused)
createdAtDateTimeYesnow()Code generation timestamp

Relations

  • user: Belongs to one User (cascade delete)

Indexes

  • userId (for user's codes)

Usage Example

typescript
// Generate 10 backup codes
const codes = Array.from({ length: 10 }, () =>
  crypto.randomBytes(4).toString('hex')
);

await prisma.backupCode.createMany({
  data: codes.map((code) => ({
    userId: user.id,
    codeHash: hashCode(code),
  })),
});

// Use a backup code
const unusedCodes = await prisma.backupCode.findMany({
  where: {
    userId: user.id,
    usedAt: null,
  },
});

// Mark code as used
await prisma.backupCode.update({
  where: { id: matchedCode.id },
  data: { usedAt: new Date() },
});

// Count remaining backup codes
const remaining = await prisma.backupCode.count({
  where: {
    userId: user.id,
    usedAt: null,
  },
});

Organization

Multi-tenant organization model for logical data isolation.

Table: organizations

Fields

FieldTypeRequiredDefaultDescription
idStringYescuid()Primary key
nameStringYes-Organization name
slugStringYes-URL-safe unique identifier
createdAtDateTimeYesnow()Creation timestamp
updatedAtDateTimeYesAutoLast update timestamp

Relations

  • users: Has many Users
  • invites: Has many OrganizationInvites

Indexes

  • slug (unique)

Usage Example

typescript
// Create organization
const org = await prisma.organization.create({
  data: {
    name: 'Acme Corp',
    slug: 'acme-corp',
  },
});

// Get organization with members
const orgWithMembers = await prisma.organization.findUnique({
  where: { slug: 'acme-corp' },
  include: {
    users: {
      select: {
        id: true,
        email: true,
        organizationRole: true,
      },
    },
  },
});

// Check if slug is available
const exists = await prisma.organization.findUnique({
  where: { slug: 'acme-corp' },
});

OrganizationInvite

Manages organization membership invitations.

Table: organization_invites

Fields

FieldTypeRequiredDefaultDescription
idStringYescuid()Primary key
emailStringYes-Invitee email address
roleOrganizationRoleYesMEMBERRole to assign upon acceptance
tokenStringYes-Unique invitation token
expiresAtDateTimeYes-Invitation expiration (7 days)
organizationIdStringYes-FK to Organization
invitedByIdStringYes-FK to User (inviter)
createdAtDateTimeYesnow()Invite creation

Relations

  • organization: Belongs to one Organization (cascade delete)
  • invitedBy: Belongs to one User (the inviter)

Indexes

  • email (for lookup)
  • token (unique, for acceptance)
  • organizationId (for organization's invites)

Usage Example

typescript
// Create invitation
const invite = await prisma.organizationInvite.create({
  data: {
    email: 'newmember@example.com',
    role: 'MEMBER',
    token: crypto.randomUUID(),
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
    organizationId: org.id,
    invitedById: currentUser.id,
  },
});

// Accept invitation
await prisma.$transaction(async (tx) => {
  // Add user to organization
  await tx.user.update({
    where: { id: user.id },
    data: {
      organizationId: invite.organizationId,
      organizationRole: invite.role,
    },
  });

  // Delete invitation
  await tx.organizationInvite.delete({
    where: { id: invite.id },
  });
});

// Cleanup expired invites
await prisma.organizationInvite.deleteMany({
  where: {
    expiresAt: { lt: new Date() },
  },
});

OAuthAccount

Links user accounts to OAuth providers (Google, GitHub).

Table: oauth_accounts

Fields

FieldTypeRequiredDefaultDescription
idStringYescuid()Primary key
userIdStringYes-FK to User
providerStringYes-Provider name ("google", "github")
providerAccountIdStringYes-User ID from provider
emailStringNo-Email from OAuth provider
accessTokenStringNo-OAuth access token
refreshTokenStringNo-OAuth refresh token
tokenExpiresAtDateTimeNo-Access token expiration
createdAtDateTimeYesnow()Account link timestamp
updatedAtDateTimeYesAutoLast update

Relations

  • user: Belongs to one User (cascade delete)

Indexes

  • userId (for user's OAuth accounts)
  • [provider, providerAccountId] (unique composite)

Usage Example

typescript
// Link OAuth account
const oauthAccount = await prisma.oAuthAccount.create({
  data: {
    userId: user.id,
    provider: 'google',
    providerAccountId: googleUser.id,
    email: googleUser.email,
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token,
    tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
  },
});

// Find user by OAuth provider
const user = await prisma.user.findFirst({
  where: {
    oauthAccounts: {
      some: {
        provider: 'google',
        providerAccountId: googleUserId,
      },
    },
  },
});

// Unlink OAuth account
await prisma.oAuthAccount.delete({
  where: {
    provider_providerAccountId: {
      provider: 'google',
      providerAccountId: googleUserId,
    },
  },
});

ApiKey

Token-based API authentication for programmatic access.

Table: api_keys

Fields

FieldTypeRequiredDefaultDescription
idStringYescuid()Primary key
userIdStringYes-FK to User (key owner)
nameStringYes-User-friendly label
keyHashStringYes-SHA-256 hash of API key
keyPrefixStringYes-First 8 chars (for identification)
permissionApiKeyPermissionYesREAD_ONLYAccess level
expiresAtDateTimeNo-Optional expiration
lastUsedAtDateTimeNo-Last usage timestamp
createdAtDateTimeYesnow()Key creation
revokedAtDateTimeNo-Soft delete timestamp

Relations

  • user: Belongs to one User (cascade delete)

Indexes

  • userId (for user's keys)
  • keyHash (for authentication lookup)

Usage Example

typescript
// Generate API key
const apiKey = `sk_${crypto.randomBytes(32).toString('base64url')}`;
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');

await prisma.apiKey.create({
  data: {
    userId: user.id,
    name: 'Production API Key',
    keyHash,
    keyPrefix: apiKey.substring(0, 8),
    permission: 'READ_WRITE',
    expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
  },
});

// Authenticate with API key
const keyHash = crypto.createHash('sha256').update(providedKey).digest('hex');

const apiKey = await prisma.apiKey.findFirst({
  where: {
    keyHash,
    revokedAt: null,
    OR: [
      { expiresAt: null },
      { expiresAt: { gt: new Date() } },
    ],
  },
  include: { user: true },
});

// Revoke API key (soft delete)
await prisma.apiKey.update({
  where: { id: keyId },
  data: { revokedAt: new Date() },
});

Enums

Role

System-wide user roles for global permissions.

prisma
enum Role {
  USER       // Standard user
  ADMIN      // Full system access
  MODERATOR  // Content moderation
}

Permissions:

  • USER: Basic app access
  • MODERATOR: User management, content moderation
  • ADMIN: Full system access, user impersonation, audit logs

OrganizationRole

Organization-specific roles for multi-tenant access control.

prisma
enum OrganizationRole {
  OWNER   // Full control, can delete organization
  ADMIN   // Manage users, settings
  MEMBER  // Basic access
}

Permissions:

  • MEMBER: View organization data
  • ADMIN: Invite members, change settings
  • OWNER: Delete organization, manage admins

ApiKeyPermission

API key access levels.

prisma
enum ApiKeyPermission {
  READ_ONLY   // GET requests only
  READ_WRITE  // Full CRUD access
}

Relationships

One-to-Many

ParentChildDelete Behavior
UserUserSessionCASCADE
UserPasswordHistoryCASCADE
UserAuditLogSET NULL
UserRememberMeTokenCASCADE
UserBackupCodeCASCADE
UserOrganizationInviteRESTRICT
UserOAuthAccountCASCADE
UserApiKeyCASCADE
OrganizationUserSET NULL
OrganizationOrganizationInviteCASCADE

Foreign Keys

All foreign keys use @relation with appropriate onDelete behavior:

  • Cascade: Child deleted when parent deleted
  • SetNull: Child's FK set to null when parent deleted
  • Restrict: Prevents parent deletion if children exist

Indexes

Performance Indexes

TableIndexPurpose
usersemailLogin lookup
usersusernameUsername lookup
usersorganizationIdFilter by organization
audit_logsuserIdUser activity logs
audit_logsactionFilter by action type
audit_logscreatedAtTime-based queries
remember_me_tokensuserIdUser's tokens
remember_me_tokensseriesToken lookup
remember_me_tokensexpiresAtCleanup expired tokens
backup_codesuserIdUser's codes
organization_invitesemailInvite lookup
organization_invitesorganizationIdOrg's invites
oauth_accountsuserIdUser's OAuth accounts
api_keysuserIdUser's API keys
api_keyskeyHashAPI key authentication

Unique Constraints

TableField(s)Purpose
usersemailPrevent duplicate emails
usersusernamePrevent duplicate usernames
organizationsslugUnique organization URLs
remember_me_tokensseriesToken series uniqueness
organization_invitestokenUnique invite tokens
oauth_accounts[provider, providerAccountId]One OAuth account per provider per user

Data Integrity

Validation Rules

  1. Email addresses: Must be unique, valid format
  2. Passwords: Hashed with bcrypt (12 rounds), nullable for OAuth users
  3. Tokens: Hashed before storage, never stored in plain text
  4. Dates: Use UTC timestamps, validated for logical order
  5. Enums: Validated against allowed values

Cascade Deletes

When a User is deleted:

  • ✅ All UserSessions deleted
  • ✅ All PasswordHistory deleted
  • ✅ All RememberMeTokens deleted
  • ✅ All BackupCodes deleted
  • ✅ All OAuthAccounts deleted
  • ✅ All ApiKeys deleted
  • ⚠️ AuditLogs preserved (userId set to null)
  • ⚠️ OrganizationInvites preserved (invitedById NOT nullable)

When an Organization is deleted:

  • ✅ All OrganizationInvites deleted
  • ⚠️ Users have organizationId set to null

Soft Deletes

  • ApiKey: Uses revokedAt timestamp instead of hard delete
  • Allows audit trail and potential key restoration

Performance Considerations

Query Optimization

DO:

typescript
// Use indexes
await prisma.user.findUnique({ where: { email } }); // Uses email index

// Select only needed fields
await prisma.user.findMany({
  select: { id: true, email: true, role: true },
});

// Paginate large result sets
await prisma.auditLog.findMany({
  take: 50,
  skip: page * 50,
  orderBy: { createdAt: 'desc' },
});

DON'T:

typescript
// Avoid N+1 queries
for (const user of users) {
  const sessions = await prisma.userSession.findMany({ where: { userId: user.id } });
}

// Use include instead
const users = await prisma.user.findMany({
  include: { sessions: true },
});

Cleanup Jobs

Recommended periodic cleanup:

typescript
// Delete expired sessions
await prisma.userSession.deleteMany({
  where: { expiresAt: { lt: new Date() } },
});

// Delete expired Remember Me tokens
await prisma.rememberMeToken.deleteMany({
  where: { expiresAt: { lt: new Date() } },
});

// Delete expired invitations
await prisma.organizationInvite.deleteMany({
  where: { expiresAt: { lt: new Date() } },
});

// Delete old audit logs (retention: 90 days)
await prisma.auditLog.deleteMany({
  where: {
    createdAt: { lt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) },
  },
});

Migration Guide

See MIGRATIONS.md for detailed migration instructions.

Quick Reference:

bash
# Generate migration
npx prisma migrate dev --name add_api_keys

# Apply migrations (development)
npx prisma migrate dev

# Apply migrations (production)
npx prisma migrate deploy

# Reset database (development only)
npx prisma migrate reset

# View migration status
npx prisma migrate status

Common Queries

Authentication

typescript
// Find user by email for login
const user = await prisma.user.findUnique({
  where: { email },
  include: {
    organization: true,
    backupCodes: { where: { usedAt: null } },
  },
});

// Check if user is locked out
const isLocked = user.lockedUntil && user.lockedUntil > new Date();

// Increment failed login attempts
await prisma.user.update({
  where: { id: user.id },
  data: {
    failedLoginAttempts: { increment: 1 },
    ...(user.failedLoginAttempts + 1 >= 5 && {
      lockedUntil: new Date(Date.now() + 15 * 60 * 1000), // 15 min
    }),
  },
});

Organization Management

typescript
// Get all organization members with roles
const members = await prisma.user.findMany({
  where: { organizationId: orgId },
  select: {
    id: true,
    email: true,
    firstName: true,
    lastName: true,
    organizationRole: true,
    createdAt: true,
  },
  orderBy: { createdAt: 'desc' },
});

// Transfer organization ownership
await prisma.$transaction([
  prisma.user.update({
    where: { id: currentOwnerId },
    data: { organizationRole: 'ADMIN' },
  }),
  prisma.user.update({
    where: { id: newOwnerId },
    data: { organizationRole: 'OWNER' },
  }),
]);

Audit & Security

typescript
// Get user's recent security events
const securityEvents = await prisma.auditLog.findMany({
  where: {
    userId: user.id,
    category: 'security',
  },
  orderBy: { createdAt: 'desc' },
  take: 20,
});

// Suspicious login detection
const recentFailures = await prisma.auditLog.count({
  where: {
    userId: user.id,
    action: 'login_failed',
    createdAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }, // Last hour
  },
});


Changelog

  • 2026-01-01: Initial comprehensive database schema documentation
    • Documented all 10 models with complete field reference
    • Added ER diagram and relationship documentation
    • Included usage examples for common queries
    • Documented indexes and performance considerations
    • Added data integrity and validation rules