first commit
This commit is contained in:
commit
6e354ba041
109 changed files with 12110 additions and 0 deletions
19
backend/.env.example
Normal file
19
backend/.env.example
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Database — SQLite works out of the box for local dev
|
||||
DATABASE_URL=sqlite:///./silos.db
|
||||
|
||||
# For PostgreSQL (with docker compose up -d), use instead:
|
||||
# DATABASE_URL=postgresql+psycopg2://silos:silos_secret@localhost:5432/silos
|
||||
|
||||
# MongoDB (for whiteboards & sketches — used in later phases)
|
||||
MONGODB_URL=mongodb://localhost:27017
|
||||
MONGODB_DB=silos
|
||||
|
||||
# Auth
|
||||
SECRET_KEY=change-this-to-a-long-random-secret-key-in-production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# App
|
||||
APP_NAME=Silos API
|
||||
DEBUG=true
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
86
backend/README.md
Normal file
86
backend/README.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Silos Backend
|
||||
|
||||
FastAPI backend with PostgreSQL/SQLite (users/auth) and MongoDB (workspace documents).
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Start databases
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
docker compose up mongo -d
|
||||
```
|
||||
|
||||
Optional (PostgreSQL instead of SQLite):
|
||||
|
||||
```bash
|
||||
docker compose up mongo postgres -d
|
||||
```
|
||||
|
||||
Then confirm the API: `GET http://localhost:8000/api/v1/health` should report `"mongodb": "connected"`.
|
||||
|
||||
MongoDB is required for Silo pages, Sketch whiteboards, Stead spaces, and Shed docs.
|
||||
SQLite is the default for user authentication (`DATABASE_URL` in `.env`).
|
||||
|
||||
### 2. Configure environment
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
### 3. Create virtual environment & install dependencies
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Run the API
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
API docs: [http://localhost:8000/docs](http://localhost:8000/docs)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v1/health` | Health check |
|
||||
| POST | `/api/v1/auth/register` | Create new user |
|
||||
| POST | `/api/v1/auth/login` | Login & get JWT tokens |
|
||||
| GET | `/api/v1/auth/me` | Get current user (requires auth) |
|
||||
| PATCH | `/api/v1/auth/me` | Update user profile |
|
||||
| POST | `/api/v1/auth/forgot-password` | Request password reset |
|
||||
| POST | `/api/v1/auth/reset-password` | Reset password with token |
|
||||
| GET | `/api/v1/documents` | List documents (optional `?type=` or `?stead_id=`) |
|
||||
| GET | `/api/v1/documents/search?q=` | Search documents by title, type, author, body |
|
||||
| GET | `/api/v1/documents/{id}` | Get a document |
|
||||
| POST | `/api/v1/documents` | Create a document |
|
||||
| PATCH | `/api/v1/documents/{id}` | Update title/content |
|
||||
| POST | `/api/v1/documents/{id}/share` | Invite a user by email/username |
|
||||
| DELETE | `/api/v1/documents/{id}/share/{user_id}` | Remove a member |
|
||||
| DELETE | `/api/v1/documents/{id}` | Delete a document |
|
||||
| WS | `/api/v1/ws/collab/{id}` | Live collab for Shed meeting notes |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI app entry
|
||||
│ ├── config.py # Settings from .env
|
||||
│ ├── database.py # PostgreSQL (SQLAlchemy)
|
||||
│ ├── mongo.py # MongoDB connection
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ ├── routers/ # API routes
|
||||
│ ├── services/ # Business logic
|
||||
│ └── utils/ # Security helpers
|
||||
├── requirements.txt
|
||||
└── .env.example
|
||||
```
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
31
backend/app/config.py
Normal file
31
backend/app/config.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
app_name: str = "Silos API"
|
||||
debug: bool = True
|
||||
|
||||
# SQLite by default for local dev; switch to PostgreSQL in production
|
||||
database_url: str = "sqlite:///./silos.db"
|
||||
|
||||
mongodb_url: str = "mongodb://localhost:27017"
|
||||
mongodb_db: str = "silos"
|
||||
|
||||
secret_key: str = "change-this-to-a-long-random-secret-key-in-production"
|
||||
access_token_expire_minutes: int = 60
|
||||
refresh_token_expire_days: int = 7
|
||||
|
||||
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
|
||||
|
||||
@property
|
||||
def is_sqlite(self) -> bool:
|
||||
return self.database_url.startswith("sqlite")
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
27
backend/app/database.py
Normal file
27
backend/app/database.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_connect_args = {"check_same_thread": False} if settings.is_sqlite else {}
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=not settings.is_sqlite,
|
||||
connect_args=_connect_args,
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
61
backend/app/dependencies.py
Normal file
61
backend/app/dependencies.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.mongo import get_mongo_db
|
||||
from app.models.user import User
|
||||
from app.utils.security import verify_token
|
||||
|
||||
security_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
subject = verify_token(credentials.credentials, expected_type="access")
|
||||
if subject is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.email == subject).first()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is inactive")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def get_mongo_db_dep() -> AsyncIOMotorDatabase:
|
||||
try:
|
||||
return get_mongo_db()
|
||||
except RuntimeError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="MongoDB is not available. Start it with: docker compose up mongo -d",
|
||||
)
|
||||
|
||||
|
||||
def generate_reset_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def reset_token_expiry(hours: int = 1) -> datetime:
|
||||
return datetime.now(UTC) + timedelta(hours=hours)
|
||||
57
backend/app/main.py
Normal file
57
backend/app/main.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base, engine
|
||||
from app.mongo import close_mongo, connect_mongo
|
||||
from app.routers import auth, collab, documents, health
|
||||
from app.services.document_service import ensure_indexes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
try:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("Database connected and tables ready")
|
||||
except OperationalError:
|
||||
logger.warning("Database unavailable — check DATABASE_URL in .env")
|
||||
|
||||
try:
|
||||
await connect_mongo()
|
||||
from app.mongo import get_mongo_db
|
||||
|
||||
await ensure_indexes(get_mongo_db())
|
||||
logger.info("MongoDB connected and indexes ready")
|
||||
except Exception:
|
||||
logger.warning("MongoDB unavailable — will connect when available")
|
||||
|
||||
yield
|
||||
await close_mongo()
|
||||
|
||||
|
||||
app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health.router, prefix="/api/v1")
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(documents.router, prefix="/api/v1")
|
||||
app.include_router(collab.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root() -> dict[str, str]:
|
||||
return {"message": "Silos API", "docs": "/docs"}
|
||||
3
backend/app/models/__init__.py
Normal file
3
backend/app/models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.models.user import PasswordResetToken, User
|
||||
|
||||
__all__ = ["User", "PasswordResetToken"]
|
||||
39
backend/app/models/user.py
Normal file
39
backend/app/models/user.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
username: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
employee_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
occupation: Mapped[str | None] = mapped_column(String(150), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(index=True, nullable=False)
|
||||
token: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
26
backend/app/mongo.py
Normal file
26
backend/app/mongo.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
mongo_client: AsyncIOMotorClient | None = None
|
||||
mongo_db: AsyncIOMotorDatabase | None = None
|
||||
|
||||
|
||||
async def connect_mongo() -> None:
|
||||
global mongo_client, mongo_db
|
||||
mongo_client = AsyncIOMotorClient(settings.mongodb_url)
|
||||
mongo_db = mongo_client[settings.mongodb_db]
|
||||
|
||||
|
||||
async def close_mongo() -> None:
|
||||
global mongo_client, mongo_db
|
||||
if mongo_client:
|
||||
mongo_client.close()
|
||||
mongo_client = None
|
||||
mongo_db = None
|
||||
|
||||
|
||||
def get_mongo_db() -> AsyncIOMotorDatabase:
|
||||
if mongo_db is None:
|
||||
raise RuntimeError("MongoDB is not connected")
|
||||
return mongo_db
|
||||
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
116
backend/app/routers/auth.py
Normal file
116
backend/app/routers/auth.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import (
|
||||
generate_reset_token,
|
||||
get_current_user,
|
||||
reset_token_expiry,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.user import (
|
||||
ForgotPasswordRequest,
|
||||
LoginRequest,
|
||||
MessageResponse,
|
||||
ResetPasswordRequest,
|
||||
TokenResponse,
|
||||
UserCreate,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.services.user_service import (
|
||||
authenticate_user,
|
||||
create_password_reset_token,
|
||||
create_user,
|
||||
get_user_by_email,
|
||||
get_user_by_username,
|
||||
get_valid_reset_token,
|
||||
update_user,
|
||||
)
|
||||
from app.utils.security import create_access_token, create_refresh_token, hash_password
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def register(data: UserCreate, db: Session = Depends(get_db)) -> User:
|
||||
if get_user_by_email(db, data.email.lower()):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
||||
|
||||
if get_user_by_username(db, data.username.lower()):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken")
|
||||
|
||||
return create_user(db, data)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(data: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
|
||||
user = authenticate_user(db, data.username_or_email, data.password)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username/email or password",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is inactive")
|
||||
|
||||
access_token = create_access_token(
|
||||
user.email,
|
||||
timedelta(days=7) if data.remember_me else None,
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=create_refresh_token(user.email),
|
||||
user=UserResponse.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: User = Depends(get_current_user)) -> User:
|
||||
return current_user
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
def update_me(
|
||||
data: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
return update_user(db, current_user, data)
|
||||
|
||||
|
||||
@router.post("/forgot-password", response_model=MessageResponse)
|
||||
def forgot_password(data: ForgotPasswordRequest, db: Session = Depends(get_db)) -> MessageResponse:
|
||||
user = get_user_by_email(db, data.email.lower())
|
||||
if user is None:
|
||||
return MessageResponse(message="If that email exists, a reset link has been sent.")
|
||||
|
||||
token = generate_reset_token()
|
||||
create_password_reset_token(db, user, token, reset_token_expiry())
|
||||
|
||||
response = MessageResponse(message="If that email exists, a reset link has been sent.")
|
||||
if settings.debug:
|
||||
response.reset_token = token
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/reset-password", response_model=MessageResponse)
|
||||
def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)) -> MessageResponse:
|
||||
reset = get_valid_reset_token(db, data.token)
|
||||
if reset is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired reset token")
|
||||
|
||||
user = db.query(User).filter(User.id == reset.user_id).first()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
user.hashed_password = hash_password(data.new_password)
|
||||
reset.used = True
|
||||
db.commit()
|
||||
|
||||
return MessageResponse(message="Password reset successfully. You can now log in.")
|
||||
97
backend/app/routers/collab.py
Normal file
97
backend/app/routers/collab.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.mongo import get_mongo_db
|
||||
from app.services.document_service import get_document
|
||||
from app.services.user_service import get_user_by_email
|
||||
from app.utils.security import verify_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
rooms: dict[str, set[WebSocket]] = defaultdict(set)
|
||||
presence: dict[str, dict[int, str]] = defaultdict(dict)
|
||||
|
||||
|
||||
async def _user_from_token(token: str):
|
||||
subject = verify_token(token, expected_type="access")
|
||||
if not subject:
|
||||
return None
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
return get_user_by_email(db, subject)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.websocket("/ws/collab/{doc_id}")
|
||||
async def collab_socket(websocket: WebSocket, doc_id: str) -> None:
|
||||
token = websocket.query_params.get("token", "")
|
||||
user = await _user_from_token(token)
|
||||
if user is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
try:
|
||||
mongo = get_mongo_db()
|
||||
except RuntimeError:
|
||||
await websocket.close(code=1013)
|
||||
return
|
||||
|
||||
doc = await get_document(mongo, doc_id, user.id)
|
||||
if doc is None:
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
rooms[doc_id].add(websocket)
|
||||
presence[doc_id][user.id] = user.full_name or user.username
|
||||
|
||||
async def broadcast(payload: dict, skip: WebSocket | None = None) -> None:
|
||||
dead: list[WebSocket] = []
|
||||
for client in rooms[doc_id]:
|
||||
if client is skip:
|
||||
continue
|
||||
try:
|
||||
await client.send_json(payload)
|
||||
except Exception:
|
||||
dead.append(client)
|
||||
for client in dead:
|
||||
rooms[doc_id].discard(client)
|
||||
|
||||
await broadcast(
|
||||
{
|
||||
"type": "presence",
|
||||
"peers": [{"id": uid, "name": name} for uid, name in presence[doc_id].items()],
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive_json()
|
||||
if message.get("type") == "content":
|
||||
await broadcast(
|
||||
{
|
||||
"type": "content",
|
||||
"content": message.get("content"),
|
||||
"from": user.id,
|
||||
},
|
||||
skip=websocket,
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
rooms[doc_id].discard(websocket)
|
||||
presence[doc_id].pop(user.id, None)
|
||||
if rooms[doc_id]:
|
||||
await broadcast(
|
||||
{
|
||||
"type": "presence",
|
||||
"peers": [{"id": uid, "name": name} for uid, name in presence[doc_id].items()],
|
||||
}
|
||||
)
|
||||
else:
|
||||
rooms.pop(doc_id, None)
|
||||
presence.pop(doc_id, None)
|
||||
159
backend/app/routers/documents.py
Normal file
159
backend/app/routers/documents.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, get_mongo_db_dep
|
||||
from app.models.user import User
|
||||
from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, ShareRequest
|
||||
from app.services.document_service import (
|
||||
add_member,
|
||||
create_document,
|
||||
delete_document,
|
||||
get_document,
|
||||
list_documents,
|
||||
record_visit,
|
||||
remove_member,
|
||||
search_documents,
|
||||
update_document,
|
||||
)
|
||||
from app.services.user_service import get_user_by_email_or_username
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[DocumentResponse])
|
||||
async def list_docs(
|
||||
type: str | None = Query(None, description="Filter by document type"),
|
||||
stead_id: str | None = Query(None, description="Filter by parent stead"),
|
||||
starred: bool | None = Query(None, description="Filter starred projects"),
|
||||
sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> list[DocumentResponse]:
|
||||
sort_field = "last_visited_at" if sort == "last_visited_at" else "updated_at"
|
||||
docs = await list_documents(
|
||||
db,
|
||||
current_user.id,
|
||||
doc_type=type,
|
||||
stead_id=stead_id,
|
||||
starred=starred,
|
||||
sort=sort_field,
|
||||
)
|
||||
return [DocumentResponse(**doc) for doc in docs]
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[DocumentResponse])
|
||||
async def search_docs(
|
||||
q: str = Query("", min_length=0),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> list[DocumentResponse]:
|
||||
docs = await search_documents(db, current_user.id, q)
|
||||
return [DocumentResponse(**doc) for doc in docs]
|
||||
|
||||
|
||||
@router.get("/{doc_id}", response_model=DocumentResponse)
|
||||
async def get_doc(
|
||||
doc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> DocumentResponse:
|
||||
doc = await get_document(db, doc_id, current_user.id)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
|
||||
return DocumentResponse(**doc)
|
||||
|
||||
|
||||
@router.post("", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_doc(
|
||||
data: DocumentCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> DocumentResponse:
|
||||
created_by = current_user.full_name or current_user.username
|
||||
doc = await create_document(db, current_user.id, created_by, data)
|
||||
return DocumentResponse(**doc)
|
||||
|
||||
|
||||
@router.patch("/{doc_id}", response_model=DocumentResponse)
|
||||
async def update_doc(
|
||||
doc_id: str,
|
||||
data: DocumentUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> DocumentResponse:
|
||||
doc = await update_document(db, doc_id, current_user.id, data)
|
||||
if doc is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Document not found or you do not have edit access",
|
||||
)
|
||||
return DocumentResponse(**doc)
|
||||
|
||||
|
||||
@router.post("/{doc_id}/visit", response_model=DocumentResponse)
|
||||
async def visit_doc(
|
||||
doc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> DocumentResponse:
|
||||
doc = await record_visit(db, doc_id, current_user.id)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
|
||||
return DocumentResponse(**doc)
|
||||
|
||||
|
||||
@router.post("/{doc_id}/share", response_model=DocumentResponse)
|
||||
async def share_doc(
|
||||
doc_id: str,
|
||||
data: ShareRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
mongo: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
sql: Session = Depends(get_db),
|
||||
) -> DocumentResponse:
|
||||
identifier = data.email_or_username.strip().lower()
|
||||
user = get_user_by_email_or_username(sql, identifier)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="You already own this document")
|
||||
|
||||
doc = await add_member(
|
||||
mongo,
|
||||
doc_id,
|
||||
current_user.id,
|
||||
{
|
||||
"user_id": user.id,
|
||||
"name": user.full_name or user.username,
|
||||
"email": user.email,
|
||||
},
|
||||
data.role,
|
||||
)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can share this document")
|
||||
return DocumentResponse(**doc)
|
||||
|
||||
|
||||
@router.delete("/{doc_id}/share/{user_id}", response_model=DocumentResponse)
|
||||
async def unshare_doc(
|
||||
doc_id: str,
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
mongo: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> DocumentResponse:
|
||||
doc = await remove_member(mongo, doc_id, current_user.id, user_id)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can update sharing")
|
||||
return DocumentResponse(**doc)
|
||||
|
||||
|
||||
@router.delete("/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_doc(
|
||||
doc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
|
||||
) -> None:
|
||||
deleted = await delete_document(db, doc_id, current_user.id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
40
backend/app/routers/health.py
Normal file
40
backend/app/routers/health.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from fastapi import APIRouter
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.config import settings
|
||||
from app.database import engine
|
||||
from app.mongo import mongo_client, mongo_db
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
db_status = "disconnected"
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
db_status = "sqlite" if settings.is_sqlite else "connected"
|
||||
except OperationalError:
|
||||
pass
|
||||
|
||||
mongo_status = "disconnected"
|
||||
if mongo_client is not None and mongo_db is not None:
|
||||
try:
|
||||
await mongo_client.admin.command("ping")
|
||||
mongo_status = "connected"
|
||||
except Exception:
|
||||
mongo_status = "disconnected"
|
||||
|
||||
overall = "ok" if db_status != "disconnected" and mongo_status == "connected" else "degraded"
|
||||
|
||||
return {
|
||||
"status": overall,
|
||||
"service": "silos-api",
|
||||
"database": db_status,
|
||||
"mongodb": mongo_status,
|
||||
"hint": ""
|
||||
if mongo_status == "connected"
|
||||
else "Start MongoDB with: docker compose up mongo -d",
|
||||
}
|
||||
21
backend/app/schemas/__init__.py
Normal file
21
backend/app/schemas/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from app.schemas.user import (
|
||||
ForgotPasswordRequest,
|
||||
LoginRequest,
|
||||
MessageResponse,
|
||||
ResetPasswordRequest,
|
||||
TokenResponse,
|
||||
UserCreate,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserResponse",
|
||||
"LoginRequest",
|
||||
"TokenResponse",
|
||||
"ForgotPasswordRequest",
|
||||
"ResetPasswordRequest",
|
||||
"MessageResponse",
|
||||
]
|
||||
51
backend/app/schemas/document.py
Normal file
51
backend/app/schemas/document.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
CreateType = Literal["shed", "silo", "sketch", "stead"]
|
||||
MemberRole = Literal["editor", "viewer"]
|
||||
Visibility = Literal["private", "members"]
|
||||
|
||||
|
||||
class DocumentMember(BaseModel):
|
||||
user_id: int
|
||||
role: MemberRole
|
||||
name: str = ""
|
||||
email: str = ""
|
||||
|
||||
|
||||
class DocumentCreate(BaseModel):
|
||||
type: CreateType
|
||||
title: str
|
||||
content: dict[str, Any] = Field(default_factory=dict)
|
||||
template_id: str | None = None
|
||||
stead_id: str | None = None
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
content: dict[str, Any] | None = None
|
||||
starred: bool | None = None
|
||||
|
||||
|
||||
class ShareRequest(BaseModel):
|
||||
email_or_username: str
|
||||
role: MemberRole = "editor"
|
||||
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
id: str
|
||||
type: CreateType
|
||||
title: str
|
||||
template_id: str | None = None
|
||||
stead_id: str | None = None
|
||||
content: dict[str, Any]
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
starred: bool = False
|
||||
last_visited_at: datetime | None = None
|
||||
owner_id: int | None = None
|
||||
visibility: Visibility = "private"
|
||||
members: list[DocumentMember] = Field(default_factory=list)
|
||||
59
backend/app/schemas/user.py
Normal file
59
backend/app/schemas/user.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
username: str = Field(min_length=3, max_length=100)
|
||||
full_name: str = Field(min_length=1, max_length=255)
|
||||
employee_id: str | None = Field(default=None, max_length=100)
|
||||
phone: str | None = Field(default=None, max_length=50)
|
||||
occupation: str | None = Field(default=None, max_length=150)
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
employee_id: str | None = Field(default=None, max_length=100)
|
||||
phone: str | None = Field(default=None, max_length=50)
|
||||
occupation: str | None = Field(default=None, max_length=150)
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username_or_email: str = Field(min_length=1)
|
||||
password: str = Field(min_length=1)
|
||||
remember_me: bool = False
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
reset_token: str | None = None
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
256
backend/app/services/document_service.py
Normal file
256
backend/app/services/document_service.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from pymongo import ReturnDocument
|
||||
|
||||
from app.schemas.document import CreateType, DocumentCreate, DocumentUpdate, MemberRole
|
||||
|
||||
COLLECTION = "documents"
|
||||
SortField = Literal["updated_at", "last_visited_at"]
|
||||
|
||||
|
||||
async def ensure_indexes(db: AsyncIOMotorDatabase) -> None:
|
||||
col = db[COLLECTION]
|
||||
await col.create_index([("owner_id", 1), ("updated_at", -1)])
|
||||
await col.create_index([("owner_id", 1), ("last_visited_at", -1)])
|
||||
await col.create_index([("owner_id", 1), ("stead_id", 1)])
|
||||
await col.create_index([("owner_id", 1), ("type", 1)])
|
||||
await col.create_index([("owner_id", 1), ("starred", 1)])
|
||||
await col.create_index([("members.user_id", 1)])
|
||||
await col.create_index([("title", "text"), ("created_by", "text")])
|
||||
|
||||
|
||||
def _serialize(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": doc["_id"],
|
||||
"type": doc["type"],
|
||||
"title": doc["title"],
|
||||
"template_id": doc.get("template_id"),
|
||||
"stead_id": doc.get("stead_id"),
|
||||
"content": doc.get("content", {}),
|
||||
"created_by": doc["created_by"],
|
||||
"created_at": doc["created_at"],
|
||||
"updated_at": doc["updated_at"],
|
||||
"starred": doc.get("starred", False),
|
||||
"last_visited_at": doc.get("last_visited_at"),
|
||||
"owner_id": doc.get("owner_id"),
|
||||
"visibility": doc.get("visibility", "private"),
|
||||
"members": doc.get("members", []),
|
||||
}
|
||||
|
||||
|
||||
def access_query(user_id: int) -> dict[str, Any]:
|
||||
return {"$or": [{"owner_id": user_id}, {"members.user_id": user_id}]}
|
||||
|
||||
|
||||
def can_edit(doc: dict[str, Any], user_id: int) -> bool:
|
||||
if doc.get("owner_id") == user_id:
|
||||
return True
|
||||
for member in doc.get("members", []):
|
||||
if member.get("user_id") == user_id and member.get("role") == "editor":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def create_document(
|
||||
db: AsyncIOMotorDatabase,
|
||||
owner_id: int,
|
||||
created_by: str,
|
||||
data: DocumentCreate,
|
||||
) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
doc_id = str(uuid.uuid4())
|
||||
doc = {
|
||||
"_id": doc_id,
|
||||
"owner_id": owner_id,
|
||||
"type": data.type,
|
||||
"title": data.title,
|
||||
"template_id": data.template_id,
|
||||
"stead_id": data.stead_id,
|
||||
"content": data.content,
|
||||
"created_by": created_by,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"starred": False,
|
||||
"last_visited_at": now,
|
||||
"visibility": "private",
|
||||
"members": [],
|
||||
}
|
||||
await db[COLLECTION].insert_one(doc)
|
||||
return _serialize(doc)
|
||||
|
||||
|
||||
async def get_document(
|
||||
db: AsyncIOMotorDatabase,
|
||||
doc_id: str,
|
||||
user_id: int,
|
||||
) -> dict[str, Any] | None:
|
||||
doc = await db[COLLECTION].find_one({"_id": doc_id, **access_query(user_id)})
|
||||
return _serialize(doc) if doc else None
|
||||
|
||||
|
||||
async def list_documents(
|
||||
db: AsyncIOMotorDatabase,
|
||||
user_id: int,
|
||||
doc_type: CreateType | None = None,
|
||||
stead_id: str | None = None,
|
||||
starred: bool | None = None,
|
||||
sort: SortField = "updated_at",
|
||||
) -> list[dict[str, Any]]:
|
||||
query: dict[str, Any] = access_query(user_id)
|
||||
extras: dict[str, Any] = {}
|
||||
if doc_type:
|
||||
extras["type"] = doc_type
|
||||
if stead_id:
|
||||
extras["stead_id"] = stead_id
|
||||
if starred is not None:
|
||||
extras["starred"] = starred
|
||||
if extras:
|
||||
query = {"$and": [query, extras]}
|
||||
|
||||
cursor = db[COLLECTION].find(query).sort(sort, -1)
|
||||
return [_serialize(doc) async for doc in cursor]
|
||||
|
||||
|
||||
async def search_documents(
|
||||
db: AsyncIOMotorDatabase,
|
||||
user_id: int,
|
||||
q: str,
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, Any]]:
|
||||
term = q.strip()
|
||||
if not term:
|
||||
return []
|
||||
|
||||
pattern = re.escape(term)
|
||||
regex = {"$regex": pattern, "$options": "i"}
|
||||
query = {
|
||||
"$and": [
|
||||
access_query(user_id),
|
||||
{
|
||||
"$or": [
|
||||
{"title": regex},
|
||||
{"type": regex},
|
||||
{"created_by": regex},
|
||||
{"content.body": regex},
|
||||
{"content.bodyHtml": regex},
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
cursor = db[COLLECTION].find(query).sort([("last_visited_at", -1), ("updated_at", -1)]).limit(limit)
|
||||
return [_serialize(doc) async for doc in cursor]
|
||||
|
||||
|
||||
async def update_document(
|
||||
db: AsyncIOMotorDatabase,
|
||||
doc_id: str,
|
||||
user_id: int,
|
||||
data: DocumentUpdate,
|
||||
) -> dict[str, Any] | None:
|
||||
existing = await db[COLLECTION].find_one({"_id": doc_id, **access_query(user_id)})
|
||||
if existing is None:
|
||||
return None
|
||||
if not can_edit(existing, user_id):
|
||||
return None
|
||||
|
||||
updates: dict[str, Any] = {"updated_at": datetime.now(UTC)}
|
||||
if data.title is not None:
|
||||
updates["title"] = data.title
|
||||
if data.content is not None:
|
||||
updates["content"] = data.content
|
||||
if data.starred is not None:
|
||||
updates["starred"] = data.starred
|
||||
|
||||
result = await db[COLLECTION].find_one_and_update(
|
||||
{"_id": doc_id},
|
||||
{"$set": updates},
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
return _serialize(result) if result else None
|
||||
|
||||
|
||||
async def record_visit(
|
||||
db: AsyncIOMotorDatabase,
|
||||
doc_id: str,
|
||||
user_id: int,
|
||||
) -> dict[str, Any] | None:
|
||||
now = datetime.now(UTC)
|
||||
result = await db[COLLECTION].find_one_and_update(
|
||||
{"_id": doc_id, **access_query(user_id)},
|
||||
{"$set": {"last_visited_at": now}},
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
return _serialize(result) if result else None
|
||||
|
||||
|
||||
async def add_member(
|
||||
db: AsyncIOMotorDatabase,
|
||||
doc_id: str,
|
||||
owner_id: int,
|
||||
member: dict[str, Any],
|
||||
role: MemberRole,
|
||||
) -> dict[str, Any] | None:
|
||||
existing = await db[COLLECTION].find_one({"_id": doc_id, "owner_id": owner_id})
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
members = list(existing.get("members", []))
|
||||
members = [m for m in members if m.get("user_id") != member["user_id"]]
|
||||
members.append(
|
||||
{
|
||||
"user_id": member["user_id"],
|
||||
"role": role,
|
||||
"name": member.get("name", ""),
|
||||
"email": member.get("email", ""),
|
||||
}
|
||||
)
|
||||
result = await db[COLLECTION].find_one_and_update(
|
||||
{"_id": doc_id, "owner_id": owner_id},
|
||||
{
|
||||
"$set": {
|
||||
"members": members,
|
||||
"visibility": "members",
|
||||
"updated_at": datetime.now(UTC),
|
||||
}
|
||||
},
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
return _serialize(result) if result else None
|
||||
|
||||
|
||||
async def remove_member(
|
||||
db: AsyncIOMotorDatabase,
|
||||
doc_id: str,
|
||||
owner_id: int,
|
||||
member_user_id: int,
|
||||
) -> dict[str, Any] | None:
|
||||
existing = await db[COLLECTION].find_one({"_id": doc_id, "owner_id": owner_id})
|
||||
if existing is None:
|
||||
return None
|
||||
members = [m for m in existing.get("members", []) if m.get("user_id") != member_user_id]
|
||||
visibility = "members" if members else "private"
|
||||
result = await db[COLLECTION].find_one_and_update(
|
||||
{"_id": doc_id, "owner_id": owner_id},
|
||||
{
|
||||
"$set": {
|
||||
"members": members,
|
||||
"visibility": visibility,
|
||||
"updated_at": datetime.now(UTC),
|
||||
}
|
||||
},
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
return _serialize(result) if result else None
|
||||
|
||||
|
||||
async def delete_document(
|
||||
db: AsyncIOMotorDatabase,
|
||||
doc_id: str,
|
||||
owner_id: int,
|
||||
) -> bool:
|
||||
result = await db[COLLECTION].delete_one({"_id": doc_id, "owner_id": owner_id})
|
||||
return result.deleted_count > 0
|
||||
80
backend/app/services/user_service.py
Normal file
80
backend/app/services/user_service.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.user import PasswordResetToken, User
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
from app.utils.security import hash_password, verify_password
|
||||
|
||||
|
||||
def get_user_by_id(db: Session, user_id: int) -> User | None:
|
||||
return db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
|
||||
def get_user_by_email(db: Session, email: str) -> User | None:
|
||||
return db.query(User).filter(User.email == email).first()
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> User | None:
|
||||
return db.query(User).filter(User.username == username).first()
|
||||
|
||||
|
||||
def get_user_by_email_or_username(db: Session, identifier: str) -> User | None:
|
||||
return (
|
||||
db.query(User)
|
||||
.filter((User.email == identifier) | (User.username == identifier))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def create_user(db: Session, data: UserCreate) -> User:
|
||||
user = User(
|
||||
email=data.email.lower(),
|
||||
username=data.username.lower(),
|
||||
full_name=data.full_name,
|
||||
employee_id=data.employee_id,
|
||||
phone=data.phone,
|
||||
occupation=data.occupation,
|
||||
hashed_password=hash_password(data.password),
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def update_user(db: Session, user: User, data: UserUpdate) -> User:
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def authenticate_user(db: Session, identifier: str, password: str) -> User | None:
|
||||
user = get_user_by_email_or_username(db, identifier)
|
||||
if user is None or not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def create_password_reset_token(db: Session, user: User, token: str, expires_at) -> PasswordResetToken:
|
||||
reset = PasswordResetToken(user_id=user.id, token=token, expires_at=expires_at)
|
||||
db.add(reset)
|
||||
db.commit()
|
||||
db.refresh(reset)
|
||||
return reset
|
||||
|
||||
|
||||
def get_valid_reset_token(db: Session, token: str) -> PasswordResetToken | None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
reset = (
|
||||
db.query(PasswordResetToken)
|
||||
.filter(
|
||||
PasswordResetToken.token == token,
|
||||
PasswordResetToken.used.is_(False),
|
||||
PasswordResetToken.expires_at > datetime.now(UTC),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return reset
|
||||
1
backend/app/utils/__init__.py
Normal file
1
backend/app/utils/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
46
backend/app/utils/security.py
Normal file
46
backend/app/utils/security.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import bcrypt
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||
|
||||
|
||||
def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str:
|
||||
expire = datetime.now(UTC) + (
|
||||
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
|
||||
)
|
||||
payload = {"sub": subject, "exp": expire, "type": "access"}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str) -> str:
|
||||
expire = datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days)
|
||||
payload = {"sub": subject, "exp": expire, "type": "refresh"}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
|
||||
|
||||
|
||||
def verify_token(token: str, expected_type: str = "access") -> str | None:
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
if payload.get("type") != expected_type:
|
||||
return None
|
||||
subject = payload.get("sub")
|
||||
return subject if isinstance(subject, str) else None
|
||||
except JWTError:
|
||||
return None
|
||||
12
backend/requirements.txt
Normal file
12
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
fastapi==0.115.8
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.38
|
||||
psycopg2-binary==2.9.10
|
||||
pydantic[email]==2.10.6
|
||||
pydantic-settings==2.8.1
|
||||
python-jose[cryptography]==3.4.0
|
||||
bcrypt==4.2.1
|
||||
python-multipart==0.0.20
|
||||
motor==3.7.0
|
||||
alembic==1.14.1
|
||||
email-validator==2.2.0
|
||||
Loading…
Add table
Add a link
Reference in a new issue