first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:44:15 +05:30
commit 6e354ba041
109 changed files with 12110 additions and 0 deletions

1
backend/app/__init__.py Normal file
View file

@ -0,0 +1 @@

31
backend/app/config.py Normal file
View 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
View 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()

View 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
View 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"}

View file

@ -0,0 +1,3 @@
from app.models.user import PasswordResetToken, User
__all__ = ["User", "PasswordResetToken"]

View 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
View 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

View file

@ -0,0 +1 @@

116
backend/app/routers/auth.py Normal file
View 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.")

View 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)

View 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")

View 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",
}

View 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",
]

View 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)

View 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

View file

@ -0,0 +1 @@

View 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

View 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

View file

@ -0,0 +1 @@

View 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