Skip to main content

Database Schema Documentation

Complete documentation of OneLibro's PostgreSQL database schema hosted on Supabase.

Table of Contents

Overview

OneLibro uses PostgreSQL via Supabase with the following design principles:

  • Row Level Security (RLS) for data isolation
  • Foreign key constraints for referential integrity
  • Indexes on frequently queried columns
  • Triggers for automated updates (timestamps, etc.)
  • Cents-based storage for monetary values (integers, not decimals)
  • UUID primary keys for all tables

Entity Relationship Diagram

data-model

┌─────────────────┐
│ invite_codes │
└────────┬────────┘

│ invited_by (FK)

┌────────▼────────┐ ┌──────────────┐
│ users │◄──────────│ admin_users │
└────────┬────────┘ created_by└──────┬───────┘
│ │
│ user_id (FK) ┌──────▼─────────┐
│ │ admin_sessions │
┌────────▼────────┐ └────────────────┘
│ plaid_items │ │
└────────┬────────┘ ┌───────▼────────┐
│ │admin_audit_logs│
│ plaid_item_id (FK) └────────────────┘

┌────────▼────────┐
│ accounts │
└────────┬────────┘

│ account_id (FK)

┌────────▼────────┐
│ transactions │
└─────────────────┘

│ user_id (FK)

┌────────▼────────┐
│ budgets │
└─────────────────┘

Tables

users

Stores user profiles and invite tracking information.

Table: users

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key (matches auth.users.id)
emailtextNO-User email (unique)
full_nametextYESNULLUser's full name
is_adminbooleanNOfalseAdmin flag
invite_codetextYESNULLInvite code used to sign up
invited_byuuidYESNULLUser ID who created the invite
invite_expires_attimestamptzYESNULLWhen invite code expires
last_login_attimestamptzYESNULLLast successful login
created_attimestamptzNOnow()Account creation timestamp
updated_attimestamptzNOnow()Last update timestamp

Indexes:

  • Primary key: id
  • Unique: email
  • Index: invite_code

RLS Policies:

-- Users can view and update their own profile
CREATE POLICY "Users can view own profile"
ON users FOR SELECT
USING (auth.uid() = id);

CREATE POLICY "Users can update own profile"
ON users FOR UPDATE
USING (auth.uid() = id);

invite_codes

Manages invitation codes for user signup.

Table: invite_codes

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
codetextNO-Unique invite code
created_byuuidNO-Admin user who created it
max_usesintegerNO1Maximum number of uses
used_countintegerNO0Current usage count
expires_attimestamptzNO-Expiration date/time
is_activebooleanNOtrueActive status
created_attimestamptzNOnow()Creation timestamp

Indexes:

  • Primary key: id
  • Unique: code

Constraints:

  • used_count <= max_uses
  • expires_at > created_at

Example:

INSERT INTO invite_codes (code, created_by, max_uses, expires_at)
VALUES ('WELCOME2024', 'admin-uuid', 100, '2024-12-31 23:59:59');

plaid_items

Stores connected bank institutions and encrypted Plaid access tokens.

Table: plaid_items

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
user_iduuidNO-Foreign key to users
plaid_item_idtextNO-Plaid's item ID
access_tokentextNO-Encrypted Plaid access token
institution_idtextYESNULLPlaid institution ID
institution_nametextYESNULLBank name (e.g., "Chase")
statustextNO'active'Connection status
cursortextYESNULLTransaction sync cursor
last_synced_attimestamptzYESNULLLast sync timestamp
created_attimestamptzNOnow()Creation timestamp
updated_attimestamptzNOnow()Last update timestamp

Indexes:

  • Primary key: id
  • Foreign key: user_idusers(id) ON DELETE CASCADE
  • Unique: plaid_item_id

RLS Policies:

CREATE POLICY "Users can view own plaid items"
ON plaid_items FOR SELECT
USING (auth.uid() = user_id);

Important Notes:

  • access_token is encrypted using AES-256-CBC before storage
  • cursor is used for incremental transaction sync
  • status values: 'active', 'login_required', 'error'

accounts

Stores bank accounts (from Plaid) and manual cash accounts.

Table: accounts

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
user_iduuidNO-Foreign key to users
plaid_item_iduuidYESNULLForeign key to plaid_items (NULL for cash accounts)
plaid_account_idtextYESNULLPlaid's account ID
account_nametextNO-Account name
account_typetextNO-Type (checking, savings, credit, cash)
current_balanceintegerNO0Balance in cents
available_balanceintegerYESNULLAvailable balance in cents
currency_codetextNO'USD'Currency (ISO 4217)
is_hiddenbooleanNOfalseHidden from UI
masktextYESNULLLast 4 digits (e.g., "1234")
created_attimestamptzNOnow()Creation timestamp
updated_attimestamptzNOnow()Last update timestamp

Indexes:

  • Primary key: id
  • Foreign key: user_idusers(id) ON DELETE CASCADE
  • Foreign key: plaid_item_idplaid_items(id) ON DELETE CASCADE
  • Index: (user_id, is_hidden) for efficient filtering

RLS Policies:

CREATE POLICY "Users can manage own accounts"
ON accounts FOR ALL
USING (auth.uid() = user_id);

Important Notes:

  • All monetary values stored in cents (integer, not decimal)
  • Cash accounts have plaid_item_id = NULL
  • account_type values: 'checking', 'savings', 'credit', 'loan', 'cash'

Example:

-- Manual cash account
INSERT INTO accounts (user_id, account_name, account_type, current_balance)
VALUES ('user-uuid', 'Wallet Cash', 'cash', 15000); -- $150.00

-- Plaid-linked checking account
INSERT INTO accounts (user_id, plaid_item_id, plaid_account_id, account_name, account_type, current_balance, mask)
VALUES ('user-uuid', 'item-uuid', 'plaid-acct-id', 'Chase Checking', 'checking', 252341, '4532');

transactions

Stores all financial transactions (from Plaid or manually added).

Table: transactions

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
user_iduuidNO-Foreign key to users
account_iduuidNO-Foreign key to accounts
plaid_transaction_idtextYESNULLPlaid's transaction ID (NULL for manual)
transaction_datedateNO-Transaction date
amountintegerNO-Amount in cents (positive = debit, negative = credit)
merchant_nametextYESNULLMerchant/payee name
categorytextYESNULLTransaction category
subcategorytextYESNULLTransaction subcategory
descriptiontextYESNULLTransaction description
is_pendingbooleanNOfalsePending status
is_hiddenbooleanNOfalseHidden from UI
created_attimestamptzNOnow()Creation timestamp
updated_attimestamptzNOnow()Last update timestamp

Indexes:

  • Primary key: id
  • Foreign key: user_idusers(id) ON DELETE CASCADE
  • Foreign key: account_idaccounts(id) ON DELETE CASCADE
  • Composite index: (user_id, transaction_date DESC) for fast queries
  • Unique: plaid_transaction_id (for Plaid transactions)

RLS Policies:

CREATE POLICY "Users can manage own transactions"
ON transactions FOR ALL
USING (auth.uid() = user_id);

Important Notes:

  • Amount in cents: $19.99 stored as 1999
  • Positive = Debit (money out), Negative = Credit (money in)
  • Categories from Plaid (or custom for manual transactions)

Example:

-- Grocery purchase
INSERT INTO transactions (user_id, account_id, transaction_date, amount, merchant_name, category)
VALUES ('user-uuid', 'account-uuid', '2024-12-01', 4567, 'Whole Foods', 'Food and Drink');
-- Amount: $45.67

-- Paycheck (credit)
INSERT INTO transactions (user_id, account_id, transaction_date, amount, merchant_name, category)
VALUES ('user-uuid', 'account-uuid', '2024-12-01', -250000, 'Employer Inc', 'Income');
-- Amount: -$2,500.00 (negative = credit)

budgets

User-defined spending budgets by category.

Table: budgets

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
user_iduuidNO-Foreign key to users
nametextNO-Budget name
categorytextNO-Transaction category to track
amountintegerNO-Budget limit in cents
spent_amountintegerNO0Current spending in cents
periodtextNO-Period (weekly, monthly, yearly)
start_datedateYESNULLBudget start date
end_datedateYESNULLBudget end date
is_activebooleanNOtrueActive status
created_attimestamptzNOnow()Creation timestamp
updated_attimestamptzNOnow()Last update timestamp

Indexes:

  • Primary key: id
  • Foreign key: user_idusers(id) ON DELETE CASCADE
  • Index: (user_id, is_active)

RLS Policies:

CREATE POLICY "Users can manage own budgets"
ON budgets FOR ALL
USING (auth.uid() = user_id);

Important Notes:

  • period values: 'weekly', 'monthly', 'yearly'
  • spent_amount is calculated automatically from transactions

Example:

-- Monthly grocery budget: $500
INSERT INTO budgets (user_id, name, category, amount, period)
VALUES ('user-uuid', 'Groceries', 'Food and Drink', 50000, 'monthly');

admin_users

Separate admin authentication system with 2FA support.

Table: admin_users

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
emailtextNO-Admin email (unique)
password_hashtextNO-bcrypt password hash
full_nametextYESNULLAdmin's full name
totp_secrettextYESNULLEncrypted TOTP secret
totp_enabledbooleanNOfalseTOTP enabled flag
totp_verifiedbooleanNOfalseTOTP verified flag
failed_login_attemptsintegerNO0Failed login counter
locked_untiltimestamptzYESNULLAccount lock expiration
last_login_attimestamptzYESNULLLast successful login
last_login_iptextYESNULLLast login IP address
is_activebooleanNOtrueActive status
created_attimestamptzNOnow()Creation timestamp
updated_attimestamptzNOnow()Last update timestamp

Indexes:

  • Primary key: id
  • Unique: email

Important Notes:

  • Passwords hashed with bcrypt (cost factor: 12)
  • TOTP secrets encrypted with AES-256-CBC
  • Account locks for 15 minutes after 5 failed attempts

admin_sessions

Admin session management (separate from Supabase Auth).

Table: admin_sessions

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
admin_user_iduuidNO-Foreign key to admin_users
tokentextNO-Session token (64-char hex)
expires_attimestamptzNO-Session expiration (8 hours)
ip_addresstextYESNULLClient IP address
user_agenttextYESNULLClient user agent
created_attimestamptzNOnow()Creation timestamp

Indexes:

  • Primary key: id
  • Foreign key: admin_user_idadmin_users(id) ON DELETE CASCADE
  • Unique: token

Important Notes:

  • Sessions expire after 8 hours
  • Token is 64-character hex string (crypto.randomBytes(32))

admin_audit_logs

Audit trail for admin actions.

Table: admin_audit_logs

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
admin_user_iduuidNO-Foreign key to admin_users
actiontextNO-Action type (login, create_invite, etc.)
resource_typetextYESNULLResource type (user, invite_code, etc.)
resource_idtextYESNULLResource ID
detailsjsonbYESNULLAdditional details
ip_addresstextYESNULLClient IP
user_agenttextYESNULLClient user agent
created_attimestamptzNOnow()Timestamp

Indexes:

  • Primary key: id
  • Foreign key: admin_user_idadmin_users(id) ON DELETE CASCADE
  • Index: (admin_user_id, created_at DESC)
  • Index: (action, created_at DESC)

Example:

INSERT INTO admin_audit_logs (admin_user_id, action, resource_type, resource_id, details)
VALUES (
'admin-uuid',
'create_invite',
'invite_code',
'invite-uuid',
'{"code": "WELCOME2024", "max_uses": 100}'::jsonb
);

notification_preferences

User email and notification preferences.

Table: notification_preferences

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
user_iduuidNO-Foreign key to users (unique)
email_enabledbooleanNOtrueMaster email toggle
marketing_emailsbooleanNOfalseMarketing email opt-in
budget_alertsbooleanNOtrueBudget alert notifications
transaction_alertsbooleanNOtrueTransaction notifications
weekly_summarybooleanNOtrueWeekly summary emails
account_securitybooleanNOtrueSecurity alert emails
budget_alert_thresholdintegerNO90Budget alert % (50-100)
large_transaction_thresholdintegerNO50000Large transaction amount in cents ($500)
alert_frequencytextNO'immediate''immediate', 'daily_digest', 'weekly_digest'
created_attimestamptzNOnow()Creation timestamp
updated_attimestamptzNOnow()Last update timestamp

Indexes:

  • Primary key: id
  • Foreign key: user_idusers(id) ON DELETE CASCADE
  • Unique: user_id

RLS Policies:

CREATE POLICY "Users can manage own notification preferences"
ON notification_preferences FOR ALL
USING (auth.uid() = user_id);

Important Notes:

  • budget_alert_threshold: Percentage (50-100) when to send budget alerts
  • large_transaction_threshold: Amount in cents for transaction alerts
  • alert_frequency: Controls batching of non-critical alerts

email_templates

Pre-built email templates using React Email.

Table: email_templates

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
template_keytextNO-Unique template identifier
template_nametextNO-Human-readable name
categorytextNO-'transactional', 'marketing', 'notification', 'system'
subject_templatetextNO-Email subject (supports variables)
template_typetextYES'react''react' or 'html'
template_pathtextYESNULLPath to React component
html_contenttextYESNULLRaw HTML (if not React)
variablesjsonbYESNULLTemplate variable schema
is_activebooleanNOtrueActive status
versionintegerNO1Template version
created_byuuidYESNULLForeign key to admin_users
created_attimestamptzNOnow()Creation timestamp
updated_attimestamptzNOnow()Last update timestamp
last_used_attimestamptzYESNULLLast time template sent
total_sentintegerNO0Total emails sent with template

Indexes:

  • Primary key: id
  • Unique: template_key
  • Index: (category, is_active)

Example:

INSERT INTO email_templates (template_key, template_name, category, subject_template, template_path)
VALUES (
'welcome_email',
'Welcome Email',
'transactional',
'Welcome to OneLibro, {{name}}!',
'emails/templates/WelcomeEmail.tsx'
);

email_logs

Audit trail of all sent emails with delivery tracking.

Table: email_logs

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
user_iduuidYESNULLForeign key to users (NULL for non-user emails)
recipient_emailtextNO-Recipient email address
template_keytextYESNULLTemplate used (if any)
subjecttextNO-Email subject line
categorytextNO-'transactional', 'marketing', 'notification', 'system'
resend_email_idtextYESNULLResend email ID for tracking
statustextNO'pending''pending', 'sent', 'delivered', 'bounced', 'failed', 'complained'
error_messagetextYESNULLError details if failed
sent_attimestamptzYESNULLWhen email sent to Resend
delivered_attimestamptzYESNULLWhen email delivered
opened_attimestamptzYESNULLWhen email opened (if tracked)
clicked_attimestamptzYESNULLWhen link clicked (if tracked)
bounced_attimestamptzYESNULLWhen email bounced
metadatajsonbYESNULLAdditional metadata
created_attimestamptzNOnow()Creation timestamp

Indexes:

  • Primary key: id
  • Foreign key: user_idusers(id) ON DELETE SET NULL
  • Index: (user_id, created_at DESC)
  • Index: (template_key, created_at DESC)
  • Index: (status, created_at DESC)
  • Index: resend_email_id

Status Flow:

pending → sent → delivered
↘ bounced
↘ failed

email_campaigns

Bulk email campaigns for marketing and announcements.

Table: email_campaigns

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
nametextNO-Campaign name (internal)
subjecttextNO-Email subject line
template_keytextYESNULLForeign key to email_templates
target_audiencejsonbYESNULLAudience filter criteria
statustextNO'draft''draft', 'scheduled', 'sending', 'sent', 'cancelled'
scheduled_attimestamptzYESNULLWhen to send (if scheduled)
sent_attimestamptzYESNULLWhen campaign sent
total_recipientsintegerYESNULLTotal target recipients
total_sentintegerNO0Emails successfully sent
total_deliveredintegerNO0Emails delivered
total_openedintegerNO0Emails opened
total_clickedintegerNO0Emails with clicks
total_bouncedintegerNO0Emails bounced
created_byuuidYESNULLForeign key to admin_users
created_attimestamptzNOnow()Creation timestamp
updated_attimestamptzNOnow()Last update timestamp

Indexes:

  • Primary key: id
  • Foreign key: template_keyemail_templates(template_key)
  • Foreign key: created_byadmin_users(id) ON DELETE SET NULL
  • Index: (status, created_at DESC)
  • Index: (created_by, created_at DESC)

target_audience JSON Schema:

{
"active_only": true,
"signup_after": "2025-01-01",
"signup_before": "2025-01-31"
}

Status Lifecycle:

draft → scheduled → sending → sent
↘ cancelled

budget_alert_history

Tracks sent budget alerts to prevent duplicates.

Table: budget_alert_history

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
user_iduuidNO-Foreign key to users
budget_iduuidNO-Foreign key to budgets
threshold_percentageintegerNO-Threshold % that triggered alert
spent_amountintegerNO-Amount spent in cents
budget_amountintegerNO-Budget limit in cents
periodtextNO-Budget period (e.g., "2025-01")
email_log_iduuidYESNULLForeign key to email_logs
alerted_attimestamptzNOnow()When alert sent

Indexes:

  • Primary key: id
  • Foreign key: user_idusers(id) ON DELETE CASCADE
  • Foreign key: budget_idbudgets(id) ON DELETE CASCADE
  • Foreign key: email_log_idemail_logs(id) ON DELETE SET NULL
  • Index: (budget_id, period, threshold_percentage) UNIQUE

Important Notes:

  • Prevents duplicate alerts for same budget/period/threshold
  • period format: "YYYY-MM" for monthly budgets, "YYYY-WW" for weekly

invite_code_requests

Users requesting invite codes (public form submissions).

Table: invite_code_requests

ColumnTypeNullableDefaultDescription
iduuidNOuuid_generate_v4()Primary key
emailtextNO-Requester email
nametextNO-Requester name
statustextNO'pending''pending', 'sent', 'rejected'
notestextYESNULLAdmin notes
invite_code_iduuidYESNULLForeign key to invite_codes (if sent)
created_attimestamptzNOnow()Request timestamp
updated_attimestamptzNOnow()Last update timestamp
processed_byuuidYESNULLForeign key to admin_users
processed_attimestamptzYESNULLWhen request processed

Indexes:

  • Primary key: id
  • Foreign key: invite_code_idinvite_codes(id) ON DELETE SET NULL
  • Foreign key: processed_byadmin_users(id) ON DELETE SET NULL
  • Unique: email
  • Index: (status, created_at DESC)

RLS Policies:

-- Public can create requests
CREATE POLICY "Anyone can create invite requests"
ON invite_code_requests FOR INSERT
WITH CHECK (true);

-- Only admins can view/update
CREATE POLICY "Admins can manage invite requests"
ON invite_code_requests FOR ALL
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.id = auth.uid()
)
);

Row Level Security (RLS)

All tables have RLS enabled to ensure data isolation.

User Data Policies

-- Users table
CREATE POLICY "Users can view own profile"
ON users FOR SELECT
USING (auth.uid() = id);

CREATE POLICY "Users can update own profile"
ON users FOR UPDATE
USING (auth.uid() = id);

-- Accounts table
CREATE POLICY "Users can manage own accounts"
ON accounts FOR ALL
USING (auth.uid() = user_id);

-- Transactions table
CREATE POLICY "Users can manage own transactions"
ON transactions FOR ALL
USING (auth.uid() = user_id);

-- Budgets table
CREATE POLICY "Users can manage own budgets"
ON budgets FOR ALL
USING (auth.uid() = user_id);

-- Plaid items table
CREATE POLICY "Users can view own plaid items"
ON plaid_items FOR SELECT
USING (auth.uid() = user_id);

Admin-Only Policies

-- Invite codes (admin-only management)
CREATE POLICY "Only admins can manage invite codes"
ON invite_codes FOR ALL
USING (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.is_admin = true
)
);

-- Public can view active invite codes (for signup validation)
CREATE POLICY "Anyone can view active invite codes"
ON invite_codes FOR SELECT
USING (is_active = true AND expires_at > now());

Functions & Triggers

Auto-Update Timestamps

CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Apply to all tables with updated_at column
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

-- Repeat for: accounts, transactions, budgets, plaid_items, admin_users

Create User Profile Trigger

-- Automatically create user profile when auth user is created
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
BEGIN
INSERT INTO public.users (id, email, full_name)
VALUES (
NEW.id,
NEW.email,
NEW.raw_user_meta_data->>'full_name'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user();

Indexes

Performance Indexes

-- High-frequency queries
CREATE INDEX idx_transactions_user_date ON transactions(user_id, transaction_date DESC);
CREATE INDEX idx_accounts_user ON accounts(user_id) WHERE is_hidden = false;
CREATE INDEX idx_plaid_items_user ON plaid_items(user_id);
CREATE INDEX idx_budgets_user_active ON budgets(user_id) WHERE is_active = true;

-- Admin audit logs
CREATE INDEX idx_admin_audit_logs_user ON admin_audit_logs(admin_user_id, created_at DESC);
CREATE INDEX idx_admin_audit_logs_action ON admin_audit_logs(action, created_at DESC);

Data Types Best Practices

Monetary Values

✅ Store as cents (integer):

current_balance INTEGER -- $123.45 = 12345

❌ Don't use decimal/numeric:

current_balance DECIMAL(10,2) -- Avoid!

Reason: Integers avoid floating-point precision issues.

Dates vs Timestamps

  • date for transaction dates (no time needed)
  • timestamptz for created_at, updated_at (includes timezone)

Next Steps: