Files
focusflow_api/migrations/001_initial_schema.sql
Oracle Public Cloud User 8958455a12 Initial scaffold: FocusFlow ADHD Task Manager backend
Dart Shelf API with modules: auth (JWT + PBKDF2), tasks (CRUD + dopamine scorer),
streaks (forgiveness + freeze), rewards (variable reward engine), time perception,
sync (offline-first push/pull), rooms (body doubling placeholder).
Includes DB migration (001_initial_schema.sql) and Docker Compose.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:53:40 +00:00

386 lines
15 KiB
PL/PgSQL

-- FocusFlow Initial Schema
-- Migration 001: Core tables for ADHD Task Manager MVP
BEGIN;
-- ============================================================
-- EXTENSIONS
-- ============================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================
-- USERS & AUTH
-- ============================================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
avatar_url VARCHAR(500),
-- ADHD-specific preferences
default_energy_level SMALLINT DEFAULT 3 CHECK (default_energy_level BETWEEN 1 AND 5),
focus_duration_minutes INTEGER DEFAULT 25,
reward_style VARCHAR(20) DEFAULT 'playful', -- playful | minimal | data
forgiveness_enabled BOOLEAN DEFAULT TRUE,
daily_task_limit INTEGER DEFAULT 10,
-- Subscription
subscription_tier VARCHAR(20) DEFAULT 'free', -- free | premium | lifetime
subscription_expires_at TIMESTAMPTZ,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL;
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id) WHERE revoked = FALSE;
CREATE INDEX idx_refresh_tokens_token ON refresh_tokens(token) WHERE revoked = FALSE;
-- ============================================================
-- TASKS
-- ============================================================
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(300) NOT NULL,
description TEXT,
status VARCHAR(20) DEFAULT 'pending', -- pending | in_progress | completed | skipped | archived
priority SMALLINT DEFAULT 3 CHECK (priority BETWEEN 1 AND 5),
energy_level SMALLINT DEFAULT 3 CHECK (energy_level BETWEEN 1 AND 5),
-- Time estimation
estimated_minutes INTEGER,
actual_minutes INTEGER,
-- Dopamine scoring inputs
dopamine_score NUMERIC(5,4) DEFAULT 0,
novelty_factor NUMERIC(3,2) DEFAULT 0.5,
times_postponed INTEGER DEFAULT 0,
last_interacted_at TIMESTAMPTZ,
-- Organization
category VARCHAR(100),
tags TEXT[] DEFAULT '{}',
due_date TIMESTAMPTZ,
-- Recurrence
is_recurring BOOLEAN DEFAULT FALSE,
recurrence_rule VARCHAR(200), -- RRULE format
parent_task_id UUID REFERENCES tasks(id),
-- Completion
completed_at TIMESTAMPTZ,
-- Sync
version INTEGER DEFAULT 1,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_tasks_user_status ON tasks(user_id, status) WHERE deleted_at IS NULL;
CREATE INDEX idx_tasks_user_due ON tasks(user_id, due_date) WHERE deleted_at IS NULL AND status = 'pending';
CREATE INDEX idx_tasks_dopamine ON tasks(user_id, dopamine_score DESC) WHERE deleted_at IS NULL AND status = 'pending';
CREATE INDEX idx_tasks_tags ON tasks USING GIN(tags);
CREATE INDEX idx_tasks_parent ON tasks(parent_task_id) WHERE parent_task_id IS NOT NULL;
-- ============================================================
-- STREAKS
-- ============================================================
CREATE TABLE streaks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
current_count INTEGER DEFAULT 0,
longest_count INTEGER DEFAULT 0,
-- Forgiveness mechanics
grace_days SMALLINT DEFAULT 2,
grace_used SMALLINT DEFAULT 0,
frozen_until DATE,
-- Decay instead of reset (premium)
decay_enabled BOOLEAN DEFAULT FALSE,
-- Tracking
last_completed_date DATE,
started_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE streak_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
streak_id UUID NOT NULL REFERENCES streaks(id) ON DELETE CASCADE,
entry_date DATE NOT NULL,
tasks_done INTEGER DEFAULT 0,
was_forgiven BOOLEAN DEFAULT FALSE,
was_frozen BOOLEAN DEFAULT FALSE,
-- Points earned on this day
points_earned INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(streak_id, entry_date)
);
CREATE INDEX idx_streak_entries_date ON streak_entries(streak_id, entry_date DESC);
-- ============================================================
-- REWARDS
-- ============================================================
CREATE TABLE rewards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
task_id UUID REFERENCES tasks(id),
-- Reward details
reward_type VARCHAR(20) NOT NULL, -- points | badge | animation | message | unlock | surprise
magnitude NUMERIC(8,2) NOT NULL DEFAULT 1.0,
is_surprise BOOLEAN DEFAULT FALSE,
-- Content
title VARCHAR(200),
description TEXT,
animation_key VARCHAR(100), -- Lottie animation identifier
-- Metadata
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_rewards_user ON rewards(user_id, created_at DESC);
CREATE INDEX idx_rewards_type ON rewards(user_id, reward_type);
-- User point balance (materialized for fast reads)
CREATE TABLE user_points (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
total INTEGER DEFAULT 0,
level INTEGER DEFAULT 1,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- TIME PERCEPTION TRACKING
-- ============================================================
CREATE TABLE time_estimates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
task_id UUID REFERENCES tasks(id),
-- Estimate vs actual
estimated_minutes INTEGER NOT NULL,
actual_minutes INTEGER,
accuracy_ratio NUMERIC(5,2), -- actual / estimated
-- Context
category VARCHAR(100),
energy_level SMALLINT,
-- Timestamps
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_time_estimates_user ON time_estimates(user_id, created_at DESC);
CREATE INDEX idx_time_estimates_category ON time_estimates(user_id, category);
-- ============================================================
-- BODY DOUBLING / CO-WORKING ROOMS (Phase 2)
-- ============================================================
CREATE TABLE coworking_rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
description TEXT,
host_id UUID NOT NULL REFERENCES users(id),
is_public BOOLEAN DEFAULT TRUE,
max_participants INTEGER DEFAULT 10,
ambient_sound VARCHAR(50) DEFAULT 'none', -- none | cafe | rain | lofi | forest
status VARCHAR(20) DEFAULT 'active', -- active | ended
started_at TIMESTAMPTZ DEFAULT NOW(),
ended_at TIMESTAMPTZ
);
CREATE TABLE room_participants (
room_id UUID NOT NULL REFERENCES coworking_rooms(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
current_task VARCHAR(200),
joined_at TIMESTAMPTZ DEFAULT NOW(),
left_at TIMESTAMPTZ,
PRIMARY KEY (room_id, user_id)
);
CREATE INDEX idx_room_participants_active ON room_participants(room_id) WHERE left_at IS NULL;
-- ============================================================
-- ACCOUNTABILITY PARTNERS (Phase 2)
-- ============================================================
CREATE TABLE accountability_partners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
partner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) DEFAULT 'pending', -- pending | active | blocked
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, partner_id)
);
CREATE TABLE nudges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_id UUID NOT NULL REFERENCES users(id),
to_id UUID NOT NULL REFERENCES users(id),
message TEXT,
nudge_type VARCHAR(20) DEFAULT 'gentle', -- gentle | encouraging | celebratory
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_nudges_to ON nudges(to_id, created_at DESC) WHERE read_at IS NULL;
-- ============================================================
-- COACHING MARKETPLACE (Phase 3)
-- ============================================================
CREATE TABLE coaches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id),
bio TEXT,
specializations TEXT[] DEFAULT '{}',
hourly_rate NUMERIC(8,2),
currency VARCHAR(3) DEFAULT 'USD',
rating_avg NUMERIC(3,2) DEFAULT 0,
rating_count INTEGER DEFAULT 0,
verified BOOLEAN DEFAULT FALSE,
status VARCHAR(20) DEFAULT 'pending', -- pending | active | suspended
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE coaching_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
coach_id UUID NOT NULL REFERENCES coaches(id),
client_id UUID NOT NULL REFERENCES users(id),
status VARCHAR(20) DEFAULT 'scheduled', -- scheduled | in_progress | completed | cancelled
scheduled_at TIMESTAMPTZ NOT NULL,
duration_min INTEGER DEFAULT 30,
notes TEXT,
rating SMALLINT CHECK (rating BETWEEN 1 AND 5),
amount NUMERIC(8,2),
commission NUMERIC(8,2),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_coaching_sessions_coach ON coaching_sessions(coach_id, scheduled_at);
CREATE INDEX idx_coaching_sessions_client ON coaching_sessions(client_id, scheduled_at);
-- ============================================================
-- PUSH NOTIFICATIONS
-- ============================================================
CREATE TABLE push_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(500) NOT NULL,
platform VARCHAR(20) NOT NULL, -- ios | android | web
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, token)
);
CREATE TABLE notification_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
notification_type VARCHAR(50) NOT NULL,
title VARCHAR(200),
body TEXT,
data_json JSONB,
sent_at TIMESTAMPTZ DEFAULT NOW(),
read_at TIMESTAMPTZ
);
CREATE INDEX idx_notification_log_user ON notification_log(user_id, sent_at DESC);
-- ============================================================
-- SYNC TRACKING
-- ============================================================
CREATE TABLE sync_changelog (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(50) NOT NULL, -- task | streak | reward | time_estimate
entity_id UUID NOT NULL,
action VARCHAR(20) NOT NULL, -- insert | update | delete
changed_at TIMESTAMPTZ DEFAULT NOW(),
changed_by UUID REFERENCES users(id)
);
CREATE INDEX idx_sync_changelog_type_time ON sync_changelog(entity_type, changed_at);
CREATE INDEX idx_sync_changelog_user ON sync_changelog(changed_by, changed_at);
-- ============================================================
-- SUBSCRIPTIONS
-- ============================================================
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(20) NOT NULL, -- stripe | revenuecat | apple | google
provider_subscription_id VARCHAR(200),
plan VARCHAR(50) NOT NULL, -- premium_monthly | premium_yearly | lifetime
status VARCHAR(20) DEFAULT 'active', -- active | cancelled | expired | past_due
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_provider ON subscriptions(provider, provider_subscription_id);
-- ============================================================
-- UPDATED_AT TRIGGER FUNCTION
-- ============================================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply trigger to tables with updated_at
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_tasks_updated_at BEFORE UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_streaks_updated_at BEFORE UPDATE ON streaks
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- SYNC TRIGGER: Auto-log changes to sync_changelog
-- ============================================================
CREATE OR REPLACE FUNCTION log_sync_change()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
INSERT INTO sync_changelog (entity_type, entity_id, action, changed_by)
VALUES (TG_TABLE_NAME, OLD.id, 'delete', OLD.user_id);
RETURN OLD;
ELSE
INSERT INTO sync_changelog (entity_type, entity_id, action, changed_by)
VALUES (TG_TABLE_NAME, NEW.id, TG_OP::VARCHAR, NEW.user_id);
RETURN NEW;
END IF;
END;
$$ language 'plpgsql';
-- Track task changes for offline sync
CREATE TRIGGER sync_tasks_changes AFTER INSERT OR UPDATE OR DELETE ON tasks
FOR EACH ROW EXECUTE FUNCTION log_sync_change();
COMMIT;