From 8aee66f6589d679eba47896dbee4da0984fa6f34 Mon Sep 17 00:00:00 2001 From: TLimoges33 <125313326+TLimoges33@users.noreply.github.com> Date: Thu, 28 Aug 2025 17:07:21 +0000 Subject: [PATCH] backend: add FastAPI scaffold, models, oauth flow, schema --- modern/backend/.env.example | 7 ++ modern/backend/README_OAUTH.md | 15 ++++ modern/backend/app.py | 43 +++++++++++ modern/backend/models.py | 110 +++++++++++++++++++++++++++ modern/backend/oauth.py | 39 ++++++++++ modern/backend/requirements_full.txt | 5 ++ modern/backend/schema.sql | 85 +++++++++++++++++++++ 7 files changed, 304 insertions(+) create mode 100644 modern/backend/.env.example create mode 100644 modern/backend/README_OAUTH.md create mode 100644 modern/backend/app.py create mode 100644 modern/backend/models.py create mode 100644 modern/backend/oauth.py create mode 100644 modern/backend/requirements_full.txt create mode 100644 modern/backend/schema.sql diff --git a/modern/backend/.env.example b/modern/backend/.env.example new file mode 100644 index 0000000..d5f52b0 --- /dev/null +++ b/modern/backend/.env.example @@ -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 diff --git a/modern/backend/README_OAUTH.md b/modern/backend/README_OAUTH.md new file mode 100644 index 0000000..54abfe3 --- /dev/null +++ b/modern/backend/README_OAUTH.md @@ -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. diff --git a/modern/backend/app.py b/modern/backend/app.py new file mode 100644 index 0000000..bef3489 --- /dev/null +++ b/modern/backend/app.py @@ -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} diff --git a/modern/backend/models.py b/modern/backend/models.py new file mode 100644 index 0000000..e546f57 --- /dev/null +++ b/modern/backend/models.py @@ -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) diff --git a/modern/backend/oauth.py b/modern/backend/oauth.py new file mode 100644 index 0000000..8e86403 --- /dev/null +++ b/modern/backend/oauth.py @@ -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} diff --git a/modern/backend/requirements_full.txt b/modern/backend/requirements_full.txt new file mode 100644 index 0000000..bc78894 --- /dev/null +++ b/modern/backend/requirements_full.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn[standard] +sqlalchemy +authlib +python-dotenv diff --git a/modern/backend/schema.sql b/modern/backend/schema.sql new file mode 100644 index 0000000..bf3a9a9 --- /dev/null +++ b/modern/backend/schema.sql @@ -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 +);