diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2334d82 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.env +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c89096 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Copy this file to .env and fill in the values before running docker-compose. +# NEVER commit .env to version control. + +# ── PostgreSQL ──────────────────────────────────────────────────────────────── +POSTGRES_DB=lms_db +POSTGRES_USER=lms_user +# REQUIRED — choose a strong password +POSTGRES_PASSWORD=change_me_strong_password + +# ── JWT ─────────────────────────────────────────────────────────────────────── +# REQUIRED — generate with: python -c "import secrets; print(secrets.token_hex(32))" +JWT_SECRET_KEY=change_me_generate_with_secrets_token_hex_32 + +# Token lifetime in minutes (default: 60) +ACCESS_TOKEN_EXPIRE_MINUTES=60 + +# ── Ports ───────────────────────────────────────────────────────────────────── +# Port exposed on the host for the Next.js frontend +FRONTEND_PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a266bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Environment +.env +.env.local +.env.*.local + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ + +# Node +node_modules/ +.next/ + +# Uploads (runtime data) +/backend/uploads/ + +# OS +.DS_Store +Thumbs.db diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c947f8e --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +*.pyo +.env +.env.* +!.env.example +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +/uploads/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..5d19b4a --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,28 @@ +# ── Stage 1: deps ───────────────────────────────────────────────────────────── +FROM python:3.12-slim AS deps + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# ── Stage 2: runtime ────────────────────────────────────────────────────────── +FROM python:3.12-slim AS runtime + +WORKDIR /app + +# Copy installed packages from deps stage +COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=deps /usr/local/bin /usr/local/bin + +# Copy application code +COPY . . + +# Ensure entrypoint is executable +RUN chmod +x entrypoint.sh + +# Upload directory (will be mounted as a Docker volume) +RUN mkdir -p /uploads + +EXPOSE 8000 + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..a579362 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,46 @@ +# Alembic configuration — values prefixed with % are interpolated from env vars +# at runtime via env.py. + +[alembic] +script_location = alembic +prepend_sys_path = . + +# sqlalchemy.url is set dynamically in env.py from the DATABASE_URL env var. +# Do NOT put credentials here. +sqlalchemy.url = + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..baae821 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,58 @@ +"""Alembic environment — wires SQLAlchemy models into the migration engine.""" + +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# Load app models so Alembic can inspect metadata +from app.database import Base # noqa: F401 — registers Base.metadata +import app.models # noqa: F401 — registers all ORM classes + +# ── Alembic Config object ───────────────────────────────────────────────────── +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Inject DATABASE_URL from environment so it is never hard-coded +database_url = os.environ["DATABASE_URL"] +config.set_main_option("sqlalchemy.url", database_url) + +target_metadata = Base.metadata + + +# ── Offline migrations (no live DB connection) ──────────────────────────────── +def run_migrations_offline() -> None: + context.configure( + url=database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +# ── Online migrations (live DB connection) ──────────────────────────────────── +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..70a72b3 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,17 @@ +"""Alembic migration script template.""" + +# revision identifiers, used by Alembic. +revision = None +down_revision = None +branch_labels = None +depends_on = None + +from alembic import op # noqa + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/backend/alembic/versions/0001_initial_schema.py b/backend/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..158a5a0 --- /dev/null +++ b/backend/alembic/versions/0001_initial_schema.py @@ -0,0 +1,102 @@ +"""initial schema: users, pdfs, annotations + +Revision ID: 0001 +Revises: +Create Date: 2026-03-31 +""" + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +def upgrade() -> None: + # ── users ───────────────────────────────────────────────────────────────── + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("username", sa.String(length=64), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_users_id", "users", ["id"]) + op.create_index("ix_users_username", "users", ["username"], unique=True) + op.create_index("ix_users_email", "users", ["email"], unique=True) + + # ── pdfs ────────────────────────────────────────────────────────────────── + op.create_table( + "pdfs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("file_path", sa.Text(), nullable=False), + sa.Column("total_pages", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_pdfs_id", "pdfs", ["id"]) + op.create_index("ix_pdfs_user_id", "pdfs", ["user_id"]) + + # ── annotations ─────────────────────────────────────────────────────────── + op.create_table( + "annotations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("pdf_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("page_number", sa.Integer(), nullable=False), + sa.Column( + "canvas_data", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default="{}", + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint(["pdf_id"], ["pdfs.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "pdf_id", "user_id", "page_number", name="uq_annotation_pdf_user_page" + ), + ) + op.create_index("ix_annotations_id", "annotations", ["id"]) + op.create_index("ix_annotations_pdf_id", "annotations", ["pdf_id"]) + op.create_index("ix_annotations_user_id", "annotations", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_annotations_user_id", table_name="annotations") + op.drop_index("ix_annotations_pdf_id", table_name="annotations") + op.drop_index("ix_annotations_id", table_name="annotations") + op.drop_table("annotations") + + op.drop_index("ix_pdfs_user_id", table_name="pdfs") + op.drop_index("ix_pdfs_id", table_name="pdfs") + op.drop_table("pdfs") + + op.drop_index("ix_users_email", table_name="users") + op.drop_index("ix_users_username", table_name="users") + op.drop_index("ix_users_id", table_name="users") + op.drop_table("users") diff --git a/backend/app/main.py b/backend/app/main.py index 67f9d75..adc6c21 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,14 +3,13 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .database import Base, engine from .routers import annotations, auth, pdfs @asynccontextmanager async def lifespan(app: FastAPI): - # Create all tables on startup (use Alembic for migrations in production) - Base.metadata.create_all(bind=engine) + # Tables are managed by Alembic migrations. + # Run: alembic upgrade head (done by docker-compose entrypoint) yield diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..2fc5c65 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Docker entrypoint: wait for Postgres, run migrations, then start the app. +set -e + +echo "⏳ Running Alembic migrations..." +alembic upgrade head + +echo "🚀 Starting FastAPI..." +exec uvicorn main:app --host 0.0.0.0 --port 8000 diff --git a/backend/requirements.txt b/backend/requirements.txt index 2518b9f..f0200e7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,3 +6,4 @@ passlib[bcrypt]==1.7.4 python-jose[cryptography]==3.3.0 pydantic[email]==2.9.2 python-multipart==0.0.12 +alembic==1.14.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1112add --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +services: + + # ── PostgreSQL ──────────────────────────────────────────────────────────────── + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-lms_db} + POSTGRES_USER: ${POSTGRES_USER:-lms_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lms_user} -d ${POSTGRES_DB:-lms_db}"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - lms_net + + # ── FastAPI backend ─────────────────────────────────────────────────────────── + backend: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-lms_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lms_db} + JWT_SECRET_KEY: ${JWT_SECRET_KEY} + JWT_ALGORITHM: HS256 + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} + UPLOAD_DIR: /uploads + volumes: + - pdf_uploads:/uploads + expose: + - "8000" + networks: + - lms_net + + # ── Next.js frontend ────────────────────────────────────────────────────────── + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_URL: http://backend:8000 + restart: unless-stopped + depends_on: + - backend + environment: + NEXT_PUBLIC_API_URL: http://backend:8000 + NODE_ENV: production + ports: + - "${FRONTEND_PORT:-3000}:3000" + networks: + - lms_net + +# ── Volumes ─────────────────────────────────────────────────────────────────── +volumes: + postgres_data: + pdf_uploads: + +# ── Networks ────────────────────────────────────────────────────────────────── +networks: + lms_net: + driver: bridge diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..6507bad --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules/ +.next/ +.env +.env.* +!.env.example +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..9e80154 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,33 @@ +# ── Stage 1: deps ───────────────────────────────────────────────────────────── +FROM node:20-alpine AS deps + +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +# ── Stage 2: build ──────────────────────────────────────────────────────────── +FROM node:20-alpine AS builder + +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# NEXT_PUBLIC_API_URL is baked in at build time via docker-compose build args +ARG NEXT_PUBLIC_API_URL=http://backend:8000 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL + +RUN npm run build + +# ── Stage 3: runtime ────────────────────────────────────────────────────────── +FROM node:20-alpine AS runtime + +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 2d867d9..030da90 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + output: "standalone", // enables minimal Docker image via .next/standalone async rewrites() { return [ {