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
);

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: