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