backend: add FastAPI scaffold, models, oauth flow, schema

This commit is contained in:
TLimoges33 2025-08-28 17:07:21 +00:00
parent c64039bb2f
commit 8aee66f658
7 changed files with 304 additions and 0 deletions

View File

@ -0,0 +1,7 @@
# Environment example for backend
DATABASE_URL=sqlite:///./modern_dev.db
BASE_URL=http://localhost:8000
FRONTEND_ORIGIN=http://localhost:5173
# Register a Google OAuth app and put credentials here for testing
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

View File

@ -0,0 +1,15 @@
OAuth notes
This scaffold uses `authlib`'s Starlette integration to provide OAuth flows.
How to test Google OAuth locally:
- Create OAuth credentials in Google Cloud Console (OAuth 2.0 Client IDs)
- Set Authorized redirect URI to: http://localhost:8000/api/v1/oauth/google/callback
- Copy credentials into `.env` or environment and start the backend:
export GOOGLE_CLIENT_ID=...\n export GOOGLE_CLIENT_SECRET=...\n export BASE_URL=http://localhost:8000
uvicorn modern.backend.app:app --reload --port 8000
- Visit: http://localhost:8000/api/v1/oauth/google/login
Security note: Never commit client secrets to source control. Use a secrets manager in production.

43
modern/backend/app.py Normal file
View File

@ -0,0 +1,43 @@
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from . import models
from .oauth import router as oauth_router
import os
app = FastAPI(title='LifeRPG Modern Backend')
app.add_middleware(
CORSMiddleware,
allow_origins=[os.getenv('FRONTEND_ORIGIN', 'http://localhost:5173')],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event('startup')
def startup_event():
models.init_db()
@app.get('/health')
def health():
return {'status': 'ok'}
@app.get('/api/v1/hello')
def hello():
return {'message': 'Hello from LifeRPG modern backend (FastAPI)'}
app.include_router(oauth_router, prefix='/api/v1')
# Basic user routes (demo)
@app.post('/api/v1/users')
def create_user(payload: dict):
db = models.SessionLocal()
email = payload.get('email')
if not email:
raise HTTPException(status_code=400, detail='email required')
user = models.User(email=email, display_name=payload.get('display_name'))
db.add(user)
db.commit()
db.refresh(user)
db.close()
return {'id': user.id, 'email': user.email}

110
modern/backend/models.py Normal file
View File

@ -0,0 +1,110 @@
from sqlalchemy import (
Column, Integer, String, Text, DateTime, ForeignKey, create_engine, func
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, sessionmaker
import os
Base = declarative_base()
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./modern_dev.db")
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, nullable=False, index=True)
password_hash = Column(String)
display_name = Column(String)
created_at = Column(DateTime, server_default=func.current_timestamp())
updated_at = Column(DateTime, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
profile = relationship("Profile", back_populates="user", cascade="all, delete-orphan")
projects = relationship("Project", back_populates="user", cascade="all, delete-orphan")
habits = relationship("Habit", back_populates="user", cascade="all, delete-orphan")
class Profile(Base):
__tablename__ = 'profiles'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
key = Column(String, nullable=False)
value = Column(Text)
user = relationship("User", back_populates="profile")
class Project(Base):
__tablename__ = 'projects'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
title = Column(String, nullable=False)
description = Column(Text)
created_at = Column(DateTime, server_default=func.current_timestamp())
updated_at = Column(DateTime, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
user = relationship("User", back_populates="projects")
class Habit(Base):
__tablename__ = 'habits'
id = Column(Integer, primary_key=True)
project_id = Column(Integer, ForeignKey('projects.id'))
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
title = Column(String, nullable=False)
notes = Column(Text)
cadence = Column(String)
difficulty = Column(Integer, default=1)
xp_reward = Column(Integer, default=10)
created_at = Column(DateTime, server_default=func.current_timestamp())
user = relationship("User", back_populates="habits")
class Log(Base):
__tablename__ = 'logs'
id = Column(Integer, primary_key=True)
habit_id = Column(Integer, ForeignKey('habits.id'))
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
action = Column(String)
timestamp = Column(DateTime, server_default=func.current_timestamp())
class Achievement(Base):
__tablename__ = 'achievements'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
name = Column(String, nullable=False)
description = Column(Text)
earned_at = Column(DateTime)
class Integration(Base):
__tablename__ = 'integrations'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
provider = Column(String, nullable=False)
external_id = Column(String)
config = Column(Text)
created_at = Column(DateTime, server_default=func.current_timestamp())
class OAuthToken(Base):
__tablename__ = 'oauth_tokens'
id = Column(Integer, primary_key=True)
integration_id = Column(Integer, ForeignKey('integrations.id'))
access_token = Column(Text)
refresh_token = Column(Text)
scope = Column(Text)
expires_at = Column(Integer)
class ChangeLog(Base):
__tablename__ = 'change_log'
id = Column(Integer, primary_key=True)
user_id = Column(Integer)
entity = Column(String)
entity_id = Column(Integer)
action = Column(String)
payload = Column(Text)
created_at = Column(DateTime, server_default=func.current_timestamp())
def init_db():
Base.metadata.create_all(bind=engine)
if __name__ == '__main__':
init_db()
print('Initialized DB ->', DATABASE_URL)

39
modern/backend/oauth.py Normal file
View File

@ -0,0 +1,39 @@
import os
from fastapi import APIRouter, Request
from starlette.responses import RedirectResponse
from authlib.integrations.starlette_client import OAuth
router = APIRouter()
oauth = OAuth()
# Register provider with placeholders; read from env at runtime
GOOGLE_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID')
GOOGLE_CLIENT_SECRET = os.getenv('GOOGLE_CLIENT_SECRET')
BASE_URL = os.getenv('BASE_URL', 'http://localhost:8000')
if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET:
oauth.register(
name='google',
client_id=GOOGLE_CLIENT_ID,
client_secret=GOOGLE_CLIENT_SECRET,
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={'scope': 'openid email profile https://www.googleapis.com/auth/calendar.events'}
)
@router.get('/oauth/google/login')
async def google_login(request: Request):
if 'google' not in oauth:
return {'error': 'google oauth not configured; set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET'}
redirect_uri = BASE_URL + '/api/v1/oauth/google/callback'
return await oauth.google.authorize_redirect(request, redirect_uri)
@router.get('/oauth/google/callback')
async def google_callback(request: Request):
if 'google' not in oauth:
return {'error': 'google oauth not configured'}
token = await oauth.google.authorize_access_token(request)
user = await oauth.google.parse_id_token(request, token)
# token contains access_token and refresh_token; persist securely
# For demo, return token info
return {'token': token, 'user': user}

View File

@ -0,0 +1,5 @@
fastapi
uvicorn[standard]
sqlalchemy
authlib
python-dotenv

85
modern/backend/schema.sql Normal file
View File

@ -0,0 +1,85 @@
-- LifeRPG DB schema (initial draft)
PRAGMA foreign_keys = ON;
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT,
display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT,
UNIQUE(user_id, key)
);
CREATE TABLE projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE habits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
notes TEXT,
cadence TEXT, -- e.g., daily, weekly
difficulty INTEGER DEFAULT 1,
xp_reward INTEGER DEFAULT 10,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
habit_id INTEGER REFERENCES habits(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
action TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE achievements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
earned_at DATETIME
);
CREATE TABLE integrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
external_id TEXT,
config TEXT, -- JSON blob for adapter config
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
integration_id INTEGER REFERENCES integrations(id) ON DELETE CASCADE,
access_token TEXT,
refresh_token TEXT,
scope TEXT,
expires_at INTEGER
);
CREATE TABLE change_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
entity TEXT,
entity_id INTEGER,
action TEXT,
payload TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);