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_id → users(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_id → users(id) ON DELETE CASCADE
  • Foreign key: plaid_item_id → plaid_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_id → users(id) ON DELETE CASCADE
  • Foreign key: account_id → accounts(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_id → users(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_id → admin_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_id → admin_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_id → users(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_id → users(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_key → email_templates(template_key)
  • Foreign key: created_by → admin_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_id → users(id) ON DELETE CASCADE
  • Foreign key: budget_id → budgets(id) ON DELETE CASCADE
  • Foreign key: email_log_id → email_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_id → invite_codes(id) ON DELETE SET NULL
  • Foreign key: processed_by → admin_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: