backend: add FastAPI scaffold, models, oauth flow, schema
This commit is contained in:
parent
c64039bb2f
commit
8aee66f658
7
modern/backend/.env.example
Normal file
7
modern/backend/.env.example
Normal 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
|
||||||
15
modern/backend/README_OAUTH.md
Normal file
15
modern/backend/README_OAUTH.md
Normal 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
43
modern/backend/app.py
Normal 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
110
modern/backend/models.py
Normal 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
39
modern/backend/oauth.py
Normal 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}
|
||||||
5
modern/backend/requirements_full.txt
Normal file
5
modern/backend/requirements_full.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
sqlalchemy
|
||||||
|
authlib
|
||||||
|
python-dotenv
|
||||||
85
modern/backend/schema.sql
Normal file
85
modern/backend/schema.sql
Normal 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
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user