YeboID Database Schema
Neon PostgreSQL — the source of truth for identity.
Overview
YeboID uses Neon PostgreSQL for:
- User accounts & profiles
- Authentication (PINs, sessions)
- Handles & reservations
- OTP codes
- Audit logs
Tables
users
The core identity table. Every YeboID user has exactly one row here.
sql
CREATE TABLE users (
-- Identity
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone VARCHAR(20) UNIQUE NOT NULL, -- E.164 format: +26878422613
phone_verified BOOLEAN DEFAULT FALSE,
handle VARCHAR(30) UNIQUE, -- lowercase: laslie
-- Authentication
pin_hash VARCHAR(255), -- bcrypt hash
pin_attempts INTEGER DEFAULT 0, -- failed attempts counter
pin_locked_until TIMESTAMP, -- lockout expiry
-- Profile
name VARCHAR(100),
avatar_url TEXT,
bio VARCHAR(160),
country CHAR(2), -- ISO 3166-1: SZ, ZA, KE
language VARCHAR(10) DEFAULT 'en', -- ISO 639-1
-- Verification
kyc_status VARCHAR(20) DEFAULT 'none', -- none, pending, verified, rejected
kyc_verified_at TIMESTAMP,
kyc_provider_id VARCHAR(100), -- YeboVerify reference
-- Metadata
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
last_login_at TIMESTAMP,
-- Constraints
CONSTRAINT handle_format CHECK (handle ~ '^[a-z0-9_]{3,30}$'),
CONSTRAINT handle_no_edges CHECK (handle !~ '^_' AND handle !~ '_$')
);
-- Indexes
CREATE INDEX idx_users_phone ON users(phone);
CREATE INDEX idx_users_handle ON users(handle);
CREATE INDEX idx_users_kyc_status ON users(kyc_status);
CREATE INDEX idx_users_created_at ON users(created_at);otp_codes
Temporary codes for phone verification and PIN reset.
sql
CREATE TABLE otp_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone VARCHAR(20) NOT NULL,
code VARCHAR(6) NOT NULL,
purpose VARCHAR(20) NOT NULL, -- signup, pin_reset
attempts INTEGER DEFAULT 0, -- verification attempts
max_attempts INTEGER DEFAULT 5,
expires_at TIMESTAMP NOT NULL,
verified_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT valid_purpose CHECK (purpose IN ('signup', 'pin_reset'))
);
-- Indexes
CREATE INDEX idx_otp_phone_purpose ON otp_codes(phone, purpose);
CREATE INDEX idx_otp_expires ON otp_codes(expires_at);
-- Auto-cleanup: delete expired codes after 24 hours
-- (handled by cron job or pg_cron)sessions
Active user sessions (refresh tokens).
sql
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Token
refresh_token_hash VARCHAR(255) UNIQUE NOT NULL, -- SHA-256 hash
-- Device info
device_id VARCHAR(100), -- unique device identifier
device_name VARCHAR(100), -- "iPhone 15 Pro"
platform VARCHAR(50), -- ios, android, web
ip_address INET,
user_agent TEXT,
-- Lifecycle
last_used_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP,
revoke_reason VARCHAR(50), -- logout, security, expired
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_token ON sessions(refresh_token_hash);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
CREATE INDEX idx_sessions_active ON sessions(user_id) WHERE revoked_at IS NULL;reserved_handles
Handles that cannot be registered.
sql
CREATE TABLE reserved_handles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
handle VARCHAR(30) UNIQUE NOT NULL,
reason VARCHAR(50) NOT NULL, -- brand, inappropriate, system
reserved_by VARCHAR(100), -- admin email or system
reserved_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT valid_reason CHECK (reason IN ('brand', 'inappropriate', 'system', 'premium'))
);
-- Pre-populate with system handles
INSERT INTO reserved_handles (handle, reason, reserved_by) VALUES
('admin', 'system', 'system'),
('support', 'system', 'system'),
('help', 'system', 'system'),
('yebo', 'brand', 'system'),
('yeboid', 'brand', 'system'),
('yeboshops', 'brand', 'system'),
('yebojobs', 'brand', 'system'),
('yebolearn', 'brand', 'system'),
('yebolink', 'brand', 'system'),
('yebona', 'brand', 'system'),
('yebosafe', 'brand', 'system'),
('yebocars', 'brand', 'system'),
('eneza', 'brand', 'system'),
('omevision', 'brand', 'system'),
('official', 'system', 'system'),
('api', 'system', 'system'),
('www', 'system', 'system'),
('app', 'system', 'system'),
('security', 'system', 'system'),
('billing', 'system', 'system'),
('status', 'system', 'system');handle_changes
History of handle changes (for cooldown enforcement).
sql
CREATE TABLE handle_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
old_handle VARCHAR(30) NOT NULL,
new_handle VARCHAR(30) NOT NULL,
changed_at TIMESTAMP DEFAULT NOW(),
-- Old handle release
old_handle_released_at TIMESTAMP, -- 90 days after change
CONSTRAINT different_handles CHECK (old_handle != new_handle)
);
-- Indexes
CREATE INDEX idx_handle_changes_user ON handle_changes(user_id);
CREATE INDEX idx_handle_changes_old ON handle_changes(old_handle);
CREATE INDEX idx_handle_changes_date ON handle_changes(changed_at);audit_logs
Security-relevant events.
sql
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Actor
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
ip_address INET,
user_agent TEXT,
-- Event
event_type VARCHAR(50) NOT NULL, -- See event types below
event_data JSONB, -- Additional context
-- Result
success BOOLEAN NOT NULL,
failure_reason VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW()
);
-- Event types:
-- auth.signup, auth.signin, auth.signout
-- auth.pin_reset, auth.pin_failed, auth.pin_locked
-- auth.otp_sent, auth.otp_verified, auth.otp_failed
-- profile.updated, profile.deleted
-- handle.changed
-- session.revoked, session.revoked_all
-- kyc.initiated, kyc.completed, kyc.failed
-- Indexes
CREATE INDEX idx_audit_user ON audit_logs(user_id);
CREATE INDEX idx_audit_type ON audit_logs(event_type);
CREATE INDEX idx_audit_date ON audit_logs(created_at);
-- Partitioning by month for scale
-- (implement when needed)rate_limits
Track rate limiting per phone/IP.
sql
CREATE TABLE rate_limits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identifier
key VARCHAR(100) NOT NULL, -- phone:+268... or ip:1.2.3.4
action VARCHAR(50) NOT NULL, -- otp_send, signin, etc.
-- Counts
count INTEGER DEFAULT 1,
window_start TIMESTAMP DEFAULT NOW(),
window_minutes INTEGER NOT NULL, -- window size
max_count INTEGER NOT NULL, -- limit
UNIQUE(key, action)
);
-- Indexes
CREATE INDEX idx_rate_limits_key ON rate_limits(key, action);
CREATE INDEX idx_rate_limits_window ON rate_limits(window_start);Relationships
users
│
├── 1:N ── sessions (active login sessions)
│
├── 1:N ── otp_codes (temporary codes)
│
├── 1:N ── handle_changes (history)
│
└── 1:N ── audit_logs (security events)
reserved_handles (standalone)
rate_limits (standalone)Data Types & Constraints
Phone Numbers
- Format: E.164 (
+26878422613) - Validated on input
- Unique constraint
Handles
- 3-30 characters
- Lowercase only
- Letters, numbers, underscores
- No leading/trailing underscores
- Unique constraint
- Checked against reserved_handles
PINs
- 4-6 digits
- Stored as bcrypt hash (cost 12)
- Never logged
Timestamps
- All in UTC
created_atauto-setupdated_atvia trigger
Triggers
Updated At Trigger
sql
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();Audit Trigger (Optional)
sql
CREATE OR REPLACE FUNCTION audit_user_changes()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_logs (user_id, event_type, event_data, success)
VALUES (
NEW.id,
'profile.updated',
jsonb_build_object(
'old', to_jsonb(OLD),
'new', to_jsonb(NEW)
),
TRUE
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;Migrations
Naming Convention
001_create_users.sql
002_create_otp_codes.sql
003_create_sessions.sql
004_create_reserved_handles.sql
005_create_handle_changes.sql
006_create_audit_logs.sql
007_create_rate_limits.sqlRunning Migrations
bash
# Via Prisma
npx prisma migrate deploy
# Via raw SQL
psql $DATABASE_URL -f migrations/001_create_users.sqlMaintenance
Cleanup Jobs
sql
-- Delete expired OTP codes (run hourly)
DELETE FROM otp_codes
WHERE expires_at < NOW() - INTERVAL '24 hours';
-- Delete expired sessions (run daily)
DELETE FROM sessions
WHERE expires_at < NOW() - INTERVAL '7 days';
-- Archive old audit logs (run monthly)
-- Move to cold storage after 90 daysVacuum Schedule
sql
-- Auto-vacuum should handle most cases
-- For high-traffic tables, consider more aggressive settings:
ALTER TABLE otp_codes SET (autovacuum_vacuum_scale_factor = 0.05);
ALTER TABLE sessions SET (autovacuum_vacuum_scale_factor = 0.05);Backup Strategy
- Neon: Automatic point-in-time recovery
- Retention: 7 days for dev, 30 days for prod
- Export: Daily pg_dump to Cloud Storage (optional)
Performance Notes
Expected Scale (Year 1)
- Users: 50,000-100,000
- Sessions: 200,000-500,000
- OTP codes: 10,000/day
- Audit logs: 50,000/day
Indexes
All foreign keys and frequently queried columns are indexed. Monitor slow queries and add indexes as needed.
Connection Pooling
Neon handles pooling. For Cloud Run:
- Min connections: 1
- Max connections: 10 per instance
Last updated: March 18, 2026