diff --git a/.gitignore b/.gitignore index 893658f..368e0dd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ .DS_Store .AppleDouble .LSOverride -Icon ? +Icon +? ._* # Editor / IDE @@ -11,7 +12,8 @@ Icon ? *.swp *.swo *~ - +**pycache__/ +**pycache** # Claude / local AI tooling .claude/ .audit.js @@ -40,3 +42,5 @@ env/ tmp/ temp/ .cache/ + +**.pdf diff --git a/backend/LLM_CONTEXT_PROMPT.md b/backend/LLM_CONTEXT_PROMPT.md new file mode 100644 index 0000000..5cf5cb5 --- /dev/null +++ b/backend/LLM_CONTEXT_PROMPT.md @@ -0,0 +1,130 @@ +# HR-ATS Backend LLM Context Prompt + +Copy everything below the line into any LLM session before asking it to write or edit backend code. + +--- + +You are coding inside **HR-ATS-Portal** (`backend/`). You must follow this house style **exactly**. Mirror neighboring files. Do not invent alternate patterns, layers, or response shapes. Prefer matching existing code over “cleaner” industry defaults. + +## Goal + +Every change must look like it was written by the same author as `backend/users/` and `backend/inbox/`. + +## Package layout (every domain) + +``` +backend// + app.py # routes only — HTTP in/out + views.py # service class — business logic + models.py # SQLModel table + classmethod DB accessors + serializers.py # hand-rolled dict builders (no Pydantic response models) + plugins.py # pure helpers (hash, JWT, clean payload) — NO FastAPI imports + permissions.py # OAuth2 scheme + Depends aliases (auth domains only) +``` + +- Bare `router = APIRouter()`; mount in `main.py` with `app.include_router(...)`. +- No package `__init__.py`. Run from `backend/` so imports are top-level (`users.app`, `db_setup`). +- Non-DB config: module-level `load_dotenv()` + `os.getenv(...)`. Do **not** extend `db_setup.Settings` for app secrets. + +## Layer duties (non-negotiable) + +| Layer | Owns | Must NOT do | +|---|---|---| +| `app.py` | Routes, inline request Pydantic models, `JSONResponse`, HTTP token envelope via serializers, inject `session` / `CurrentUser` | Business rules, SQL, JWT crypto beyond calling plugin functions | +| `views.py` | Business checks, call models, raise `HTTPException`, return ORM user (auth) or serialized dict (CRUD) | Call `serialize_token` or build login HTTP payloads | +| `models.py` | Fields, queries, inserts/updates/soft-delete, `selectinload` when needed | HTTPException, FastAPI, serializers | +| `serializers.py` | `serialize_*` → plain `dict` | DB, Depends | +| `plugins.py` | Pure helpers; raise library errors (`jwt.*`) | Import FastAPI / raise HTTPException | +| `permissions.py` | `OAuth2PasswordBearer`, `get_current_user`, `CurrentUser` alias, `require_permission` | Route handlers | + +## Exact route pattern (`app.py`) + +- Paths are verb-in-path: `/users/create`, `/users/fetch`, `/users/login` — **not** `/auth/token`, not REST-resource-only. +- Standard wrapper on every handler: + +```python +try: + service=User(session=session) + data=await service.some_method(...) + return JSONResponse(content={"data":data,"status_code":200}) +except HTTPException: + raise +except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) +``` + +- List: `{"data":items,"total":total,"status_code":200}`. By id: include `"total":1`. +- Login / refresh — **service returns ORM user**; route mints tokens and serializes: + +```python +user=await service.authenticate_user(form_data.username,form_data.password) +tokens=serialize_token(create_access_token(user),create_refresh_token(user),user) +return JSONResponse(content={**tokens,"status_code":200}) +``` + +- Request body models stay **inline in `app.py`** (`UserCreate`, `UserUpdate`, `TokenRefresh`). Never move them into `serializers.py`. +- Use `session: AsyncSession = Depends(get_session)` by default. Use `Annotated` only when required (`OAuth2PasswordRequestForm`, `CurrentUser` before other defaulted params). +- Preserve tight local spacing: `service=User(session=session)`, `detail=str(e)`. Do not pretty-reformat unrelated code. + +## Exact service pattern (`views.py`) + +```python +class User: + def __init__(self,session:AsyncSession): + self.session=session + + async def create_user(self,payload): + ... + return serialize_user(user) +``` + +- Leave service method parameters untyped (match existing). +- Raise `HTTPException(status_code=...,detail="...")` for domain errors. +- `authenticate_user` / `refresh_access_token` return the **Users ORM instance only**. +- If you loaded via an accessor that does not `selectinload(role)`, re-fetch with `get_user_by_id` before serialization that touches `user.role`. (`get_user_by_email` and `get_user_by_id` both eager-load `role` today.) + +## Exact model pattern (`models.py`) + +- SQLModel `table=True`; accessors as `@classmethod async def`. +- Soft delete sets `is_deleted=True` and `is_active=False`. +- `selectinload` relations that serializers read. +- Commits happen inside write accessors (existing convention). + +## Serializers + +- Hand-built dicts only. `str(uuid)`, `.isoformat()` for datetimes. Never include `password`. +- Token response: OAuth2 fields at **root** (`access_token`, `refresh_token`, `token_type`, `expires_in`); user record under `data`. + +## Auth (when touching users auth) + +- PyJWT access + refresh with a `type` claim; `decode_token(..., expected_type=...)` rejects mismatches. +- Protect `/users/*` with `current_user: CurrentUser` except `/users/login` and `/users/refresh`. Prefer `Depends(require_permission(...))` on mutating/list routes that need a specific tag; keep `/users/me` on plain `CurrentUser` so users can discover a missing-role state. +- `get_current_user`: decode access → DB by `sub` → reject missing/deleted/inactive → return `serialize_user(user, with_permissions=True)`. +- Login: `OAuth2PasswordRequestForm` (username = email). `tokenUrl="users/login"` (no leading slash). +- JWT `iat`/`exp` use `datetime.now(timezone.utc)` only — never naive `datetime.now()`. + +## Dependencies / env + +- Add pins to `backend/requirements.txt` under banner comments with a trailing `# why` comment. +- Put secrets in `backend/.env`; keep key names in `backend/.env.example`. + +## Hard bans + +1. No repository / use-case / DTO layers beyond inline request models. +2. No Pydantic response models; no alternate envelopes; no `/api/v1` prefix. +3. No `serialize_token` inside `views.py`. +4. No FastAPI imports in `plugins.py`. +5. No drive-by refactors, renames, or whole-file reformats. +6. RBAC exists in `users/permissions.py`; do not invent a second scheme. +7. Do not edit unrelated domains (`inbox/` vs `users/`) unless asked. +8. Do not add `__init__.py` to make packages. + +## Workflow when adding an endpoint + +1. Model accessor (if DB). +2. Service method in `views.py`. +3. `serialize_*` if new shape. +4. Route in `app.py` with the standard try/except + `JSONResponse`. +5. Add `current_user: CurrentUser` or `Depends(require_permission(...))` if the route is protected. + +Before finishing, re-read the touched files and confirm they still match a sibling file’s structure, naming, spacing, and response shape. diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..e27b3da --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,6 @@ +# Generated by alembic_setup.py. The URL is injected from the environment at runtime. +[alembic] +script_location = migrations +prepend_sys_path = . +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +timezone = UTC diff --git a/backend/alembic_setup.py b/backend/alembic_setup.py new file mode 100644 index 0000000..fd4c52c --- /dev/null +++ b/backend/alembic_setup.py @@ -0,0 +1,319 @@ +"""Alembic wiring: scaffolding, autogenerate and applying migrations. + +`backend/alembic.ini` and `backend/migrations/` are generated on first use and are +never overwritten, so the plain `alembic` CLI works alongside `db_setup.init_db()`. +Models are discovered automatically: every `/models.py` under `backend/` +is imported before the metadata is diffed against the live schema. + + python alembic_setup.py [migrate|revision|upgrade|downgrade|current|head] [-m MSG] [-r REV] + +Named `alembic_setup` rather than `alembic` because `backend/` is on `sys.path`, +so a module called `alembic.py` would shadow the installed package. +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import sys +from contextlib import asynccontextmanager +from importlib import import_module +from pathlib import Path +from typing import Any, AsyncIterator, Callable, Sequence + +from alembic import command +from alembic.autogenerate import compare_metadata +from alembic.config import Config +from alembic.runtime.migration import MigrationContext +from alembic.script import ScriptDirectory +from sqlalchemy import MetaData, text +from sqlalchemy.engine import Connection + +from db_setup import BASE_DIR, Base, close_db, database_url, get_engine, get_settings + +logger = logging.getLogger("db.alembic") + +INI = BASE_DIR / "alembic.ini" +MIGRATIONS = BASE_DIR / "migrations" +VERSIONS = MIGRATIONS / "versions" +SKIP_DIRS = {"migrations", "__pycache__", "tests", "test", ".venv", "venv", "node_modules"} +LOCK_ID = 8_412_557_390_112_004 # any 64-bit constant; serialises startup migrations + +INI_TEMPLATE = """\ +# Generated by alembic_setup.py. The URL is injected from the environment at runtime. +[alembic] +script_location = migrations +prepend_sys_path = . +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +timezone = UTC +""" + +ENV_TEMPLATE = '''"""Alembic environment -- generated by alembic_setup.py.""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +from alembic import context + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import alembic_setup as setup # noqa: E402 +import db_setup # noqa: E402 + +metadata = setup.target_metadata() +options = setup.context_options() + + +def run(connection) -> None: + context.configure(connection=connection, target_metadata=metadata, **options) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + context.configure( + url=db_setup.database_url(async_driver=False), + target_metadata=metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + **options, + ) + with context.begin_transaction(): + context.run_migrations() +elif (connection := context.config.attributes.get("connection")) is not None: + run(connection) # alembic_setup passed an already-open connection +else: + asyncio.run(setup.run_standalone(run)) # bare `alembic` CLI +''' + +MAKO_TEMPLATE = '''"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # SQLModel renders AutoString() into migrations but adds no import +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} +''' + + +def scaffold() -> None: + """Create the Alembic layout if missing; existing files are left alone.""" + VERSIONS.mkdir(parents=True, exist_ok=True) + (VERSIONS / ".gitkeep").touch() + for path, content in ( + (INI, INI_TEMPLATE), + (MIGRATIONS / "env.py", ENV_TEMPLATE), + (MIGRATIONS / "script.py.mako", MAKO_TEMPLATE), + ): + if not path.exists(): + path.write_text(content, encoding="utf-8") + logger.info("created %s", path) + + +_imported = False + + +def target_metadata() -> MetaData: + """`Base.metadata` with every discovered model module imported onto it.""" + global _imported + if _imported: + return Base.metadata + if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + names = get_settings().db_model_modules or [ + ".".join(p.relative_to(BASE_DIR).with_suffix("").parts).removesuffix(".__init__") + for p in [*BASE_DIR.rglob("models.py"), *BASE_DIR.rglob("models/__init__.py")] + if not SKIP_DIRS & set(p.relative_to(BASE_DIR).parts) + ] + for name in names: + try: + import_module(name) + except Exception as exc: + logger.warning("skipping model module %s: %s", name, exc) + _imported = True + return Base.metadata + + +def config(connection: Connection | None = None) -> Config: + scaffold() + cfg = Config(str(INI)) + cfg.set_main_option("script_location", str(MIGRATIONS)) + # ConfigParser interpolates '%', which is legal inside a password. + cfg.set_main_option("sqlalchemy.url", database_url(async_driver=False).replace("%", "%%")) + if connection is not None: + cfg.attributes["connection"] = connection + return cfg + + +VERSION_TABLE = "alembic_version" + + +def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to: Any) -> bool: + """Keep autogenerate inside the schemas this application owns.""" + s = get_settings() + if type_ != "table": + return True + if name == VERSION_TABLE: # Alembic's own bookkeeping; never ours to alter + return False + return not s.db_schemas or (obj.schema or s.db_default_schema) in s.db_schemas + + +def _skip_empty(context_: Any, revision: Any, directives: list[Any]) -> None: + """Write no revision file at all when the models and the database already agree.""" + ops = directives[0].upgrade_ops if directives else None + if ops is not None and ops.is_empty(): + directives[:] = [] + logger.info("no model changes detected") + + +def context_options() -> dict[str, Any]: + """Shared by `migrations/env.py` and the in-process drift check.""" + s = get_settings() + return { + "compare_type": True, + "compare_server_default": True, + "include_schemas": bool(s.db_schemas), + "version_table_schema": s.db_default_schema or None, + "include_object": _include_object, + "process_revision_directives": _skip_empty, + } + + +async def _run(fn: Callable[[Connection], Any]) -> Any: + """Run a synchronous Alembic call on the async engine's connection.""" + async with get_engine().connect() as conn: + result = await conn.run_sync(fn) + await conn.commit() + return result + + +async def run_standalone(fn: Callable[[Connection], Any]) -> None: + """Entry point for the bare `alembic` CLI, which brings no connection of its own.""" + try: + await _run(fn) + finally: + await close_db() + + +def head() -> str | None: + """The latest revision on disk.""" + heads = ScriptDirectory.from_config(config()).get_heads() + return heads[0] if heads else None + + +async def current() -> str | None: + """The revision the database is stamped with.""" + opts = {"version_table_schema": get_settings().db_default_schema or None} + return await _run(lambda c: MigrationContext.configure(c, opts=opts).get_current_revision()) + + +async def upgrade(revision: str = "head") -> None: + await _run(lambda c: command.upgrade(config(c), revision)) + logger.info("upgraded to %s", revision) + + +async def downgrade(revision: str = "-1") -> None: + await _run(lambda c: command.downgrade(config(c), revision)) + logger.info("downgraded to %s", revision) + + +async def autogenerate(message: str = "auto") -> str | None: + """Write a revision if the models have drifted; return its id, or None.""" + opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"} + diffs = await _run( + lambda c: compare_metadata(MigrationContext.configure(c, opts=opts), target_metadata()) + ) + if not diffs: + logger.info("schema matches the models") + return None + logger.info("%s schema difference(s) detected", len(diffs)) + before = head() + await _run(lambda c: command.revision(config(c), message=message, autogenerate=True)) + after = head() + return after if after != before else None + + +@asynccontextmanager +async def _lock() -> AsyncIterator[None]: + """Advisory lock, so only one worker migrates when several boot at once.""" + async with get_engine().connect() as conn: + await conn.execution_options(isolation_level="AUTOCOMMIT") + await conn.execute(text("SELECT pg_advisory_lock(:k)"), {"k": LOCK_ID}) + try: + yield + finally: + await conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": LOCK_ID}) + + +async def migrate(*, autogen: bool | None = None, message: str = "auto") -> None: + """Apply pending revisions, then any fresh model drift, under the lock.""" + should_autogen = get_settings().db_autogenerate if autogen is None else autogen + async with _lock(): + await upgrade() + if should_autogen and await autogenerate(message): + await upgrade() + logger.info("database at revision %s", await current()) + + +def main(argv: Sequence[str] | None = None) -> None: + parser = argparse.ArgumentParser(prog="alembic_setup.py", description=__doc__) + parser.add_argument( + "command", + nargs="?", + default="migrate", + choices=["migrate", "revision", "upgrade", "downgrade", "current", "head"], + ) + parser.add_argument("-m", "--message", default="auto", help="revision message") + parser.add_argument("-r", "--revision", help="target revision") + args = parser.parse_args(argv) + logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s") + + async def run() -> None: + from db_setup import init_db # local import: db_setup imports this module lazily + + try: + if args.command == "migrate": + await init_db() + elif args.command == "revision": + print(await autogenerate(args.message) or "no changes") + elif args.command == "upgrade": + await upgrade(args.revision or "head") + elif args.command == "downgrade": + await downgrade(args.revision or "-1") + elif args.command == "current": + print(await current()) + elif args.command == "head": + print(head()) + finally: + await close_db() + + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/backend/db_setup.py b/backend/db_setup.py new file mode 100644 index 0000000..5ce0337 --- /dev/null +++ b/backend/db_setup.py @@ -0,0 +1,256 @@ +"""PostgreSQL connection, async SQLAlchemy ORM and session management. + +Configuration comes from the environment, with `.env` read from the repo root or +from `backend/` (`Db_USERNAME`, `Db_PASSWORD`, `Db_HOST`, `Db_PORT`, `Db_NAME`, and +the `DB_*` tuning fields below). Alembic lives in `alembic_setup.py`; `init_db()` +calls into it. + + app = FastAPI(lifespan=lifespan) # migrate on startup + async def endpoint(db: AsyncSession = Depends(get_session)): ... + async with session_scope() as db: ... # workers and scripts +""" + +from __future__ import annotations + +import asyncio +import os +import logging +from contextlib import asynccontextmanager +from functools import lru_cache +from pathlib import Path +from typing import Annotated, Any, AsyncIterator, Sequence + +from pydantic import field_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict +from sqlalchemy import MetaData, text +from sqlalchemy.engine import URL, make_url +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase +from sqlmodel import SQLModel +from dotenv import load_dotenv + +load_dotenv() + +logger = logging.getLogger("db") +BASE_DIR = Path(__file__).resolve().parent + + +class Settings(BaseSettings): + """Every field is overridden by an environment variable of the same name.""" + + model_config = SettingsConfigDict( + env_file=(BASE_DIR.parent / ".env", BASE_DIR / ".env"), extra="ignore" + ) + + database_url: str = "" # full DSN; wins over the Db_* parts below + db_username: str = os.getenv("DB_USERNAME") + db_password: str = os.getenv("DB_PASSWORD") + db_host: str = os.getenv("DB_HOST") + db_port: int = int(os.getenv("DB_PORT")) + db_name: str = os.getenv("DB_NAME") + db_sslmode: str = "" # e.g. "require" on Azure + + + db_schemas: Annotated[list[str], NoDecode] = "app" + db_default_schema: str = "app" # schema for models that declare none + db_echo: bool = False + db_pool_size: int = 5 + db_max_overflow: int = 10 + db_pool_recycle: int = 1800 + db_connect_retries: int = 10 + db_auto_migrate: bool = True # run `upgrade head` on startup + db_autogenerate: bool = True # write a revision when models drift from the schema + db_model_modules: Annotated[list[str], NoDecode] = [] # empty means auto-discover + app_name: str = "hr-ats-portal" + + @field_validator("db_schemas", "db_model_modules", mode="before") + @classmethod + def _csv(cls, value: Any) -> Any: + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + return value + + def url(self, *, async_driver: bool = True) -> URL: + """DSN with the driver forced; `sslmode` is translated to asyncpg's `ssl`.""" + url = ( + make_url(self.database_url) + if self.database_url + else URL.create( + "postgresql", + self.db_username, + self.db_password, + self.db_host, + self.db_port, + self.db_name, + ) + ) + query = dict(url.query) + if self.db_sslmode: + query.setdefault("sslmode", self.db_sslmode) + if async_driver and query.pop("sslmode", None) not in (None, "disable", "allow", "prefer"): + query["ssl"] = "true" + driver = "asyncpg" if async_driver else "psycopg2" + return url.set(drivername=f"postgresql+{driver}", query=query) + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() + + +def database_url(*, async_driver: bool = True, hide_password: bool = False) -> str: + return get_settings().url(async_driver=async_driver).render_as_string(hide_password=hide_password) + + +NAMING_CONVENTION = { + "ix": "ix_%(table_name)s_%(column_0_N_name)s", + "uq": "uq_%(table_name)s_%(column_0_N_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + """Declarative base every model must inherit from.""" + + metadata = MetaData( + naming_convention=NAMING_CONVENTION, + schema=get_settings().db_default_schema or None, + ) + + +# SQLModel keeps its own registry, so `SQLModel` tables would be invisible to the +# Alembic autogenerate in alembic_setup.py, which diffs `Base.metadata` alone. +# Pointing SQLModel at the same MetaData gives both styles one registry, and lets +# SQLModel tables inherit the naming convention and the default schema above. +SQLModel.metadata = Base.metadata + + +_engine: AsyncEngine | None = None +_sessionmaker: async_sessionmaker[AsyncSession] | None = None + + +def get_engine() -> AsyncEngine: + """The process-wide AsyncEngine, created on first use.""" + global _engine + if _engine is None: + s = get_settings() + _engine = create_async_engine( + s.url(), + echo=s.db_echo, + pool_pre_ping=True, + pool_size=s.db_pool_size, + max_overflow=s.db_max_overflow, + pool_recycle=s.db_pool_recycle, + connect_args={ + "server_settings": {"timezone": "UTC", "application_name": s.app_name} + }, + ) + return _engine + + +def get_sessionmaker() -> async_sessionmaker[AsyncSession]: + global _sessionmaker + if _sessionmaker is None: + _sessionmaker = async_sessionmaker( + bind=get_engine(), class_=AsyncSession, expire_on_commit=False, autoflush=False + ) + return _sessionmaker + + +async def get_session() -> AsyncIterator[AsyncSession]: + """FastAPI dependency. Rolls back on error; committing is the caller's job.""" + async with get_sessionmaker()() as session: + try: + yield session + except Exception: + await session.rollback() + raise + + +@asynccontextmanager +async def session_scope() -> AsyncIterator[AsyncSession]: + """Transactional session for scripts and workers: commits, or rolls back on error.""" + async with get_sessionmaker()() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +async def close_db() -> None: + """Dispose of the pool and reset the cached factories.""" + global _engine, _sessionmaker + if _engine is not None: + await _engine.dispose() + logger.info("connection pool closed") + _engine, _sessionmaker = None, None + + +async def check_connection(retries: int | None = None, delay: float = 1.0) -> None: + """Wait for Postgres to answer `SELECT 1`, retrying with a capped backoff.""" + attempts = get_settings().db_connect_retries if retries is None else retries + for attempt in range(1, max(attempts, 1) + 1): + try: + async with get_engine().connect() as conn: + await conn.execute(text("SELECT 1")) + logger.info("connected to %s", database_url(hide_password=True)) + return + except Exception as exc: + if attempt >= attempts: + raise RuntimeError(f"cannot reach {database_url(hide_password=True)}") from exc + logger.warning("database not ready (%s/%s): %s", attempt, attempts, exc) + await asyncio.sleep(delay) + delay = min(delay * 2, 10.0) + + +async def create_schemas(schemas: Sequence[str] | None = None) -> None: + """`CREATE SCHEMA IF NOT EXISTS` for every configured schema.""" + names = list(get_settings().db_schemas if schemas is None else schemas) + if not names: + return + async with get_engine().begin() as conn: + for name in names: + await conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{name}"')) + logger.info("schemas ensured: %s", ", ".join(names)) + + +async def init_db(*, migrate: bool | None = None, autogen: bool | None = None) -> None: + """Connect, create the schemas, then bring migrations up to head.""" + should_migrate = get_settings().db_auto_migrate if migrate is None else migrate + await check_connection() + await create_schemas() + if should_migrate: + from alembic_setup import migrate as run_migrations # local import: avoids a cycle + + await run_migrations(autogen=autogen) + + +@asynccontextmanager +async def lifespan(app: Any = None) -> AsyncIterator[None]: + """FastAPI lifespan: `app = FastAPI(lifespan=lifespan)`.""" + await init_db() + try: + yield + finally: + await close_db() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s") + + async def _main() -> None: + try: + await init_db() + finally: + await close_db() + + asyncio.run(_main()) diff --git a/backend/inbox/app.py b/backend/inbox/app.py new file mode 100644 index 0000000..c817262 --- /dev/null +++ b/backend/inbox/app.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter,Depends, Query +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from inbox.views import Email +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + +@router.get("/email/fetch") +async def fetch_email(top:int=Query(100),skip:int=Query(0,ge=0),token=Query(...),session: AsyncSession = Depends(get_session)): + try: + if not token: + raise HTTPException(status_code=401,detail="Unauthorized") + service=Email(session=session,token=token) + data=await service.service_email(top,skip) + value=data.get("value") + items_lst=[] + for item in value: + message_id=item.get("id") + service_per_email=await service.get_email_by_id(message_id) + items_lst.append({"message_id":message_id,"email_contents":service_per_email}) + + + return JSONResponse(content={"data":items_lst,"status_code":200}) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/inbox/fetch") +async def fetch_inbox( + record_id: str | None = Query(None), + search: str | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +): + try: + service=Email(session=session) + if record_id: + item=await service.get_inbox_message_by_id(record_id) + return JSONResponse(content={"data":item,"total":1,"status_code":200}) + + items=await service.get_inbox_messages(top,skip,search) + total=await service.count_inbox_messages(search) + return JSONResponse(content={"data":items,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/inbox/decoded_attachments/Farman.pdf b/backend/inbox/decoded_attachments/Farman.pdf new file mode 100644 index 0000000..839269d Binary files /dev/null and b/backend/inbox/decoded_attachments/Farman.pdf differ diff --git a/backend/inbox/decoded_attachments/Fatima Tanveer (4) (1).pdf b/backend/inbox/decoded_attachments/Fatima Tanveer (4) (1).pdf new file mode 100644 index 0000000..ce10627 Binary files /dev/null and b/backend/inbox/decoded_attachments/Fatima Tanveer (4) (1).pdf differ diff --git a/backend/inbox/decoded_attachments/M ABDULLAH SIDDIQUI - UI UX DESIGNER - RESUME.pdf b/backend/inbox/decoded_attachments/M ABDULLAH SIDDIQUI - UI UX DESIGNER - RESUME.pdf new file mode 100644 index 0000000..7259124 Binary files /dev/null and b/backend/inbox/decoded_attachments/M ABDULLAH SIDDIQUI - UI UX DESIGNER - RESUME.pdf differ diff --git a/backend/inbox/file_decoder.py b/backend/inbox/file_decoder.py new file mode 100644 index 0000000..a7b117f --- /dev/null +++ b/backend/inbox/file_decoder.py @@ -0,0 +1,148 @@ +"""Decode Graph fileAttachment contentBytes into PDF / DOC / DOCX files.""" + +from __future__ import annotations + +import asyncio +import base64 +import binascii +import io +import zipfile +from pathlib import Path +from typing import Any + + +class AttachmentDecodeError(ValueError): + """Raised when contentBytes is malformed or is not the expected format.""" + + +_DEFAULT_OUT_DIR = Path(__file__).resolve().parent / "decoded_attachments" + + +def _decode_bytes(attachment: dict) -> bytes: + """base64 -> raw bytes. + + Graph's ``size`` often includes MIME/encoding overhead and may not equal + ``len(contentBytes)`` after decode, so it is not treated as a hard check. + """ + b64 = attachment.get("contentBytes") + if not b64: + raise AttachmentDecodeError(f"{attachment.get('name')!r}: no contentBytes") + + try: + return base64.b64decode(b64, validate=True) + except binascii.Error as exc: + raise AttachmentDecodeError( + f"{attachment.get('name')!r}: bad base64: {exc}" + ) from exc + + +def _write(out_dir: Path, name: str, raw: bytes) -> Path: + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + dest = out_dir / Path(name).name # basename only — strip path traversal + dest.write_bytes(raw) + return dest + + +def decode_pdf(attachment: dict, out_dir: str | Path) -> Path: + """Decode a PDF attachment and write it under out_dir.""" + raw = _decode_bytes(attachment) + name = attachment.get("name") + if not raw.startswith(b"%PDF-"): + raise AttachmentDecodeError(f"{name!r}: not a PDF (missing %PDF- header)") + if b"%%EOF" not in raw[-2048:]: + raise AttachmentDecodeError(f"{name!r}: not a PDF (missing %%EOF trailer)") + return _write(Path(out_dir), name or "attachment.pdf", raw) + + +def decode_docx(attachment: dict, out_dir: str | Path) -> Path: + """Decode a DOCX attachment and write it under out_dir.""" + raw = _decode_bytes(attachment) + name = attachment.get("name") + if not raw.startswith(b"PK\x03\x04"): + raise AttachmentDecodeError(f"{name!r}: not a DOCX (missing ZIP signature)") + + bio = io.BytesIO(raw) + if not zipfile.is_zipfile(bio): + raise AttachmentDecodeError(f"{name!r}: not a DOCX (invalid ZIP)") + bio.seek(0) + with zipfile.ZipFile(bio) as zf: + if not any(member.startswith("word/") for member in zf.namelist()): + raise AttachmentDecodeError(f"{name!r}: not a DOCX (no word/ entry)") + + return _write(Path(out_dir), name or "attachment.docx", raw) + + +def decode_doc(attachment: dict, out_dir: str | Path) -> Path: + """Decode a legacy DOC (OLE2) attachment and write it under out_dir.""" + raw = _decode_bytes(attachment) + name = attachment.get("name") + if raw.startswith(b"PK\x03\x04"): + raise AttachmentDecodeError( + f"{name!r}: named .doc but content is DOCX — use decode_docx" + ) + ole2 = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + if not raw.startswith(ole2): + raise AttachmentDecodeError(f"{name!r}: not a DOC (missing OLE2 signature)") + return _write(Path(out_dir), name or "attachment.doc", raw) + + +_DECODERS = { + ".pdf": decode_pdf, + ".docx": decode_docx, + ".doc": decode_doc, +} + + +def _decode_one(attachment: dict, out_dir: str | Path) -> Path: + """Route on the file extension to the right decoder.""" + ext = Path(attachment.get("name", "")).suffix.lower() + if ext not in _DECODERS: + raise AttachmentDecodeError(f"unsupported extension {ext!r}") + return _DECODERS[ext](attachment, out_dir) + + +def _normalize_attachments(attachments: Any) -> list[dict]: + """Accept None, a single dict, or a list; return only dict items.""" + if attachments is None: + return [] + if isinstance(attachments, dict): + return [attachments] + if isinstance(attachments, list): + return [a for a in attachments if isinstance(a, dict)] + return [] + + +def _decode_attachments_sync( + attachments: Any, + out_dir: str | Path | None = None, +) -> list[str]: + """Decode supported file attachments; skip empty / non-file / unsupported.""" + dest_dir = Path(out_dir) if out_dir is not None else _DEFAULT_OUT_DIR + paths: list[str] = [] + + for attachment in _normalize_attachments(attachments): + # Graph itemAttachment / referenceAttachment have no contentBytes + if not attachment.get("contentBytes"): + continue + ext = Path(attachment.get("name") or "").suffix.lower() + if ext not in _DECODERS: + continue + path = _decode_one(attachment, dest_dir).resolve() + paths.append(str(path)) + + return paths + + +async def decode_attachment( + attachments: Any, + out_dir: str | Path | None = None, +) -> list[str]: + """ + Decode Graph attachments into files under out_dir. + + Designed for views: ``await decode_attachment(data.get("attachments"))``. + Accepts None, a single attachment dict, or a list of attachment dicts. + Returns absolute file_path strings for successfully converted files. + """ + return await asyncio.to_thread(_decode_attachments_sync, attachments, out_dir) diff --git a/backend/inbox/models.py b/backend/inbox/models.py new file mode 100644 index 0000000..8b7fdf9 --- /dev/null +++ b/backend/inbox/models.py @@ -0,0 +1,176 @@ +import uuid +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy import Column, func, or_ +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, Relationship, SQLModel, select + +from users.models import Users + + +class Inbox(SQLModel, table=True): + __tablename__ = "inbox" + + id: int | None = Field(default=None, primary_key=True) + user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") + + alert_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_alerts.id") + alerts: Optional["Inbox_Alerts"] = Relationship(back_populates="inbox") + + message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id") + messages: Optional["Inbox_Messages"] = Relationship(back_populates="inbox") + + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + # is_active: bool = Field(default=True) + # is_deleted: bool = Field(default=False) + # user: Users | None = Relationship(back_populates="inbox") + + +class Inbox_Alerts(SQLModel, table=True): + __tablename__ = "inbox_alerts" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + alert_sender_name: str + alert_sender_email: str + is_read: bool = Field(default=False) + recieve_time: datetime = Field(default_factory=datetime.now) + + inbox: list[Inbox] = Relationship(back_populates="alerts") + + +class Inbox_Messages(SQLModel, table=True): + __tablename__ = "inbox_messages" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + message_id: str | None = Field(default=None, index=True, unique=True) + full_email_response: dict[str, Any] | None = Field( + default=None, sa_column=Column(JSONB) + ) + message_subject: str + message_body: str + message_sent_time: str + message_received_time: str + message_from: str + message_to: str + message_cc: str | None = Field(default=None) + message_bcc: str | None = Field(default=None) + message_read: bool = Field(default=False) + attachment: bool = Field(default=False) + message_reply: str | None = Field(default=None) + file_name: str | None = Field(default=None) + file_path: str | None = Field(default=None) + + inbox: list[Inbox] = Relationship(back_populates="messages") + + @staticmethod + def _body_text(email_data: dict) -> str: + body = email_data.get("body") + if isinstance(body, dict): + return body.get("content") or "" + if isinstance(body, str): + return body + return email_data.get("bodyPreview") or "" + + @classmethod + def _fields_from_email(cls, email_data: dict, file_path: list[str] | None = None) -> dict: + return { + "message_subject": email_data.get("subject") or "", + "message_body": cls._body_text(email_data), + "message_sent_time": email_data.get("sentDateTime") or "", + "message_read": bool(email_data.get("isRead")), + "message_received_time": email_data.get("receivedDateTime") or "", + "message_from": email_data.get("from", {}) + .get("emailAddress", {}) + .get("address", ""), + "message_to": ",".join( + [r["emailAddress"]["address"] for r in email_data.get("toRecipients", [])] + ), + "message_cc": ",".join( + [r["emailAddress"]["address"] for r in email_data.get("ccRecipients", [])] + ), + "message_bcc": ",".join( + [r["emailAddress"]["address"] for r in email_data.get("bccRecipients", [])] + ), + "attachment": bool(email_data.get("hasAttachments")), + "message_reply": ",".join( + [r["emailAddress"]["address"] for r in email_data.get("replyTo", [])] + ), + "message_id": email_data.get("id"), + "file_name": ",".join( + [r.get("name") for r in email_data.get("attachments", [])] + ), + "file_path": ",".join(file_path) if file_path else None, + "full_email_response": email_data, + } + + @classmethod + async def insert_email( + cls, + session: AsyncSession, + email_data: dict, + file_path: list[str] | None = None, + ): + fields = cls._fields_from_email(email_data, file_path) + external_id = fields.get("message_id") + + if external_id: + existing = ( + await session.execute( + select(cls).where(cls.message_id == external_id) + ) + ).scalars().first() + if existing: + for key, value in fields.items(): + setattr(existing, key, value) + session.add(existing) + await session.commit() + await session.refresh(existing) + return existing + + email = cls(**fields) + session.add(email) + await session.commit() + return email + + @classmethod + def _search_filter(cls, search: str): + pattern = f"%{search}%" + return or_( + cls.message_subject.ilike(pattern), + cls.message_from.ilike(pattern), + cls.message_body.ilike(pattern), + ) + + @classmethod + async def get_inbox_messages( + cls, session: AsyncSession, top: int | None, skip: int, search: str | None + ): + statement = select(cls).order_by(cls.message_received_time.desc()) + if search: + statement = statement.where(cls._search_filter(search)) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return result.scalars().all() + + @classmethod + async def get_inbox_message_by_id(cls, session: AsyncSession, record_id: str): + try: + uid = uuid.UUID(str(record_id)) + except ValueError: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + + @classmethod + async def count_inbox_messages(cls, session: AsyncSession, search: str | None): + statement = select(func.count()).select_from(cls) + if search: + statement = statement.where(cls._search_filter(search)) + result = await session.execute(statement) + return result.scalar_one() diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py new file mode 100644 index 0000000..b0795ed --- /dev/null +++ b/backend/inbox/plugins.py @@ -0,0 +1,32 @@ +"""Inbox helpers — attachment loading and other non-routing checks.""" + +from __future__ import annotations + +import base64 +from pathlib import Path + +from inbox.models import Inbox_Messages + + +def load_message_files(message: Inbox_Messages) -> list[dict]: + """Read files from file_path when they exist on disk.""" + if not message.file_path: + return [] + + files: list[dict] = [] + for path_str in message.file_path.split(","): + path = Path(path_str.strip()) + if not path.is_file(): + continue + try: + raw = path.read_bytes() + except OSError: + continue + files.append( + { + "file_name": path.name, + "content_base64": base64.b64encode(raw).decode("ascii"), + "size": len(raw), + } + ) + return files diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py new file mode 100644 index 0000000..712db4b --- /dev/null +++ b/backend/inbox/serializers.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from inbox.models import Inbox_Messages + + +def serialize_message(message: Inbox_Messages) -> dict: + """inbox_messages row -> the shape the #inbox Email tab renders.""" + sender_name = message.message_from + full = message.full_email_response + if isinstance(full, dict): + from_block = full.get("from") + if isinstance(from_block, dict): + email_address = from_block.get("emailAddress") + if isinstance(email_address, dict): + name = email_address.get("name") + if name: + sender_name = name + + attachment_name = None + if message.file_name: + attachment_name = message.file_name.split(",")[0].strip() or None + elif message.file_path: + attachment_name = Path(message.file_path.split(",")[0].strip()).name or None + + return { + "id": str(message.id), + "message_id": str(message.message_id) if message.message_id else None, + "sender_name": sender_name, + "fromEmail": message.message_from, + "subject": message.message_subject, + "body": message.message_body, + "when": message.message_received_time, + "unread": not message.message_read, + "attachment": message.attachment, + "attachment_name": attachment_name, + "file_name": message.file_name, + "message_to": message.message_to, + "message_cc": message.message_cc, + "message_bcc": message.message_bcc, + "message_sent_time": message.message_sent_time, + "message_reply": message.message_reply, + "file_path": message.file_path, + } diff --git a/backend/inbox/views.py b/backend/inbox/views.py new file mode 100644 index 0000000..e561a44 --- /dev/null +++ b/backend/inbox/views.py @@ -0,0 +1,70 @@ +import httpx,os +from fastapi import HTTPException +from inbox.models import Inbox_Messages +from inbox.file_decoder import decode_attachment, AttachmentDecodeError +from inbox.serializers import serialize_message +from inbox.plugins import load_message_files +from dotenv import load_dotenv +load_dotenv() +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +class Email: + def __init__(self,session:AsyncSession,token=None): + self.session=session + self.get_url=os.getenv("EMAIL_URL") + self.token=token + + async def service_email(self,top,skip): + async with httpx.AsyncClient() as client: + try: + response=await client.get(f"{self.get_url}/emails", + params={"skip":skip,"top":top}, + headers={"Authorization":f"Bearer {self.token}"} + ) + if response.status_code==200: + return response.json() + else: + raise HTTPException(status_code=response.status_code,detail=response.text) + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + async def get_email_by_id(self,message_id): + async with httpx.AsyncClient() as client: + try: + response=await client.get(f"{self.get_url}/emails/{message_id}", + headers={"Authorization":f"Bearer {self.token}"} + ) + if response.status_code==200: + data=response.json() + re_create_file=await decode_attachment(data.get("attachments")) + insert_func=await Inbox_Messages.insert_email(session=self.session,email_data=data,file_path=re_create_file) + return response.json() + else: + raise HTTPException(status_code=response.status_code,detail=response.text) + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + async def get_inbox_messages(self,top,skip,search=None): + messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search) + items=[] + for m in messages: + item=serialize_message(m) + files=load_message_files(m) + if files: + item["files"]=files + items.append(item) + return items + + async def get_inbox_message_by_id(self,record_id): + message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) + if not message: + raise HTTPException(status_code=404,detail="Message not found") + item=serialize_message(message) + files=load_message_files(message) + if files: + item["files"]=files + return item + + async def count_inbox_messages(self,search=None): + return await Inbox_Messages.count_inbox_messages(self.session,search) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..c13d9f4 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,27 @@ +import logging + +import fastapi +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from fastapi import FastAPI,APIRouter +from db_setup import lifespan +from inbox.app import router as inbox_router +from users.app import router as users_router +from role.app import router as role_router +# Without this the db/migration logs have no handler and are swallowed under uvicorn. +logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s") + +# lifespan connects to Postgres and brings migrations up to head on startup, +# and disposes of the connection pool on shutdown. +app = FastAPI(lifespan=lifespan) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(inbox_router) +app.include_router(users_router) +app.include_router(role_router) \ No newline at end of file diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..df4a181 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,39 @@ +"""Alembic environment -- generated by alembic_setup.py.""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +from alembic import context + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import alembic_setup as setup # noqa: E402 +import db_setup # noqa: E402 + +metadata = setup.target_metadata() +options = setup.context_options() + + +def run(connection) -> None: + context.configure(connection=connection, target_metadata=metadata, **options) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + context.configure( + url=db_setup.database_url(async_driver=False), + target_metadata=metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + **options, + ) + with context.begin_transaction(): + context.run_migrations() +elif (connection := context.config.attributes.get("connection")) is not None: + run(connection) # alembic_setup passed an already-open connection +else: + asyncio.run(setup.run_standalone(run)) # bare `alembic` CLI diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..76f46f7 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # SQLModel renders AutoString() into migrations but adds no import +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/.gitkeep b/backend/migrations/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/versions/20260803_1416-817ded7e4f55_initial_roles_users_inbox.py b/backend/migrations/versions/20260803_1416-817ded7e4f55_initial_roles_users_inbox.py new file mode 100644 index 0000000..696e860 --- /dev/null +++ b/backend/migrations/versions/20260803_1416-817ded7e4f55_initial_roles_users_inbox.py @@ -0,0 +1,104 @@ +"""initial roles users inbox + +Revision ID: 817ded7e4f55 +Revises: +Create Date: 2026-08-03 14:16:25.739300+00:00 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.dialects import postgresql + +revision: str = '817ded7e4f55' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('inbox_alerts', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('alert_sender_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('alert_sender_email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('is_read', sa.Boolean(), nullable=False), + sa.Column('recieve_time', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_inbox_alerts')), + schema='app' + ) + op.create_table('inbox_messages', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('full_email_response', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('message_subject', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message_body', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message_sent_time', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message_received_time', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message_from', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message_to', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message_cc', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('message_bcc', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('message_attachments', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('message_read', sa.Boolean(), nullable=False), + sa.Column('attachment', sa.Boolean(), nullable=False), + sa.Column('message_reply', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('file_path', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_inbox_messages')), + schema='app' + ) + op.create_table('roles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('role_name', sa.Enum('SYSTEM_ADMINISTRATOR', 'HR_ADMINISTRATOR', 'RECRUITER', 'HIRING_MANAGER', 'DEPARTMENT_HEAD', 'INTERVIEWER', 'CEO', 'CANDIDATE', name='enumroles'), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('permissions', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_roles')), + sa.UniqueConstraint('role_name', name=op.f('uq_roles_role_name')), + schema='app' + ) + op.create_table('users', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('role_id', sa.Integer(), nullable=True), + sa.Column('password', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['app.roles.id'], name=op.f('fk_users_role_id_roles')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_users')), + sa.UniqueConstraint('email', name=op.f('uq_users_email')), + schema='app' + ) + op.create_table('inbox', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=True), + sa.Column('alert_id', sa.Uuid(), nullable=True), + sa.Column('message_id', sa.Uuid(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['alert_id'], ['app.inbox_alerts.id'], name=op.f('fk_inbox_alert_id_inbox_alerts')), + sa.ForeignKeyConstraint(['message_id'], ['app.inbox_messages.id'], name=op.f('fk_inbox_message_id_inbox_messages')), + sa.ForeignKeyConstraint(['user_id'], ['app.users.id'], name=op.f('fk_inbox_user_id_users')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_inbox')), + schema='app' + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('inbox', schema='app') + op.drop_table('users', schema='app') + op.drop_table('roles', schema='app') + op.drop_table('inbox_messages', schema='app') + op.drop_table('inbox_alerts', schema='app') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/20260803_1452-ea9c09868aff_remove_message_attachments.py b/backend/migrations/versions/20260803_1452-ea9c09868aff_remove_message_attachments.py new file mode 100644 index 0000000..534fa38 --- /dev/null +++ b/backend/migrations/versions/20260803_1452-ea9c09868aff_remove_message_attachments.py @@ -0,0 +1,32 @@ +"""remove_message_attachments + +Revision ID: ea9c09868aff +Revises: 817ded7e4f55 +Create Date: 2026-08-03 14:52:42.420029+00:00 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # SQLModel renders AutoString() into migrations but adds no import +from sqlalchemy.dialects import postgresql + +revision: str = 'ea9c09868aff' +down_revision: Union[str, None] = '817ded7e4f55' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('inbox_messages', 'message_attachments', schema='app') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('inbox_messages', sa.Column('message_attachments', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True), schema='app') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/20260803_1520-a55e6b0a4d9a_auto.py b/backend/migrations/versions/20260803_1520-a55e6b0a4d9a_auto.py new file mode 100644 index 0000000..270bab0 --- /dev/null +++ b/backend/migrations/versions/20260803_1520-a55e6b0a4d9a_auto.py @@ -0,0 +1,34 @@ +"""auto + +Revision ID: a55e6b0a4d9a +Revises: ea9c09868aff +Create Date: 2026-08-03 15:20:16.015993+00:00 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # SQLModel renders AutoString() into migrations but adds no import + + +revision: str = 'a55e6b0a4d9a' +down_revision: Union[str, None] = 'ea9c09868aff' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('inbox_messages', sa.Column('message_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True), schema='app') + op.create_index(op.f('ix_inbox_messages_message_id'), 'inbox_messages', ['message_id'], unique=True, schema='app') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_inbox_messages_message_id'), table_name='inbox_messages', schema='app') + op.drop_column('inbox_messages', 'message_id', schema='app') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/20260803_1554-72853b8d2126_auto.py b/backend/migrations/versions/20260803_1554-72853b8d2126_auto.py new file mode 100644 index 0000000..fa120d6 --- /dev/null +++ b/backend/migrations/versions/20260803_1554-72853b8d2126_auto.py @@ -0,0 +1,32 @@ +"""auto + +Revision ID: 72853b8d2126 +Revises: a55e6b0a4d9a +Create Date: 2026-08-03 15:54:59.335413+00:00 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # SQLModel renders AutoString() into migrations but adds no import + + +revision: str = '72853b8d2126' +down_revision: Union[str, None] = 'a55e6b0a4d9a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('inbox_messages', sa.Column('file_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True), schema='app') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('inbox_messages', 'file_name', schema='app') + # ### end Alembic commands ### diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e3c9c13 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,28 @@ +# HR-ATS-Portal backend dependencies. +# pip install -r backend/requirements.txt +# Versions are the ones this backend is currently developed and verified against. + +# --- web framework --------------------------------------------------------- +fastapi==0.136.1 +uvicorn==0.47.0 + +# --- database -------------------------------------------------------------- +sqlalchemy==2.0.51 +sqlmodel==0.0.38 +alembic==1.18.4 +asyncpg==0.31.0 # async driver used by the app (postgresql+asyncpg) +psycopg2-binary==2.9.12 # sync driver for db_setup.url(async_driver=False) + +# --- settings and validation ---------------------------------------------- +pydantic==2.12.4 +pydantic-settings==2.12.0 # db_setup.Settings +python-dotenv==1.2.1 +email-validator==2.3.0 # required by pydantic EmailStr in users/app.py + +# --- auth ------------------------------------------------------------------ +PyJWT==2.10.1 # access/refresh token encode+decode in users/plugins.py +python-multipart==0.0.20 # required by OAuth2PasswordRequestForm in users/app.py + +# --- other ----------------------------------------------------------------- +httpx==0.28.1 # Graph email calls in inbox/views.py +bcrypt==5.0.0 # password hashing in users/plugins.py diff --git a/backend/role/app.py b/backend/role/app.py new file mode 100644 index 0000000..bd4875d --- /dev/null +++ b/backend/role/app.py @@ -0,0 +1,191 @@ +from fastapi import APIRouter, Depends, Query +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from role.views import Role +from users.permissions import PermissionTag, require_permission +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + + +class RoleCreate(BaseModel): + role_name: str + description: str | None = None + permissions: list[int] | None = None + is_active: bool = True + + +class RoleUpdate(BaseModel): + role_name: str | None = None + description: str | None = None + permissions: list[int] | None = None + is_active: bool | None = None + + +class PermissionCreate(BaseModel): + name: str + description: str | None = None + permission_tags: list[int] | None = None + is_active: bool = True + + +class PermissionUpdate(BaseModel): + name: str | None = None + description: str | None = None + permission_tags: list[int] | None = None + is_active: bool | None = None + + +@router.get("/roles/fetch") +async def fetch_roles( + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)), + record_id: int | None = Query(None), + search: str | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +): + try: + service=Role(session=session) + if record_id is not None: + item=await service.get_role_by_id(record_id) + return JSONResponse(content={"data":item,"total":1,"status_code":200}) + items=await service.get_roles(top,skip,search) + total=await service.count_roles(search) + return JSONResponse(content={"data":items,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/roles/create") +async def create_role( + payload: RoleCreate, + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + service=Role(session=session) + data=await service.create_role(payload.model_dump()) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.put("/roles/update") +async def update_role( + payload: RoleUpdate, + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_EDIT)), + record_id: int = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service=Role(session=session) + data=await service.update_role(record_id,payload.model_dump()) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.delete("/roles/delete") +async def delete_role( + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_DELETE)), + record_id: int = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service=Role(session=session) + data=await service.delete_role(record_id) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/permissions/fetch") +async def fetch_permissions( + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)), + record_id: int | None = Query(None), + search: str | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +): + try: + service=Role(session=session) + if record_id is not None: + item=await service.get_permission_by_id(record_id) + return JSONResponse(content={"data":item,"total":1,"status_code":200}) + items=await service.get_permissions(top,skip,search) + total=await service.count_permissions(search) + return JSONResponse(content={"data":items,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/permissions/create") +async def create_permission( + payload: PermissionCreate, + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_MANAGE)), + session: AsyncSession = Depends(get_session), +): + try: + service=Role(session=session) + data=await service.create_permission(payload.model_dump()) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.put("/permissions/update") +async def update_permission( + payload: PermissionUpdate, + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_MANAGE)), + record_id: int = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service=Role(session=session) + data=await service.update_permission(record_id,payload.model_dump()) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/permission-tags/fetch") +async def fetch_permission_tags( + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)), + record_id: int | None = Query(None), + search: str | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +): + try: + service=Role(session=session) + if record_id is not None: + item=await service.get_permission_tag_by_id(record_id) + return JSONResponse(content={"data":item,"total":1,"status_code":200}) + items=await service.get_permission_tags(top,skip,search) + total=await service.count_permission_tags(search) + return JSONResponse(content={"data":items,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/role/models.py b/backend/role/models.py new file mode 100644 index 0000000..e30298a --- /dev/null +++ b/backend/role/models.py @@ -0,0 +1,335 @@ +from datetime import datetime +from enum import Enum +from sqlalchemy import Column, UniqueConstraint, func, or_ +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, Relationship, SQLModel, select + + +class EnumRoles(str, Enum): + """Canonical keys for the eight seeded system roles. `Roles.role_name` is a varchar.""" + + SYSTEM_ADMINISTRATOR = "system_administrator" + HR_ADMINISTRATOR = "hr_administrator" + RECRUITER = "recruiter" + HIRING_MANAGER = "hiring_manager" + DEPARTMENT_HEAD = "department_head" + INTERVIEWER = "interviewer" + CEO = "ceo" + CANDIDATE = "candidate" + + +class PermissionTags(SQLModel, table=True): + __tablename__ = "permission_tags" + __table_args__ = ( + UniqueConstraint("module", "action", name="uq_permission_tags_module_action"), + ) + + id: int | None = Field(default=None, primary_key=True) + tag_name: str = Field(max_length=64, unique=True, nullable=False, index=True) + module: str = Field(max_length=32, nullable=False, index=True) + action: str = Field(max_length=32, nullable=False) + description: str | None = Field(default=None) + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + is_active: bool = Field(default=True) + is_deleted: bool = Field(default=False) + + @classmethod + def _search_filter(cls, search: str): + pattern = f"%{search}%" + return or_( + cls.tag_name.ilike(pattern), + cls.module.ilike(pattern), + cls.action.ilike(pattern), + cls.description.ilike(pattern), + ) + + @classmethod + async def get_permission_tags( + cls, session: AsyncSession, top: int | None, skip: int, search: str | None + ): + statement = ( + select(cls) + .where(cls.is_deleted == False) # noqa: E712 + .order_by(cls.module.asc(), cls.action.asc()) + ) + if search: + statement = statement.where(cls._search_filter(search)) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return result.scalars().all() + + @classmethod + async def get_permission_tag_by_id(cls, session: AsyncSession, record_id: int): + statement = select(cls).where(cls.id == record_id) + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def get_permission_tags_by_ids(cls, session: AsyncSession, ids: list[int]): + if not ids: + return [] + statement = select(cls).where( + cls.id.in_(ids), + cls.is_active == True, # noqa: E712 + cls.is_deleted == False, # noqa: E712 + ) + result = await session.execute(statement) + return result.scalars().all() + + @classmethod + async def count_permission_tags(cls, session: AsyncSession, search: str | None): + statement = ( + select(func.count()) + .select_from(cls) + .where(cls.is_deleted == False) # noqa: E712 + ) + if search: + statement = statement.where(cls._search_filter(search)) + result = await session.execute(statement) + return result.scalar_one() + + +class Permissions(SQLModel, table=True): + """Named permission bundles — each row holds a JSONB array of permission_tags.id.""" + + __tablename__ = "permissions" + + id: int | None = Field(default=None, primary_key=True) + name: str = Field(max_length=64, unique=True, nullable=False) + description: str | None = Field(default=None) + permission_tags: list | None = Field(default=None, sa_column=Column(JSONB)) + is_system: bool = Field(default=False) + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + is_active: bool = Field(default=True) + is_deleted: bool = Field(default=False) + + @classmethod + def _search_filter(cls, search: str): + pattern = f"%{search}%" + return or_(cls.name.ilike(pattern), cls.description.ilike(pattern)) + + @classmethod + async def get_permissions( + cls, session: AsyncSession, top: int | None, skip: int, search: str | None + ): + statement = ( + select(cls) + .where(cls.is_deleted == False) # noqa: E712 + .order_by(cls.name.asc()) + ) + if search: + statement = statement.where(cls._search_filter(search)) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return result.scalars().all() + + @classmethod + async def get_permission_by_id(cls, session: AsyncSession, record_id: int): + statement = select(cls).where(cls.id == record_id) + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def get_permission_by_name(cls, session: AsyncSession, name: str): + statement = select(cls).where(cls.name == name, cls.is_deleted == False) # noqa: E712 + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def get_permissions_by_ids(cls, session: AsyncSession, ids: list[int]): + if not ids: + return [] + statement = select(cls).where( + cls.id.in_(ids), + cls.is_active == True, # noqa: E712 + cls.is_deleted == False, # noqa: E712 + ) + result = await session.execute(statement) + return result.scalars().all() + + @classmethod + async def count_permissions(cls, session: AsyncSession, search: str | None): + statement = ( + select(func.count()) + .select_from(cls) + .where(cls.is_deleted == False) # noqa: E712 + ) + if search: + statement = statement.where(cls._search_filter(search)) + result = await session.execute(statement) + return result.scalar_one() + + @classmethod + async def insert_permission(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_permission_by_id(session, row.id) + + @classmethod + async def update_permission(cls, session: AsyncSession, record_id: int, fields: dict): + row = await cls.get_permission_by_id(session, record_id) + if not row: + return None + for key, value in fields.items(): + setattr(row, key, value) + row.updated_at = datetime.now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def soft_delete_permission(cls, session: AsyncSession, record_id: int): + row = await cls.get_permission_by_id(session, record_id) + if not row: + return None + row.is_deleted = True + row.is_active = False + row.updated_at = datetime.now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + +class Roles(SQLModel, table=True): + __tablename__ = "roles" + + id: int | None = Field(default=None, primary_key=True) + role_name: str = Field(max_length=64, unique=True, nullable=False) + description: str | None = Field(default=None) + permissions: list | None = Field(default=None, sa_column=Column(JSONB)) + is_system: bool = Field(default=False) + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + is_active: bool = Field(default=True) + is_deleted: bool = Field(default=False) + + users: list["Users"] = Relationship(back_populates="role") + + @classmethod + def _search_filter(cls, search: str): + pattern = f"%{search}%" + return or_(cls.role_name.ilike(pattern), cls.description.ilike(pattern)) + + @classmethod + async def get_roles( + cls, session: AsyncSession, top: int | None, skip: int, search: str | None + ): + statement = ( + select(cls) + .where(cls.is_deleted == False) # noqa: E712 + .order_by(cls.role_name.asc()) + ) + if search: + statement = statement.where(cls._search_filter(search)) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return result.scalars().all() + + @classmethod + async def get_role_by_id(cls, session: AsyncSession, record_id: int): + statement = select(cls).where(cls.id == record_id) + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def get_role_by_name(cls, session: AsyncSession, role_name: str): + statement = select(cls).where( + cls.role_name == role_name, cls.is_deleted == False # noqa: E712 + ) + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def count_roles(cls, session: AsyncSession, search: str | None): + statement = ( + select(func.count()) + .select_from(cls) + .where(cls.is_deleted == False) # noqa: E712 + ) + if search: + statement = statement.where(cls._search_filter(search)) + result = await session.execute(statement) + return result.scalar_one() + + @classmethod + async def insert_role(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_role_by_id(session, row.id) + + @classmethod + async def update_role(cls, session: AsyncSession, record_id: int, fields: dict): + row = await cls.get_role_by_id(session, record_id) + if not row: + return None + for key, value in fields.items(): + setattr(row, key, value) + row.updated_at = datetime.now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def soft_delete_role(cls, session: AsyncSession, record_id: int): + row = await cls.get_role_by_id(session, record_id) + if not row: + return None + row.is_deleted = True + row.is_active = False + row.updated_at = datetime.now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def resolve_tags(cls, session: AsyncSession, role: "Roles | None") -> tuple[str, ...]: + """roles.permissions[] → permissions.permission_tags[] → permission_tags.tag_name. + + Dangling / inactive ids contribute nothing (deny, never error). NULL or [] denies all. + """ + if role is None or not role.is_active or role.is_deleted: + return () + perm_ids = role.permissions + if not perm_ids or not isinstance(perm_ids, list): + return () + bundles = await Permissions.get_permissions_by_ids(session, [int(i) for i in perm_ids]) + tag_ids: list[int] = [] + for bundle in bundles: + raw = bundle.permission_tags + if not raw or not isinstance(raw, list): + continue + tag_ids.extend(int(i) for i in raw) + if not tag_ids: + return () + tags = await PermissionTags.get_permission_tags_by_ids(session, tag_ids) + # Stable unique order by tag id (seed order), then name as tiebreaker. + ordered = sorted(tags, key=lambda t: (t.id or 0, t.tag_name)) + seen: set[str] = set() + names: list[str] = [] + for tag in ordered: + if tag.tag_name not in seen: + seen.add(tag.tag_name) + names.append(tag.tag_name) + return tuple(names) + + +# Register Users so Roles.users Relationship can resolve (safe under circular import). +import users.models as _users_models # noqa: E402, F401 diff --git a/backend/role/serializers.py b/backend/role/serializers.py new file mode 100644 index 0000000..3d16e00 --- /dev/null +++ b/backend/role/serializers.py @@ -0,0 +1,55 @@ +from role.models import PermissionTags, Permissions, Roles + + +def serialize_permission_tag(tag: PermissionTags) -> dict: + return { + "id": tag.id, + "tag_name": tag.tag_name, + "module": tag.module, + "action": tag.action, + "description": tag.description, + "is_active": tag.is_active, + "is_deleted": tag.is_deleted, + "created_at": tag.created_at.isoformat() if tag.created_at else None, + "updated_at": tag.updated_at.isoformat() if tag.updated_at else None, + } + + +def serialize_permission( + permission: Permissions, + *, + tag_names: list[str] | None = None, +) -> dict: + return { + "id": permission.id, + "name": permission.name, + "description": permission.description, + "permission_tags": list(permission.permission_tags or []), + "tag_names": list(tag_names or []), + "is_system": permission.is_system, + "is_active": permission.is_active, + "is_deleted": permission.is_deleted, + "created_at": permission.created_at.isoformat() if permission.created_at else None, + "updated_at": permission.updated_at.isoformat() if permission.updated_at else None, + } + + +def serialize_role( + role: Roles, + *, + bundles: list[dict] | None = None, + permissions: list[str] | None = None, +) -> dict: + return { + "id": role.id, + "role_name": role.role_name, + "description": role.description, + "permissions": list(role.permissions or []), + "bundles": list(bundles or []), + "effective_permissions": list(permissions or []), + "is_system": role.is_system, + "is_active": role.is_active, + "is_deleted": role.is_deleted, + "created_at": role.created_at.isoformat() if role.created_at else None, + "updated_at": role.updated_at.isoformat() if role.updated_at else None, + } diff --git a/backend/role/views.py b/backend/role/views.py new file mode 100644 index 0000000..a53a395 --- /dev/null +++ b/backend/role/views.py @@ -0,0 +1,161 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from role.models import PermissionTags, Permissions, Roles +from role.serializers import serialize_permission, serialize_permission_tag, serialize_role + + +class Role: + def __init__(self, session: AsyncSession): + self.session = session + + async def _bundle_payload(self, permission: Permissions) -> dict: + tag_ids = [int(i) for i in (permission.permission_tags or [])] + tags = await PermissionTags.get_permission_tags_by_ids(self.session, tag_ids) + by_id = {t.id: t.tag_name for t in tags} + tag_names = [by_id[i] for i in tag_ids if i in by_id] + return serialize_permission(permission, tag_names=tag_names) + + async def _role_payload(self, role: Roles) -> dict: + perm_ids = [int(i) for i in (role.permissions or [])] + bundles_orm = await Permissions.get_permissions_by_ids(self.session, perm_ids) + by_id = {b.id: b for b in bundles_orm} + bundles = [] + for pid in perm_ids: + bundle = by_id.get(pid) + if bundle is not None: + bundles.append(await self._bundle_payload(bundle)) + tags = await Roles.resolve_tags(self.session, role) + return serialize_role(role, bundles=bundles, permissions=list(tags)) + + async def get_roles(self, top, skip, search=None): + rows = await Roles.get_roles(self.session, top, skip, search) + return [await self._role_payload(r) for r in rows] + + async def get_role_by_id(self, record_id): + role = await Roles.get_role_by_id(self.session, int(record_id)) + if not role or role.is_deleted: + raise HTTPException(status_code=404, detail="Role not found") + return await self._role_payload(role) + + async def count_roles(self, search=None): + return await Roles.count_roles(self.session, search) + + async def create_role(self, payload): + name = (payload.get("role_name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="role_name is required") + if await Roles.get_role_by_name(self.session, name): + raise HTTPException(status_code=409, detail="Role name already exists") + fields = { + "role_name": name, + "description": payload.get("description"), + "permissions": list(payload.get("permissions") or []), + "is_system": False, + "is_active": payload.get("is_active", True), + "is_deleted": False, + } + role = await Roles.insert_role(self.session, fields) + return await self._role_payload(role) + + async def update_role(self, record_id, payload): + role = await Roles.get_role_by_id(self.session, int(record_id)) + if not role or role.is_deleted: + raise HTTPException(status_code=404, detail="Role not found") + fields = {} + if "role_name" in payload and payload["role_name"] is not None: + new_name = payload["role_name"].strip() + if role.is_system and new_name != role.role_name: + raise HTTPException(status_code=409, detail="System roles cannot be renamed") + if new_name != role.role_name: + clash = await Roles.get_role_by_name(self.session, new_name) + if clash: + raise HTTPException(status_code=409, detail="Role name already exists") + fields["role_name"] = new_name + if "description" in payload and payload["description"] is not None: + fields["description"] = payload["description"] + if "permissions" in payload and payload["permissions"] is not None: + fields["permissions"] = list(payload["permissions"]) + if "is_active" in payload and payload["is_active"] is not None: + fields["is_active"] = payload["is_active"] + updated = await Roles.update_role(self.session, int(record_id), fields) + return await self._role_payload(updated) + + async def delete_role(self, record_id): + role = await Roles.get_role_by_id(self.session, int(record_id)) + if not role or role.is_deleted: + raise HTTPException(status_code=404, detail="Role not found") + if role.is_system: + raise HTTPException(status_code=409, detail="System roles cannot be deleted") + deleted = await Roles.soft_delete_role(self.session, int(record_id)) + return await self._role_payload(deleted) + + async def get_permissions(self, top, skip, search=None): + rows = await Permissions.get_permissions(self.session, top, skip, search) + return [await self._bundle_payload(r) for r in rows] + + async def get_permission_by_id(self, record_id): + row = await Permissions.get_permission_by_id(self.session, int(record_id)) + if not row or row.is_deleted: + raise HTTPException(status_code=404, detail="Permission bundle not found") + return await self._bundle_payload(row) + + async def count_permissions(self, search=None): + return await Permissions.count_permissions(self.session, search) + + async def create_permission(self, payload): + name = (payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name is required") + if await Permissions.get_permission_by_name(self.session, name): + raise HTTPException(status_code=409, detail="Permission bundle name already exists") + fields = { + "name": name, + "description": payload.get("description"), + "permission_tags": list(payload.get("permission_tags") or []), + "is_system": False, + "is_active": payload.get("is_active", True), + "is_deleted": False, + } + row = await Permissions.insert_permission(self.session, fields) + return await self._bundle_payload(row) + + async def update_permission(self, record_id, payload): + row = await Permissions.get_permission_by_id(self.session, int(record_id)) + if not row or row.is_deleted: + raise HTTPException(status_code=404, detail="Permission bundle not found") + fields = {} + if "name" in payload and payload["name"] is not None: + new_name = payload["name"].strip() + if row.is_system and new_name != row.name: + raise HTTPException( + status_code=409, detail="System permission bundles cannot be renamed" + ) + if new_name != row.name: + clash = await Permissions.get_permission_by_name(self.session, new_name) + if clash: + raise HTTPException( + status_code=409, detail="Permission bundle name already exists" + ) + fields["name"] = new_name + if "description" in payload and payload["description"] is not None: + fields["description"] = payload["description"] + if "permission_tags" in payload and payload["permission_tags"] is not None: + fields["permission_tags"] = list(payload["permission_tags"]) + if "is_active" in payload and payload["is_active"] is not None: + fields["is_active"] = payload["is_active"] + updated = await Permissions.update_permission(self.session, int(record_id), fields) + return await self._bundle_payload(updated) + + async def get_permission_tags(self, top, skip, search=None): + rows = await PermissionTags.get_permission_tags(self.session, top, skip, search) + return [serialize_permission_tag(r) for r in rows] + + async def get_permission_tag_by_id(self, record_id): + row = await PermissionTags.get_permission_tag_by_id(self.session, int(record_id)) + if not row or row.is_deleted: + raise HTTPException(status_code=404, detail="Permission tag not found") + return serialize_permission_tag(row) + + async def count_permission_tags(self, search=None): + return await PermissionTags.count_permission_tags(self.session, search) diff --git a/backend/users/app.py b/backend/users/app.py new file mode 100644 index 0000000..e6bb2e9 --- /dev/null +++ b/backend/users/app.py @@ -0,0 +1,192 @@ +from fastapi import APIRouter,Depends, Query +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, EmailStr, model_validator +from users.views import User +from users.permissions import CurrentUser, PermissionTag, require_permission +from users.serializers import serialize_token +from users.plugins import create_access_token,create_refresh_token +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + + +class UserCreate(BaseModel): + name: str + email: EmailStr + password: str + role_id: int | None = None + is_active: bool = True + + +class UserUpdate(BaseModel): + name: str | None = None + email: EmailStr | None = None + password: str | None = None + is_active: bool | None = None + + +class RoleAssign(BaseModel): + role_id: int + + +class UserLogin(BaseModel): + password: str + email: EmailStr | None = None + username: str | None = None + + @model_validator(mode="after") + def require_email_or_username(self): + if not self.email and not self.username: + raise ValueError("email or username is required") + return self + + +class TokenRefresh(BaseModel): + refresh_token: str + + +@router.post("/users/login") +async def login(payload: UserLogin,session: AsyncSession = Depends(get_session)): + try: + service=User(session=session) + user=await service.authenticate_user(payload.email or payload.username,payload.password) + tokens=serialize_token(create_access_token(user),create_refresh_token(user),user) + return JSONResponse(content={**tokens,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/users/refresh") +async def refresh(payload: TokenRefresh,session: AsyncSession = Depends(get_session)): + try: + service=User(session=session) + user=await service.refresh_access_token(payload.refresh_token) + tokens=serialize_token(create_access_token(user),create_refresh_token(user),user) + return JSONResponse(content={**tokens,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/users/me") +async def me(current_user: CurrentUser): + try: + return JSONResponse(content={"data":current_user,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/users/create") +async def create_user( + payload: UserCreate, + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + service=User(session=session) + user=await service.create_user(payload.model_dump(),current_user) + tokens=serialize_token(create_access_token(user),create_refresh_token(user),user) + return JSONResponse(content={**tokens,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/users/fetch") +async def fetch_users( + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)), + record_id: str | None = Query(None), + search: str | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +): + try: + service=User(session=session) + if record_id: + item=await service.get_user_by_id(record_id) + return JSONResponse(content={"data":item,"total":1,"status_code":200}) + + items=await service.get_users(top,skip,search) + total=await service.count_users(search) + return JSONResponse(content={"data":items,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.put("/users/update") +async def update_user( + payload: UserUpdate, + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_EDIT)), + record_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service=User(session=session) + data=await service.update_user(record_id,payload.model_dump(exclude_unset=True)) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.put("/users/assign-role") +async def assign_role( + payload: RoleAssign, + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_EDIT)), + record_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service=User(session=session) + data=await service.assign_role(record_id,payload.role_id,current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.put("/users/remove-role") +async def remove_role( + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_EDIT)), + record_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service=User(session=session) + data=await service.remove_role(record_id,current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.delete("/users/delete") +async def delete_user( + current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_DELETE)), + record_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service=User(session=session) + data=await service.delete_user(record_id) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/users/models.py b/backend/users/models.py new file mode 100644 index 0000000..6a52910 --- /dev/null +++ b/backend/users/models.py @@ -0,0 +1,119 @@ +import uuid +from datetime import datetime + +from sqlalchemy import func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from sqlmodel import Field, Relationship, SQLModel, select + +from role.models import Roles + + +class Users(SQLModel, table=True): + __tablename__ = "users" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + name: str + email: str = Field(unique=True) + role_id: int | None = Field(nullable=True, foreign_key="roles.id") + role: Roles | None = Relationship(back_populates="users") + password: str + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + is_active: bool = Field(default=True) + is_deleted: bool = Field(default=False) + + @classmethod + def _search_filter(cls, search: str): + pattern = f"%{search}%" + return or_( + cls.name.ilike(pattern), + cls.email.ilike(pattern), + ) + + @staticmethod + def _as_uuid(record_id: str) -> uuid.UUID | None: + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_users( + cls, session: AsyncSession, top: int | None, skip: int, search: str | None + ): + statement = ( + select(cls) + .options(selectinload(cls.role)) + .where(cls.is_deleted == False) + .order_by(cls.created_at.desc()) + ) + if search: + statement = statement.where(cls._search_filter(search)) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return result.scalars().all() + + @classmethod + async def get_user_by_id(cls, session: AsyncSession, record_id: str): + uid = cls._as_uuid(record_id) + if uid is None: + return None + statement = select(cls).options(selectinload(cls.role)).where(cls.id == uid) + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def get_user_by_email(cls, session: AsyncSession, email: str): + statement = select(cls).options(selectinload(cls.role)).where(cls.email == email) + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def count_users(cls, session: AsyncSession, search: str | None): + statement = ( + select(func.count()) + .select_from(cls) + .where(cls.is_deleted == False) # noqa: E712 + ) + if search: + statement = statement.where(cls._search_filter(search)) + result = await session.execute(statement) + return result.scalar_one() + + @classmethod + async def insert_user(cls, session: AsyncSession, fields: dict): + """`fields["password"]` is expected to be hashed already — see users.plugins.""" + user = cls(**fields) + session.add(user) + await session.commit() + return await cls.get_user_by_id(session, user.id) + + @classmethod + async def update_user(cls, session: AsyncSession, record_id: str, fields: dict): + user = await cls.get_user_by_id(session, record_id) + if not user: + return None + for key, value in fields.items(): + setattr(user, key, value) + user.updated_at = datetime.now() + session.add(user) + await session.commit() + await session.refresh(user) + return await cls.get_user_by_id(session, user.id) + + @classmethod + async def soft_delete_user(cls, session: AsyncSession, record_id: str): + user = await cls.get_user_by_id(session, record_id) + if not user: + return None + user.is_deleted = True + user.is_active = False + user.updated_at = datetime.now() + session.add(user) + await session.commit() + await session.refresh(user) + return user diff --git a/backend/users/permissions.py b/backend/users/permissions.py new file mode 100644 index 0000000..7b1d7e0 --- /dev/null +++ b/backend/users/permissions.py @@ -0,0 +1,242 @@ +"""HTTP Bearer scheme, current-user dependency, and RBAC enforcement. + +PermissionTag is a str Enum of every "module.action" tag. With class PermissionTag(str, Enum), +f"{PermissionTag.JOBS_VIEW}" renders "PermissionTag.JOBS_VIEW" — always use .value in JSON +and HTTPException details. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Annotated + +import jwt +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.ext.asyncio import AsyncSession + +from db_setup import get_session +from role.models import Roles +from users.models import Users +from users.plugins import decode_token +from users.serializers import serialize_user + +bearer_scheme = HTTPBearer() + + +class PermissionModule(str, Enum): + DASHBOARD = "dashboard" + INBOX = "inbox" + JOBS = "jobs" + CANDIDATES = "candidates" + PIPELINE = "pipeline" + INTERVIEWS = "interviews" + ASSESSMENTS = "assessments" + OFFERS = "offers" + REPORTS = "reports" + ANALYTICS = "analytics" + JOB_BOARD = "job_board" + SETTINGS = "settings" + RBAC_USERS = "rbac_users" + + +class PermissionAction(str, Enum): + VIEW = "view" + CREATE = "create" + EDIT = "edit" + DELETE = "delete" + APPROVE = "approve" + EXPORT = "export" + MANAGE = "manage" + CONFIGURE = "configure" + + +class PermissionTag(str, Enum): + DASHBOARD_VIEW = "dashboard.view" + DASHBOARD_CREATE = "dashboard.create" + DASHBOARD_EDIT = "dashboard.edit" + DASHBOARD_DELETE = "dashboard.delete" + DASHBOARD_APPROVE = "dashboard.approve" + DASHBOARD_EXPORT = "dashboard.export" + DASHBOARD_MANAGE = "dashboard.manage" + DASHBOARD_CONFIGURE = "dashboard.configure" + INBOX_VIEW = "inbox.view" + INBOX_CREATE = "inbox.create" + INBOX_EDIT = "inbox.edit" + INBOX_DELETE = "inbox.delete" + INBOX_APPROVE = "inbox.approve" + INBOX_EXPORT = "inbox.export" + INBOX_MANAGE = "inbox.manage" + INBOX_CONFIGURE = "inbox.configure" + JOBS_VIEW = "jobs.view" + JOBS_CREATE = "jobs.create" + JOBS_EDIT = "jobs.edit" + JOBS_DELETE = "jobs.delete" + JOBS_APPROVE = "jobs.approve" + JOBS_EXPORT = "jobs.export" + JOBS_MANAGE = "jobs.manage" + JOBS_CONFIGURE = "jobs.configure" + CANDIDATES_VIEW = "candidates.view" + CANDIDATES_CREATE = "candidates.create" + CANDIDATES_EDIT = "candidates.edit" + CANDIDATES_DELETE = "candidates.delete" + CANDIDATES_APPROVE = "candidates.approve" + CANDIDATES_EXPORT = "candidates.export" + CANDIDATES_MANAGE = "candidates.manage" + CANDIDATES_CONFIGURE = "candidates.configure" + PIPELINE_VIEW = "pipeline.view" + PIPELINE_CREATE = "pipeline.create" + PIPELINE_EDIT = "pipeline.edit" + PIPELINE_DELETE = "pipeline.delete" + PIPELINE_APPROVE = "pipeline.approve" + PIPELINE_EXPORT = "pipeline.export" + PIPELINE_MANAGE = "pipeline.manage" + PIPELINE_CONFIGURE = "pipeline.configure" + INTERVIEWS_VIEW = "interviews.view" + INTERVIEWS_CREATE = "interviews.create" + INTERVIEWS_EDIT = "interviews.edit" + INTERVIEWS_DELETE = "interviews.delete" + INTERVIEWS_APPROVE = "interviews.approve" + INTERVIEWS_EXPORT = "interviews.export" + INTERVIEWS_MANAGE = "interviews.manage" + INTERVIEWS_CONFIGURE = "interviews.configure" + ASSESSMENTS_VIEW = "assessments.view" + ASSESSMENTS_CREATE = "assessments.create" + ASSESSMENTS_EDIT = "assessments.edit" + ASSESSMENTS_DELETE = "assessments.delete" + ASSESSMENTS_APPROVE = "assessments.approve" + ASSESSMENTS_EXPORT = "assessments.export" + ASSESSMENTS_MANAGE = "assessments.manage" + ASSESSMENTS_CONFIGURE = "assessments.configure" + OFFERS_VIEW = "offers.view" + OFFERS_CREATE = "offers.create" + OFFERS_EDIT = "offers.edit" + OFFERS_DELETE = "offers.delete" + OFFERS_APPROVE = "offers.approve" + OFFERS_EXPORT = "offers.export" + OFFERS_MANAGE = "offers.manage" + OFFERS_CONFIGURE = "offers.configure" + REPORTS_VIEW = "reports.view" + REPORTS_CREATE = "reports.create" + REPORTS_EDIT = "reports.edit" + REPORTS_DELETE = "reports.delete" + REPORTS_APPROVE = "reports.approve" + REPORTS_EXPORT = "reports.export" + REPORTS_MANAGE = "reports.manage" + REPORTS_CONFIGURE = "reports.configure" + ANALYTICS_VIEW = "analytics.view" + ANALYTICS_CREATE = "analytics.create" + ANALYTICS_EDIT = "analytics.edit" + ANALYTICS_DELETE = "analytics.delete" + ANALYTICS_APPROVE = "analytics.approve" + ANALYTICS_EXPORT = "analytics.export" + ANALYTICS_MANAGE = "analytics.manage" + ANALYTICS_CONFIGURE = "analytics.configure" + JOB_BOARD_VIEW = "job_board.view" + JOB_BOARD_CREATE = "job_board.create" + JOB_BOARD_EDIT = "job_board.edit" + JOB_BOARD_DELETE = "job_board.delete" + JOB_BOARD_APPROVE = "job_board.approve" + JOB_BOARD_EXPORT = "job_board.export" + JOB_BOARD_MANAGE = "job_board.manage" + JOB_BOARD_CONFIGURE = "job_board.configure" + SETTINGS_VIEW = "settings.view" + SETTINGS_CREATE = "settings.create" + SETTINGS_EDIT = "settings.edit" + SETTINGS_DELETE = "settings.delete" + SETTINGS_APPROVE = "settings.approve" + SETTINGS_EXPORT = "settings.export" + SETTINGS_MANAGE = "settings.manage" + SETTINGS_CONFIGURE = "settings.configure" + RBAC_USERS_VIEW = "rbac_users.view" + RBAC_USERS_CREATE = "rbac_users.create" + RBAC_USERS_EDIT = "rbac_users.edit" + RBAC_USERS_DELETE = "rbac_users.delete" + RBAC_USERS_APPROVE = "rbac_users.approve" + RBAC_USERS_EXPORT = "rbac_users.export" + RBAC_USERS_MANAGE = "rbac_users.manage" + RBAC_USERS_CONFIGURE = "rbac_users.configure" + + +def _assert_vocabulary_complete() -> None: + expected = { + f"{m.value}.{a.value}" + for m in PermissionModule + for a in PermissionAction + } + actual = {t.value for t in PermissionTag} + if expected != actual: + missing = sorted(expected - actual) + extra = sorted(actual - expected) + raise RuntimeError( + f"PermissionTag vocabulary drift: missing={missing!r} extra={extra!r}" + ) + + +_assert_vocabulary_complete() + + +def has_permission( + granted: set[str] | list[str] | tuple[str, ...], + *required: PermissionTag, + require_all: bool = True, +) -> bool: + needed = {t.value for t in required} + have = set(granted or ()) + if require_all: + return needed.issubset(have) + return bool(needed & have) + + +def require_permission(*required: PermissionTag, require_all: bool = True): + """FastAPI dependency: enforce one or more PermissionTag values (AND by default).""" + + async def dependency(current_user: CurrentUser) -> dict: + if current_user.get("role_id") is None: + raise HTTPException(status_code=403, detail="User has no role assigned") + granted = current_user.get("permissions") or [] + if not has_permission(granted, *required, require_all=require_all): + if require_all and len(required) == 1: + detail = f"Missing required permission: {required[0].value}" + elif require_all: + detail = ( + "Missing required permissions: " + + ", ".join(t.value for t in required) + ) + else: + detail = ( + "Missing any of required permissions: " + + ", ".join(t.value for t in required) + ) + raise HTTPException(status_code=403, detail=detail) + return current_user + + return dependency + + +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> dict: + credentials_exception = HTTPException( + status_code=401, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = decode_token(credentials.credentials, expected_type="access") + except jwt.PyJWTError: + raise credentials_exception + + user = await Users.get_user_by_id(session, payload.get("sub")) + if not user or user.is_deleted or not user.is_active: + raise HTTPException( + status_code=401, + detail="User is inactive or does not exist", + headers={"WWW-Authenticate": "Bearer"}, + ) + permissions = await Roles.resolve_tags(session, user.role) + return serialize_user(user, with_permissions=True, permissions=permissions) + + +CurrentUser = Annotated[dict, Depends(get_current_user)] diff --git a/backend/users/plugins.py b/backend/users/plugins.py new file mode 100644 index 0000000..74546df --- /dev/null +++ b/backend/users/plugins.py @@ -0,0 +1,119 @@ +"""Users helpers — password hashing, JWT tokens, and payload cleaning. + +Uses the `bcrypt` package directly rather than passlib: passlib 1.7.4 reads +`bcrypt.__about__.__version__`, which bcrypt dropped in 4.1, and the failed +version probe makes it reject every password as longer than 72 bytes. +""" + +from __future__ import annotations + +import os +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +import bcrypt +import jwt +from dotenv import load_dotenv + +load_dotenv() + +# bcrypt hashes at most 72 bytes and raises on anything longer. +BCRYPT_MAX_BYTES = 72 + +# Columns the server owns; a client must never be able to set them. +SERVER_OWNED_FIELDS = ("id", "created_at", "updated_at", "is_deleted") + +JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY") +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30")) +REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7")) +ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60 + + +def _encode(raw: str) -> bytes: + """UTF-8 bytes truncated to what bcrypt accepts, without splitting a character.""" + return raw.encode("utf-8")[:BCRYPT_MAX_BYTES].decode("utf-8", "ignore").encode("utf-8") + + +def hash_password(raw: str) -> str: + return bcrypt.hashpw(_encode(raw), bcrypt.gensalt()).decode("ascii") + + +def verify_password(raw: str, hashed: str) -> bool: + """False rather than raising on rows written before hashing existed.""" + if not raw or not hashed: + return False + try: + return bcrypt.checkpw(_encode(raw), hashed.encode("utf-8")) + except (ValueError, TypeError): + return False + + +def clean_user_payload(payload: dict, *, partial: bool = False) -> dict: + """Strip server-owned keys and hash the password; on partial, drop unset fields.""" + fields = { + key: value + for key, value in payload.items() + if key not in SERVER_OWNED_FIELDS + } + if partial: + fields = {key: value for key, value in fields.items() if value is not None} + if fields.get("password"): + fields["password"] = hash_password(fields["password"]) + else: + fields.pop("password", None) + return fields + + +def _secret() -> str: + if not JWT_SECRET_KEY: + raise RuntimeError("JWT_SECRET_KEY is not set") + return JWT_SECRET_KEY + + +def _create_token( + subject: str, + *, + token_type: str, + expires_delta: timedelta, + claims: dict[str, Any] | None = None, +) -> str: + now = datetime.now(timezone.utc) + payload: dict[str, Any] = { + "sub": subject, + "type": token_type, + "iat": now, + "exp": now + expires_delta, + "jti": str(uuid.uuid4()), + } + if claims: + payload.update(claims) + return jwt.encode(payload, _secret(), algorithm=JWT_ALGORITHM) + + +def create_access_token(user) -> str: + return _create_token( + str(user.id), + token_type="access", + expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), + claims={ + "email": user.email, + "role_id": user.role_id, + }, + ) + + +def create_refresh_token(user) -> str: + return _create_token( + str(user.id), + token_type="refresh", + expires_delta=timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), + ) + + +def decode_token(token: str, *, expected_type: str) -> dict: + payload = jwt.decode(token, _secret(), algorithms=[JWT_ALGORITHM]) + if payload.get("type") != expected_type: + raise jwt.InvalidTokenError("Unexpected token type") + return payload diff --git a/backend/users/serializers.py b/backend/users/serializers.py new file mode 100644 index 0000000..21eb76c --- /dev/null +++ b/backend/users/serializers.py @@ -0,0 +1,42 @@ +from users.models import Users +from users.plugins import ACCESS_TOKEN_EXPIRE_SECONDS + + +def serialize_user( + user: Users, + *, + with_permissions: bool = False, + permissions: tuple[str, ...] | list[str] | None = None, +) -> dict: + """users row -> the shape the #rbac Users tab renders. Never includes password.""" + role = getattr(user, "role", None) + role_name = None + if role is not None: + role_name = getattr(role.role_name, "value", role.role_name) + + data = { + "id": str(user.id), + "name": user.name, + "email": user.email, + "role_id": user.role_id, + "role_name": role_name, + "role_description": role.description if role is not None else None, + "is_active": user.is_active, + "is_deleted": user.is_deleted, + "created_at": user.created_at.isoformat() if user.created_at else None, + "updated_at": user.updated_at.isoformat() if user.updated_at else None, + } + if with_permissions: + data["permissions"] = list(permissions or ()) + return data + + +def serialize_token(access_token: str, refresh_token: str, user: Users) -> dict: + """Login/refresh payload. OAuth2 fields live at the root so Swagger's Authorize can read them.""" + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + "expires_in": ACCESS_TOKEN_EXPIRE_SECONDS, + "data": serialize_user(user), + } diff --git a/backend/users/views.py b/backend/users/views.py new file mode 100644 index 0000000..101febd --- /dev/null +++ b/backend/users/views.py @@ -0,0 +1,113 @@ +from fastapi import HTTPException +from role.models import Roles +from users.models import Users +from users.permissions import PermissionTag,has_permission +from users.serializers import serialize_user +from users.plugins import clean_user_payload,verify_password,decode_token +from dotenv import load_dotenv +load_dotenv() +from sqlalchemy.ext.asyncio import AsyncSession +import jwt + + +class User: + def __init__(self,session:AsyncSession): + self.session=session + + async def _check_role_assignment(self,current_user,role_id,existing_role_id=None): + if role_id==existing_role_id: + return + if not has_permission(current_user.get("permissions") or [],PermissionTag.RBAC_USERS_MANAGE): + raise HTTPException(status_code=403,detail="Assigning a role requires rbac_users.manage") + if role_id is None: + return + role=await Roles.get_role_by_id(self.session,role_id) + if role is None or role.is_deleted: + raise HTTPException(status_code=404,detail="Role not found") + if not role.is_active: + raise HTTPException(status_code=400,detail="Role is not active") + target=set(await Roles.resolve_tags(self.session,role)) + missing=sorted(target-set(current_user.get("permissions") or [])) + if missing: + raise HTTPException(status_code=403,detail=f"Cannot assign a role with permissions you do not hold: {', '.join(missing)}") + + async def create_user(self,payload,current_user): + existing=await Users.get_user_by_email(self.session,payload.get("email")) + if existing: + raise HTTPException(status_code=409,detail="Email already registered") + await self._check_role_assignment(current_user,payload.get("role_id"),None) + # this is for password hasshing + fields=clean_user_payload(payload) + if not fields.get("password"): + raise HTTPException(status_code=400,detail="Password is required") + return await Users.insert_user(self.session,fields) + + async def get_users(self,top,skip,search=None): + users=await Users.get_users(self.session,top,skip,search) + return [serialize_user(u) for u in users] + + async def get_user_by_id(self,record_id): + user=await Users.get_user_by_id(self.session,record_id) + if not user: + raise HTTPException(status_code=404,detail="User not found") + return serialize_user(user) + + async def update_user(self,record_id,payload): + user=await Users.get_user_by_id(self.session,record_id) + if not user: + raise HTTPException(status_code=404,detail="User not found") + fields=clean_user_payload(payload,partial=True) + email=fields.get("email") + if email and email!=user.email: + clash=await Users.get_user_by_email(self.session,email) + if clash: + raise HTTPException(status_code=409,detail="Email already registered") + updated=await Users.update_user(self.session,record_id,fields) + return serialize_user(updated) + + async def assign_role(self,record_id,role_id,current_user): + user=await Users.get_user_by_id(self.session,record_id) + if not user: + raise HTTPException(status_code=404,detail="User not found") + await self._check_role_assignment(current_user,role_id,user.role_id) + updated=await Users.update_user(self.session,record_id,{"role_id":role_id}) + return serialize_user(updated) + + async def remove_role(self,record_id,current_user): + user=await Users.get_user_by_id(self.session,record_id) + if not user: + raise HTTPException(status_code=404,detail="User not found") + await self._check_role_assignment(current_user,None,user.role_id) + updated=await Users.update_user(self.session,record_id,{"role_id":None}) + return serialize_user(updated) + + async def delete_user(self,record_id): + user=await Users.soft_delete_user(self.session,record_id) + if not user: + raise HTTPException(status_code=404,detail="User not found") + return serialize_user(user) + + async def count_users(self,search=None): + return await Users.count_users(self.session,search) + + async def authenticate_user(self,email,password): + user=await Users.get_user_by_email(self.session,email) + if not user or not verify_password(password,user.password): + raise HTTPException( + status_code=401, + detail="Incorrect email or password", + headers={"WWW-Authenticate":"Bearer"}, + ) + if user.is_deleted or not user.is_active: + raise HTTPException(status_code=401,detail="User is inactive") + return await Users.get_user_by_id(self.session,user.id) + + async def refresh_access_token(self,refresh_token): + try: + payload=decode_token(refresh_token,expected_type="refresh") + except jwt.PyJWTError: + raise HTTPException(status_code=401,detail="Invalid or expired refresh token") + user=await Users.get_user_by_id(self.session,payload.get("sub")) + if not user or user.is_deleted or not user.is_active: + raise HTTPException(status_code=401,detail="User is inactive or does not exist") + return user diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b2b1c23 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +services: + minio: + image: minio/minio:RELEASE.2025-04-22T22-12-26Z + container_name: hrms-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + ports: + - "9000:9000" # S3 API + - "9001:9001" # web console + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + + # One-shot: creates the attachments bucket, then exits. + minio-init: + image: minio/mc:RELEASE.2025-04-16T18-13-26Z + container_name: hrms-minio-init + depends_on: + minio: + condition: service_healthy + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + MINIO_BUCKET: ${MINIO_BUCKET:-hrms-attachments} + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 \"$$MINIO_ROOT_USER\" \"$$MINIO_ROOT_PASSWORD\" && + mc mb --ignore-existing local/\"$$MINIO_BUCKET\" && + mc version enable local/\"$$MINIO_BUCKET\" && + echo 'bucket ready: '\"$$MINIO_BUCKET\" + " + + postgres: + image: postgres:16-alpine + container_name: hrms-postgres + environment: + POSTGRES_USER: ${DB_USERNAME:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-hrms} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-hrms}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + minio-data: + postgres-data: diff --git a/docs/aws-production-cost-estimate.md b/docs/aws-production-cost-estimate.md new file mode 100644 index 0000000..c84aece --- /dev/null +++ b/docs/aws-production-cost-estimate.md @@ -0,0 +1,678 @@ +# AWS Production Cost Estimate — Utopia Brands HR/ATS Portal + +**Status:** Draft for budget approval. Prepared 2026-08-04. +**Platform:** Amazon Web Services only. Every service, alternative and price in this document is AWS. +**Prepared against:** `docs/architecture/` (the signed-off-pending architecture package) and the +current state of the repository. + +--- + +## 0. The number, up front + +| Scenario | Monthly (on-demand) | Monthly (with 1-yr commitments) | Annual (committed) | +|---|---:|---:|---:| +| **A — Lean** (single-AZ, Spot workers, accepts downtime) | $454 | $404 | **$4,850** | +| **B — Recommended** (Multi-AZ, HA, the sizing the architecture specifies) | $957 | $814 | **$9,770** | +| **C — Scale** (3× volume: 100k+ applications/yr, ~75 concurrent users) | $2,800 | $2,380 | **$28,560** | + +Add **staging** (~$220/mo, or ~$140/mo with an off-hours shutdown schedule) and **AWS Support** +(Developer $29/mo, Business ~$130/mo at this spend). + +**Recommended budget line: $1,206/month all-in ($957 prod + $220 staging + $29 support) += ~$14,470 in year 2 on-demand, ~$12,760 with 1-year commitments.** + +**Year 1 is lower** because production does not exist for the first ~7 months. See §10. + +> **Unit economics.** At Option B and the architecture's midpoint volume of 40,000 +> applications/year, infrastructure costs **$0.29 per application processed**, or +> **$15.25 per named seat per month** across 66 seats. + +--- + +## 1. What this document prices, and on what basis + +Every sizing input below is taken from the architecture package or read directly out of the +repository. Nothing is invented. Where the source itself says **ASSUMPTION**, that label is carried +forward — those are the numbers most likely to move the total. + +| Input | Value | Source | +|---|---|---| +| Named seats | 66 | `02-system-architecture.md:833` (BRD §4) | +| Peak concurrent users | 20–25 | `02-system-architecture.md:834` — **ASSUMPTION** | +| Applications per year | 20,000–60,000 | `02-system-architecture.md:835` — **ASSUMPTION** | +| Documents per day at peak | 200–600 | `02-system-architecture.md:836` — **ASSUMPTION** | +| Blob volume, year one | well under 1 TB | `02-system-architecture.md:837` — **ASSUMPTION** | +| Candidate rows, several years | 10⁴–10⁵ | `02-system-architecture.md:838` — **ASSUMPTION** | +| Queue throughput | hundreds of jobs/hour | `02-system-architecture.md:839` | +| Web process | 2 vCPU / 4 GB, autoscale 1–4 | `02-system-architecture.md:439` | +| Worker process | 2 vCPU / 4 GB, concurrency 4, autoscale 1–3 | `02-system-architecture.md:440` | +| Database | PostgreSQL 16, 2 vCPU / 8 GB, PITR, 14-day backups | `02-system-architecture.md:445` | +| Cache | Redis — cache, rate limit, sessions. **Never a broker** | `02-system-architecture.md:447` | +| Queue | PostgreSQL-backed (`procrastinate`). No Redis broker, no Kafka | ADR 0004 | +| Environments | local (docker compose), staging, production. **No per-developer cloud env** | `02-system-architecture.md:490-496` | +| Max upload size | 25 MB per file | `06-api-boundaries.md:1126` — **ASSUMPTION** | +| Audit retention | 7 years, WORM/immutable archive | `02-system-architecture.md:1102` — **ASSUMPTION** | + +The volume figures are modest. This is a **66-seat internal system**, not a public SaaS. The cost +model reflects that: the dominant lines are the database and the always-on network plumbing, not +compute or storage. + +--- + +## 2. Azure → AWS service mapping + +ADR 0012 recommends Azure on the strength of assumption A1 (Utopia Brands runs Microsoft 365, so +Entra ID and Graph co-locate). The same document states plainly that **"the architecture is +unchanged and the equivalent AWS or GCP services substitute directly"** +(`02-system-architecture.md:950-956`). This is that substitution. + +| Architecture calls for | Azure (ADR 0012) | **AWS equivalent used here** | Note | +|---|---|---|---| +| Container platform, one image / two revisions | Container Apps | **ECS on Fargate** — one task definition family, two services (`web`, `worker`) | Closest 1:1 fit. See §11 for why not App Runner / EKS / EC2 | +| Managed PostgreSQL 16 | Flexible Server | **RDS for PostgreSQL 16** (Graviton) | All required extensions available: `pg_trgm`, `unaccent`, `btree_gist`, `pgcrypto`, `pgvector` | +| Object storage for CV blobs | Blob Storage | **S3** | The architecture already names S3 as *"the default"* if the cloud decision moves to AWS (`04-integrations-and-processing.md:1062`) | +| Immutable audit archive | Immutable blob container | **S3 Object Lock (Compliance mode)** | `_decisions.md` names Object Lock by name for this purpose | +| Cache / rate limit / sessions | Managed Redis | **ElastiCache for Valkey** (Redis-compatible) | Valkey is ~20% cheaper than the Redis OSS engine for the same node | +| Secret store, no keys in code | Key Vault | **Secrets Manager** (rotating) + **SSM Parameter Store** (non-secret config, free) | Accessed via ECS task role — no static credentials | +| Managed identity | Managed Identity | **IAM roles for tasks (IRSA-equivalent)** | Same "no keys anywhere" property | +| SSO | Entra ID | **Entra ID, unchanged** — federated to AWS via **IAM Identity Center** (OIDC) for console access | The application keeps using Entra as its IdP. AWS does not replace it | +| Careers mailbox | Microsoft Graph | **Microsoft Graph, unchanged** | Graph is an M365 service, not a cloud-platform service. Only the *egress path* changes (NAT Gateway) | +| CDN + TLS + WAF | Front Door | **CloudFront + ACM + AWS WAF** | ACM certificates are free | +| Load balancer | Container Apps ingress | **Application Load Balancer** | Required in front of ECS | +| Container registry | ACR | **ECR** | | +| Log workspace | Log Analytics | **CloudWatch Logs** | | +| Malware scanning | ClamAV in worker image, or Defender for Storage | **GuardDuty Malware Protection for S3** | See §7 — cheaper *and* better than ClamAV at this volume | +| OCR | (unspecified) | **Amazon Textract** as fallback behind free local parsers | | +| AI provider | contracted API provider under DPA | **Amazon Bedrock** | Satisfies the `05-security` T-13 requirement directly: zero retention, no training on customer data, in-region processing, and it is inside the same account boundary | +| Error tracking | Sentry (self-hosted or EU) | Sentry remains a third-party SaaS. **AWS-native alternative:** CloudWatch Application Signals + X-Ray | Priced as CloudWatch below; Sentry SaaS is out of AWS scope | + +**Two things AWS improves over the Azure plan, at no extra cost:** + +1. **Bedrock resolves open item BL-3.** `05-security-rbac-ai-governance.md:720` flags AI provider + data handling as an unresolved legal blocker requiring a DPA with zero-retention and no-training + terms. Bedrock provides exactly that contractually, inside the customer's own AWS account, under + the existing AWS agreement — no new vendor, no new DPA negotiation, no new data processor. +2. **Object Lock is native.** `_decisions.md` layer 4 requires a write-once audit archive. S3 Object + Lock in Compliance mode is the reference implementation of that requirement. + +--- + +## 3. Target architecture on AWS + +```mermaid +graph TB + subgraph EDGE["Edge — public"] + R53["Route 53
DNS + health checks"] + CF["CloudFront
static bundle + /api behaviour
1 TB/mo egress free"] + WAF["AWS WAF
managed rules + Bot Control
on the careers form"] + ACM["ACM
TLS certs — free"] + end + + subgraph VPC["VPC — 2 Availability Zones"] + subgraph PUB["Public subnets"] + ALB["Application Load Balancer"] + NAT["NAT Gateway x2
outbound to Graph + job boards"] + end + subgraph PRIV["Private subnets — no inbound from internet"] + WEB["ECS Fargate service: web
2 vCPU / 4 GB
desired 2, autoscale 2-4"] + WRK["ECS Fargate service: worker
2 vCPU / 4 GB
desired 1, autoscale 1-3"] + MIG["ECS RunTask: migrate
one-off, per deploy"] + end + subgraph DATA["Data — private, encrypted at rest"] + RDS[("RDS PostgreSQL 16
db.m7g.large Multi-AZ
PITR 14 days")] + EC[("ElastiCache for Valkey
cache.t4g.small x2
cache / rate limit / sessions")] + end + VPE["S3 Gateway Endpoint
FREE — keeps CV traffic off NAT"] + end + + subgraph STORE["Storage & AI — regional services"] + S3A[("S3: candidate-documents
SSE-KMS, versioning off,
7-day soft delete")] + S3B[("S3: audit-archive
Object Lock COMPLIANCE
Glacier Instant Retrieval")] + S3C[("S3: static frontend
OAC-restricted to CloudFront")] + BR["Amazon Bedrock
ATS scoring + chatbot"] + TX["Amazon Textract
OCR fallback only"] + GD["GuardDuty
Malware Protection for S3"] + end + + subgraph OPS["Security & ops"] + SM["Secrets Manager
Graph creds, DB password"] + KMS["KMS
4 customer-managed keys"] + CW["CloudWatch
logs, metrics, alarms"] + CT["CloudTrail + AWS Config"] + ECR["ECR
one image, keep last 10"] + end + + EXT["Microsoft Graph
careers mailbox + Entra ID SSO"] + + R53 --> CF + WAF --> CF + ACM -.-> CF + CF --> S3C + CF --> ALB + ALB --> WEB + WEB --> RDS + WEB --> EC + WEB --> VPE + WRK --> RDS + WRK --> VPE + WRK --> NAT + WEB --> NAT + NAT --> EXT + VPE --> S3A + RDS -.->|"LISTEN / NOTIFY"| WRK + RDS -->|"nightly closed-partition export"| S3B + S3A --> GD + WRK --> BR + WRK --> TX + WEB --> BR + WEB --> SM + WRK --> SM + KMS -.-> S3A + KMS -.-> RDS + WEB --> CW + WRK --> CW + MIG --> RDS + ECR -.-> WEB + ECR -.-> WRK + + classDef free fill:#eafff4,stroke:#004d43,stroke-width:2px + classDef costly fill:#fff4e6,stroke:#a35200,stroke-width:2px + class VPE,ACM free + class RDS,NAT,BR costly +``` + +Green = free and load-bearing. Amber = the three lines that dominate the bill. + +--- + +## 4. Pricing basis and honesty statement + +| | | +|---|---| +| **Region priced** | `us-east-1` (N. Virginia) — AWS's cheapest major region, used as the baseline | +| **Prices** | AWS **public list prices**, on-demand, as of **August 2026** | +| **Excludes** | Taxes/VAT, Enterprise Discount Program terms, AWS Marketplace software, third-party SaaS (Sentry, GitHub), staff time, and one-time engineering effort | +| **Hours/month** | 730 | +| **Currency** | USD | + +> ⚠️ **These figures were compiled from published AWS list pricing, not from a live query against the +> AWS Pricing API.** Before this document is used to commit spend, re-run every line through the +> [AWS Pricing Calculator](https://calculator.aws) for the region actually chosen. Expect individual +> line items to move by a few percent; expect the **total** to land within ±10% of Option B. + +### 4.1 Region multiplier — the single largest lever on this number + +Region is not yet decided. `02-system-architecture.md:502` records that postings span **six +jurisdictions** and that residency is unresolved (BRD OQ-4). Region choice moves the total by up +to 30%. + +| Region | Multiplier vs `us-east-1` | Option B monthly | When you would choose it | +|---|---:|---:|---| +| `us-east-1` N. Virginia | 1.00× | $957 | Cheapest; no EU/UK residency guarantee | +| `us-west-2` Oregon | 1.00× | $957 | Same price, better DR pairing with us-east-1 | +| `eu-west-1` Ireland | ~1.06× | ~$1,015 | GDPR residency, English-language jurisdiction | +| `eu-central-1` Frankfurt | ~1.12× | ~$1,072 | Strictest GDPR posture | +| `eu-west-2` London | ~1.10× | ~$1,053 | UK data residency | +| `ap-south-1` Mumbai | ~0.97× | ~$928 | Cheapest of the non-US options | +| `me-central-1` UAE | ~1.25× | ~$1,196 | Only if Gulf residency is mandated | + +**Recommendation:** if residency is genuinely unconstrained, use `us-east-1`. If any of the six +jurisdictions is in the EU/UK — which is likely — use **`eu-west-1`** and budget ~$1,015/mo for +Option B. Do not split across regions; the architecture forbids it +(`02-system-architecture.md:66` — "no region column, no tenant module"). + +--- + +## 5. Option B — Recommended production, line by line + +This is the configuration the architecture actually specifies, deployed with the availability +posture a system holding candidate PII under legal retention obligations should have. + +### 5.1 Compute + +| Line | Configuration | Calculation | $/mo | +|---|---|---|---:| +| ECS Fargate — `web` | 2 tasks × 2 vCPU / 4 GB, 24×7 | 2 × (2 × $0.04048 + 4 × $0.004445) × 730 | **144.16** | +| ECS Fargate — `worker` | 1 baseline, bursts to 3; avg 1.2 tasks × 2 vCPU / 4 GB | 1.2 × $0.09874 × 730 | **86.50** | +| ECS Fargate — `migrate` | One-off task per deploy, ~3 min, ~30 deploys/mo | 30 × 0.05 h × $0.09874 | **0.15** | +| Application Load Balancer | 1 ALB + ~2 LCU average | $0.0225 × 730 + 2 × $0.008 × 730 | **28.11** | +| | | **Compute subtotal** | **$258.92** | + +Two `web` tasks is not padding — it is the minimum for a zero-downtime rolling deploy and for +surviving the loss of one Availability Zone. The architecture's "1–4 replicas" describes the +autoscaling range; the *floor* for production HA is 2. + +### 5.2 Data + +| Line | Configuration | Calculation | $/mo | +|---|---|---|---:| +| RDS PostgreSQL 16 | `db.m7g.large` (2 vCPU / 8 GB, Graviton3), **Multi-AZ** | 2 × $0.1733 × 730 | **253.02** | +| RDS storage | 100 GB gp3, Multi-AZ (billed on both instances) | 100 × $0.23 | **23.00** | +| RDS backup storage | PITR 14 days; ~150 GB beyond the free allowance | 150 × $0.095 | **14.25** | +| ElastiCache for Valkey | `cache.t4g.small` × 2 (primary + replica, Multi-AZ) | 2 × $0.0324 × 730 | **47.30** | +| S3 — candidate documents | 500 GB S3 Standard (end-of-year-1 projection) | 500 × $0.023 | **11.50** | +| S3 — requests | ~30k PUT + ~120k GET/mo | 30 × $0.005 + 120 × $0.0004 | **0.20** | +| S3 — audit archive | 60 GB, Glacier Instant Retrieval, Object Lock Compliance | 60 × $0.004 | **0.24** | +| S3 — static frontend | ~200 MB | negligible | **0.01** | +| | | **Data subtotal** | **$349.52** | + +**Why `db.m7g.large` and not a burstable `db.t4g.large`** (which would save $158/mo Multi-AZ): the +`worker` process runs sustained CPU-bound parsing and batch rescoring against this database. A +burstable instance that exhausts its CPU credits during a bulk-import or rescore batch degrades the +*interactive* path at the same time — exactly the coupling `02-system-architecture.md:6.1` splits the +processes to avoid. `db.t4g.large` is priced in Option A and is a legitimate choice while volumes +stay at the low end of the assumed range; it is not the right default. + +**Storage grows.** At 40,000 applications/year × ~0.5 MB average CV, blob storage grows ~20 GB/month. +By year 3 that is ~1.2 TB (~$28/mo). This line is not a budget risk — S3 is the cheapest thing here. + +### 5.3 Edge and network + +| Line | Configuration | Calculation | $/mo | +|---|---|---|---:| +| CloudFront | ~200 GB egress/mo — **inside the perpetual 1 TB/mo free tier** | 0 | **0.00** | +| AWS WAF — web ACL | 1 ACL + 4 managed rule groups | $5.00 + 4 × $1.00 | **9.00** | +| AWS WAF — requests | ~2M requests/mo | 2 × $0.60 | **1.20** | +| AWS WAF — Bot Control | Targeted at the public careers form only | $10.00 + 2 × $1.00 | **12.00** | +| Route 53 | 1 hosted zone + ~2M queries | $0.50 + 2 × $0.40 | **1.30** | +| **NAT Gateway** | 2 AZ (HA) — required for Graph, job boards, ECR | 2 × $0.045 × 730 | **65.70** | +| NAT data processing | ~120 GB (S3 traffic excluded via Gateway Endpoint) | 120 × $0.045 | **5.40** | +| **S3 Gateway VPC Endpoint** | **FREE** — and it is what keeps the NAT bill small | 0 | **0.00** | +| Data transfer out (non-CloudFront) | ~20 GB | 20 × $0.09 | **1.80** | +| ACM certificates | Public certs for CloudFront/ALB | free | **0.00** | +| | | **Edge & network subtotal** | **$96.40** | + +**The NAT Gateway is the most-underestimated line in any AWS estimate**, and at $71/mo it is the +third-largest item here — more than the entire cache tier. Two design decisions keep it from being +much worse: + +- **The free S3 Gateway Endpoint is mandatory, not optional.** Every CV upload, download and + virus-scan read flows to S3. Routed through NAT instead, that traffic alone would add ~$25/mo at + year-1 volume and scale linearly with document count. Configure it on day one. +- **CloudFront's 1 TB/month free egress tier covers this workload entirely.** A 66-seat internal + tool plus a careers site will not approach 1 TB. Serving the frontend from S3 through CloudFront + is therefore genuinely free, and *cheaper* than serving it from the ALB. + +*Lean alternative:* a single NAT Gateway saves $32.85/mo but means a worker in the failed AZ loses +all outbound connectivity — Graph polling stops until ECS reschedules the task into the healthy AZ. +Given that `04-integrations-and-processing.md` promises **zero documents lost** with reconciliation, +and that intake is idempotent and replayable, one NAT is defensible. It is priced in Option A. + +### 5.4 Security and operations + +| Line | Configuration | Calculation | $/mo | +|---|---|---|---:| +| Secrets Manager | ~10 secrets (DB, Graph client secret, webhook `clientState`, job-board keys) + API calls | 10 × $0.40 + calls | **4.50** | +| SSM Parameter Store | Non-secret config (Standard tier) | free | **0.00** | +| KMS | 4 customer-managed keys (S3 docs, S3 audit, RDS, Secrets) + requests | 4 × $1.00 + $1.00 | **5.00** | +| ECR | ~20 GB across the last 10 image tags | 20 × $0.10 | **2.00** | +| CloudWatch Logs | ~15 GB/mo ingest + ~60 GB retained | 15 × $0.50 + 60 × $0.03 | **9.30** | +| CloudWatch metrics + alarms | 50 custom metrics + 30 alarms | 50 × $0.30 + 30 × $0.10 | **18.00** | +| CloudTrail | Management events free; S3 data events on the document buckets | | **2.00** | +| AWS Config | Compliance recording, ~10 rules | | **10.00** | +| GuardDuty | Account-level threat detection + **Malware Protection for S3** | see §7 | **25.00** | +| AWS Backup | RDS snapshot copies to a second region for DR | | **8.00** | +| | | **Security & ops subtotal** | **$83.80** | + +### 5.5 AI and document services (usage-based) + +These are the only lines that scale with *business* volume rather than with time. They are also +the only lines with genuinely unbounded downside if left unmonitored — see §8. + +| Line | Basis | Calculation | $/mo | +|---|---|---|---:| +| Bedrock — ATS scoring | 3,300 applications/mo (40k/yr midpoint), Claude Sonnet 4.5, prompt caching on the job-description prefix | 3,300 × $0.0214 | **70.62** | +| Bedrock — chatbot | ~4,400 queries/mo (20 active users × 10/day × 22 days), Sonnet 4.5, cached system prompt + tool schemas | 4,400 × $0.0147 | **64.68** | +| Bedrock — rescore batches | Model/config version changes trigger full re-evaluation (`05-security:519`); amortised | | **25.00** | +| Amazon Textract | OCR **fallback only** — ~5,000 pages/mo after free local parsers | 5 × $1.50 | **7.50** | +| Amazon SES | ~5,000 transactional emails/mo (optional; Graph handles most outbound) | 5 × $0.10 | **0.50** | +| | | **AI subtotal** | **$168.30** | + +Token model used for scoring, per call: ~1,200 cached prefix tokens (system prompt + job version) +at $0.30/M, ~3,000 fresh input tokens (redacted CV body) at $3.00/M, ~800 output tokens +(score + rationale + skill matches) at $15.00/M. + +### 5.6 Option B total + +| Group | $/mo | +|---|---:| +| Compute | 258.92 | +| Data | 349.52 | +| Edge & network | 96.40 | +| Security & ops | 83.80 | +| AI & document services | 168.30 | +| **Production total, on-demand** | **$956.94** | +| **Production total, with 1-yr commitments** (§9) | **$814** | + +--- + +## 6. Option A (lean) and Option C (scale) + +### 6.1 Option A — Lean: $454/mo + +Everything single-AZ. Suitable for a pilot or a first quarter in production while volumes are +proven. **Not suitable as a permanent posture** for a system under 7-year audit retention. + +| Change from Option B | Saving | +|---|---:| +| `web` 1 task instead of 2 (deploys cause a brief outage) | −$72.08 | +| `worker` on **Fargate Spot** (~70% off; safe — every task is idempotent and retried) | −$60.55 | +| RDS `db.t4g.large` **Single-AZ** instead of `db.m7g.large` Multi-AZ | −$174.16 | +| ElastiCache single `cache.t4g.micro`, no replica (degrades gracefully per `02:820`) | −$35.62 | +| 1 NAT Gateway instead of 2 | −$32.85 | +| No WAF Bot Control | −$12.00 | +| Reduced CloudWatch metrics/alarms, no AWS Config, no cross-region backup | −$21.00 | +| Claude **Haiku 4.5** for ATS scoring instead of Sonnet 4.5 ($1/M in, $5/M out) | −$47.00 | +| Smaller storage footprint in month 1–6 | −$8.00 | +| **Total saving** | **−$463** | +| **Option A total** | **$454/mo** | + +**What you give up, stated plainly:** RTO on an AZ failure goes from seconds to roughly 20–40 +minutes (RDS single-AZ restore). Every deploy is a short outage. Scoring quality drops somewhat with +Haiku — acceptable, because `05-security` mandates that AI output is **advisory only** and no +automatic path reaches a terminal-negative outcome, so a weaker model cannot reject anyone. + +### 6.2 Option C — Scale: $2,800/mo + +Priced at 3× the assumed volume — 100,000+ applications/year, ~75 concurrent users — which is where +`02-system-architecture.md:10.2`'s scaling ladder steps 2, 3, 4 and 6 have all been climbed. + +| Line | Configuration | $/mo | +|---|---|---:| +| Fargate `web` | 4 tasks × 4 vCPU / 8 GB | 576.64 | +| Fargate `worker` | avg 2.5 tasks × 4 vCPU / 8 GB | 360.40 | +| ALB | higher LCU | 45.00 | +| RDS | `db.m7g.xlarge` Multi-AZ + one `db.m7g.large` **read replica** for analytics and search | 632.00 | +| RDS storage + backups | 300 GB | 109.00 | +| ElastiCache | `cache.m7g.large` × 2 | 230.00 | +| S3 | 2 TB + requests | 47.00 | +| WAF / Route 53 / CloudFront | egress still under the free tier | 25.00 | +| NAT × 3 AZ + data | | 110.00 | +| CloudWatch / GuardDuty / Config / Secrets / KMS / ECR | | 160.00 | +| Bedrock | 3× scoring + chatbot volume | 480.00 | +| Textract + SES | | 24.00 | +| **Option C total** | | **$2,799** | + +Note that a 3× volume increase produces a ~2.9× cost increase — this architecture scales close to +linearly, with no step-function cliff. The read replica at step 6 of the ladder is the realistic +ceiling; `02-system-architecture.md:875` projects that PostgreSQL FTS + trigram will **never** be +outgrown by this system (the extraction trigger sits 3–4 orders of magnitude away). + +--- + +## 7. Malware scanning — a place where AWS is both cheaper and better + +`04-integrations-and-processing.md:1109` selects ClamAV in the worker image for Phase 1 and states +its own limitation honestly: *"ClamAV's detection rate on targeted or novel malware is materially +below a commercial multi-engine service. It is a hygiene control, not a guarantee."* It then names +the upgrade path — a cloud-native scanner behind the same `MalwareScanner` port (§7.4). + +On AWS that upgrade is available immediately, at trivial cost: + +| Option | Monthly cost at 9,000 documents / 4.5 GB | Detection quality | Operational burden | +|---|---:|---|---| +| ClamAV in the worker image | $0 direct — but adds ~400 MB to the image (toward the 2 GB T1 trigger), needs a signature-update job, and consumes worker CPU | Signature-based only | Signature freshness is your problem | +| **GuardDuty Malware Protection for S3** | 4.5 GB × $0.60 + 9 × $0.187 ≈ **$4.38** | AWS-managed multi-engine, continuously updated | Zero — event-driven on `s3:ObjectCreated` | + +**Recommendation: use GuardDuty Malware Protection for S3.** It costs about $4/month at this volume, +removes a dependency from the image, removes the signature-update scheduled job from the 24-job +catalogue, keeps the worker CPU free for parsing, and gives strictly better detection. It fits the +existing `MalwareScanner` port without changing anything above it — the adapter writes the verdict +into `virus_scan_status` exactly as `ClamAvScanner` would, and the quarantine-prefix rule at +`04-integrations-and-processing.md:635` is unchanged. + +The $25 GuardDuty line in §5.4 covers this *plus* account-level threat detection (VPC flow log, DNS +and CloudTrail analysis), which is worth having on its own. + +--- + +## 8. Bedrock cost sensitivity — the only line that can surprise you + +Everything else in this estimate is bounded by an instance size. Bedrock is bounded only by how many +times the application calls it. This table is the one to keep. + +| Scenario | Scoring model | Chatbot model | Caching | Apps/mo | Chat queries/mo | **$/mo** | +|---|---|---|---|---:|---:|---:| +| Floor | Haiku 4.5 | Haiku 4.5 | on | 1,700 | 2,000 | **$26** | +| Lean (Option A) | Haiku 4.5 | Sonnet 4.5 | on | 3,300 | 4,400 | **$88** | +| **Baseline (Option B)** | Sonnet 4.5 | Sonnet 4.5 | on | 3,300 | 4,400 | **$160** | +| No caching | Sonnet 4.5 | Sonnet 4.5 | **off** | 3,300 | 4,400 | **$209** | +| High volume | Sonnet 4.5 | Sonnet 4.5 | on | 5,000 | 8,000 | **$258** | +| Worst realistic | Sonnet 4.5 | Sonnet 4.5 | off | 5,000 | 12,000 | **$412** | +| Runaway (no guardrails) | Sonnet 4.5 | Sonnet 4.5 | off | rescore loop | unbounded | **unbounded** | + +### Four controls that must exist before Bedrock is enabled in production + +1. **The kill switch already in the design.** `05-security:376` specifies a `config` setting that + disables all provider calls and degrades the product. Wire it to a CloudWatch billing alarm. +2. **Idempotency on rescore batches.** `06-api-boundaries.md:263` already requires an idempotency + key on any `POST` that enqueues an async job, with the key doubling as the `procrastinate` + queueing lock. This is what prevents an impatient double-click from costing $200. +3. **AWS Budgets with an action.** Set a $300/mo Bedrock budget with an SNS alert at 80% and an + IAM action at 100%. Costs nothing. +4. **Enable prompt caching from day one.** It is a request parameter, not a project. On the chatbot + path — where the system prompt and tool schemas are a large fixed prefix — it cuts cost ~40%. + +### Explicitly do NOT buy Bedrock Provisioned Throughput + +Provisioned Throughput is priced per model-unit-hour and starts in the range of **$40–60/hour** +(~$30,000+/month for a single unit on a 1-month commitment). At this workload's volume that is +roughly **190× more expensive** than on-demand token pricing. It exists for sustained +high-throughput inference. Use **on-demand** token pricing. If anyone proposes Provisioned +Throughput for this system, the answer is no. + +--- + +## 9. Commitment discounts — what to buy, and when + +Do not buy any commitment until production has run for 30 days and the usage baseline is real. Then: + +| Commitment | Applies to | Discount | Monthly saving | Risk | +|---|---|---:|---:|---| +| **Compute Savings Plan**, 1-yr, no upfront | Fargate `web` + `worker` (and any future Lambda/EC2) | ~20% | **−$46** | Low — it is compute-generic, not service-locked | +| **RDS Reserved Instance**, 1-yr, no upfront | `db.m7g.large` Multi-AZ | ~33% | **−$83** | Medium — locks the instance class for 12 months | +| **ElastiCache Reserved Node**, 1-yr, no upfront | `cache.t4g.small` × 2 | ~30% | **−$14** | Low | +| **S3 Intelligent-Tiering** | Candidate documents older than 90 days | ~40% on aged objects | −$3 now, grows with volume | None — automatic | +| | | **Total** | **−$146/mo** | | + +**Do not** take 3-year terms in year one. The volume assumptions carry an explicit **ASSUMPTION** +label (`02-system-architecture.md:1218`, risk A3: *"Bulk job-board feeds could be 1–2 orders +higher"*). A 3-year RDS RI at ~52% off saves another $50/mo and would be the wrong trade against a +sizing assumption the architecture itself flags as unvalidated. + +--- + +## 10. Year-1 cash flow — production does not exist for seven months + +`07-implementation-plan.md` §15.3 states plainly that Phase 1 alone is **24–30 weeks** and that +within the first month what can be demonstrated is Phase 0 output plus the beginnings of the +Phase 1 spine — not a working ATS. Budgeting a full production environment from month one would +overstate year-1 spend by roughly $6,000. + +| Period | What exists | $/mo | Subtotal | +|---|---|---:|---:| +| Months 1–2 | Phase 0. Local `docker compose` only (`02:960`). AWS = an account, ECR, and IAM Identity Center | $50 | $100 | +| Months 3–7 | Staging live, auto-deploying on merge to `main` (`02:493`). Real test-mailbox traffic | $249 | $1,245 | +| Months 8–12 | **Production live** + staging + Developer support | $1,206 | $6,030 | +| | | **Year 1 total** | **$7,375** | +| | | **Year 2 total** (12 × $1,063 committed) | **$12,756** | +| | | **Year 3** (volume growth, ~1.2 TB storage, +15%) | **~$14,700** | + +### Staging environment detail — $220/mo + +| Line | Configuration | $/mo | +|---|---|---:| +| Fargate `web` | 1 task × 1 vCPU / 2 GB | 36.03 | +| Fargate `worker` | 1 task × 1 vCPU / 2 GB | 36.03 | +| ALB | 1 + minimal LCU | 22.27 | +| RDS | `db.t4g.medium` Single-AZ + 50 GB | 53.20 | +| ElastiCache | `cache.t4g.micro` × 1 | 11.68 | +| NAT Gateway | 1 AZ + data | 34.85 | +| S3 + CloudWatch + Secrets | | 11.15 | +| Bedrock | Mocked by default (`02:492`); real-credential smoke tests only | 15.00 | +| **Staging total** | | **$220.21** | + +**Optimisation:** stop the Fargate services and the RDS instance outside business hours with an +EventBridge rule and a small Lambda (12h × 5 days = 36% of the week). Saves ~$80/mo. The ALB and +NAT Gateway run 24×7 regardless — $57 of the $220 is irreducible. + +**No per-developer cloud environment is priced**, matching `02-system-architecture.md:496`: +*"Two developers do not need six environments; they need one that behaves like production."* +Each additional full environment would add ~$220/mo. + +--- + +## 11. Alternatives considered and rejected + +| Option | Monthly (prod-equivalent) | Verdict | +|---|---:|---| +| **ECS on Fargate** | $231 compute | **Chosen.** Maps 1:1 onto ADR 0012's "one image, two revisions". No servers to patch, per-second billing, native autoscaling on both HTTP metrics and queue depth | +| **AWS App Runner** | ~$228 for web alone | **Rejected.** $0.064/vCPU-hr + $0.007/GB-hr is ~55% more than Fargate for the same shape, and it has no clean model for a long-running queue-consumer process. It optimises for a request-driven service, which is exactly half of this workload | +| **Amazon EKS** | +$73/mo control plane, before nodes | **Rejected.** The architecture's binding constraint is *two developers, no ops staff* (`02:496`, `_decisions.md:287`). Kubernetes adds a control plane, an upgrade cadence, an add-on ecosystem and a second scheduler to reason about, for zero capability this workload uses | +| **EC2 + Docker Compose** | ~$120 for 2 × `t4g.medium` | **Rejected.** The cheapest option on paper and the most expensive in practice: OS patching, AMI rebuilds, log shipping and capacity management all become the two developers' problem. Saves ~$110/mo and costs several days per quarter | +| **AWS Lambda for the worker** | ~$20 | **Rejected.** The 15-minute ceiling is survivable, but the worker holds a `procrastinate` LISTEN/NOTIFY connection and runs multi-second CPU-bound parsing with a memory cap and restricted OS user (`04:1114`) — a persistent process, not an event handler | +| **Aurora Serverless v2** | $44 floor, ~$175 realistic + I/O charges | **Rejected as default.** The worker keeps a persistent connection, so it never scales to the floor. Compute is comparable but I/O-per-request billing makes the monthly number unpredictable — the opposite of what a budget document needs. Reconsider at Option C scale with I/O-Optimized | +| **RDS Multi-AZ *cluster*** (2 readable standbys) | ~$380 | **Rejected for Phase 1.** ~$127/mo more than Multi-AZ instance deployment for a read-scaling capability this workload does not need until step 6 of the scaling ladder | +| **Amazon OpenSearch for search** | +$150 minimum | **Rejected.** ADR-level decision: Phase 1 search is PostgreSQL FTS + `pg_trgm`, and `02:875` projects the extraction trigger sits 3–4 orders of magnitude away. Adding OpenSearch now buys a second datastore, a second backup story and a sync problem, for nothing | +| **Amazon MQ / MSK for the queue** | +$130 / +$300 | **Rejected.** ADR 0004 makes the queue PostgreSQL-backed specifically to preserve transactional enqueue. Kafka is named in `_decisions.md` as explicitly out of scope for Phase 1 | +| **Bedrock Provisioned Throughput** | ~$30,000 | **Rejected.** ~190× on-demand at this volume. See §8 | +| **VPC Interface Endpoints** (ECR, Secrets, Logs, Bedrock) | +$58/mo | **Rejected at this scale.** 4 services × 2 AZ × $0.01/hr costs more than the NAT data processing it would displace ($5.40). The **S3 Gateway Endpoint is free and is kept.** Revisit interface endpoints at Option C, or if a compliance requirement forbids internet egress | + +--- + +## 12. Cost optimisation levers, ranked by saving per unit of effort + +| # | Lever | Saving | Effort | Do it? | +|---|---|---:|---|---| +| 1 | **S3 Gateway Endpoint** (free) so CV traffic bypasses NAT | ~$25/mo, grows with volume | 5 minutes of Terraform | **Day one, non-negotiable** | +| 2 | **Serve the frontend from S3 + CloudFront**, not the ALB — 1 TB/mo egress is free | ~$20/mo + lower ALB LCU | Already the plan | **Day one** | +| 3 | **Fargate Spot for the `worker` service** — tasks are idempotent and retried by design | ~$61/mo | One line in the capacity provider strategy | **Yes** | +| 4 | **Prompt caching on all Bedrock calls** | ~$49/mo | A request parameter | **Yes** | +| 5 | **1-yr Compute Savings Plan + RDS RI** after 30 days of real baseline | ~$143/mo | One purchase | **Yes, at month 2 of production** | +| 6 | **Off-hours shutdown for staging** (EventBridge + Lambda) | ~$80/mo | Half a day | **Yes** | +| 7 | **Haiku 4.5 for bulk ATS scoring**, Sonnet reserved for the chatbot | ~$47/mo | A model-id config change; AI is advisory only, so quality risk is contained | Evaluate | +| 8 | **S3 Intelligent-Tiering** on candidate documents | $3/mo now, ~$20/mo by year 3 | A bucket lifecycle rule | **Yes** | +| 9 | **Graviton everywhere** (`m7g`, `t4g`, `cache.t4g`) | ~15% vs x86, already in the estimate | Build ARM64 images | **Already assumed — do not regress to x86** | +| 10 | **Single NAT Gateway** | $33/mo | Config | Only in Option A | +| 11 | **CloudWatch log retention 30 days**, archive to S3 beyond | ~$5/mo | A retention setting | Yes | +| 12 | **Delete the `decoded_attachments/` local-disk path** (see §13) | Prevents an EFS line item of ~$30–150/mo | Real engineering work | **Required regardless** | + +Levers 1–6 and 8 together save **$381/mo** — 40% of the Option B bill — and none of them changes the +architecture. + +--- + +## 13. Repository gaps that must close before this estimate holds + +The cost model above assumes the application is deployable as the architecture describes. Five +things in the repository today contradict that. Four are correctness problems that also have a cost +consequence. + +| # | Finding | Cost consequence if not fixed | +|---|---|---| +| 1 | **Attachments are written to local disk.** [file_decoder.py:18](backend/inbox/file_decoder.py#L18) sets `_DEFAULT_OUT_DIR` to a directory beside the source file, and [views.py:40-41](backend/inbox/views.py#L40-L41) stores that absolute path in `Inbox_Messages.file_path`. On Fargate the task filesystem is **ephemeral and per-task** — files vanish on restart and are invisible to the other `web` replica | Must move to S3 (already budgeted at $11.50/mo). "Fixing" it with EFS instead adds **$30–150/mo** ($0.30/GB-mo Standard, plus throughput) and reintroduces a shared mutable filesystem the architecture does not want | +| 2 | **No Dockerfile exists.** `docker-compose.yml` provisions only `minio` and `postgres` — the application itself is not containerised | Blocks ECS entirely. Prerequisite engineering, not an AWS cost | +| 3 | **Migrations run on application startup.** [db_setup.py:226-244](backend/db_setup.py#L226-L244) — `lifespan` calls `init_db()`, which runs Alembic to head when `db_auto_migrate` is set. With 2+ `web` tasks this is a concurrent-migration race on every deploy | Move to the one-off ECS `migrate` RunTask already priced at $0.15/mo. Set `db_auto_migrate=false` in the task definition | +| 4 | **CORS is `allow_origins=["*"]` with `allow_credentials=True`.** [main.py:16-22](backend/main.py#L16-L22) — browsers reject this combination outright, and no CloudFront or WAF configuration compensates for it | None directly, but it will look like a CDN misconfiguration and burn debugging time at go-live | +| 5 | **Database credentials live in `backend/.env`.** `.gitignore` correctly excludes it, but the deployment model must be Secrets Manager + ECS task role, never an env file baked into an image | Already budgeted at $4.50/mo | + +Item 1 is the one that matters most for this document: it is the difference between an $11.50/mo +storage line and a $150/mo one, and it has to be resolved before the first production deploy either +way. + +--- + +## 14. What would change this number + +| # | Risk | Direction | Magnitude | +|---|---|---|---| +| 1 | **Region is not `us-east-1`** (likely — six jurisdictions, GDPR unresolved) | ↑ | +6% to +25% (§4.1) | +| 2 | **Legal requires self-hosted models** instead of Bedrock. `05-security:720` (BL-3) names this: *"Phase 1 gains GPU infrastructure and an MLOps burden two developers cannot absorb"* | ↑↑↑ | A single `g5.xlarge` is ~$730/mo on-demand; realistic HA inference is **$1,500–3,000/mo**, more than doubling the total | +| 3 | **Bulk job-board feeds arrive.** Risk A3 (`02:1218`) warns volumes could be *"1–2 orders higher"* | ↑↑ | Option C, or beyond | +| 4 | **Data residency forces multi-region.** Directly conflicts with the one-database constraint (`_decisions.md:283`) and requires a business exception | ↑↑ | Roughly ×1.8 — a second full stack | +| 5 | Audit retention exceeds 7 years, or the immutable archive grows faster than projected | ↑ | Small — Glacier Deep Archive is $0.00099/GB-mo | +| 6 | Chatbot adoption exceeds 20 active users | ↑ | +$15/mo per additional 1,000 queries | +| 7 | `pgvector` embeddings land in Phase 2 (migration 028, ~200,000 rows per `03-database-design.md:2607`) | ↑ | +$20/mo Bedrock embeddings, +~5 GB storage. Negligible — this is exactly why the architecture put vectors in the same database | +| 8 | Volumes stay at the **low** end (20k applications/yr, not 60k) | ↓ | −$80/mo | +| 9 | Enterprise Discount Program / Private Pricing, if Utopia Brands has existing AWS spend | ↓ | −5% to −15% | + +--- + +## 15. Recommendations + +1. **Budget $1,206/month** for a fully HA production plus staging plus Developer support, in + `us-east-1`. If EU/UK residency is required — decide this before provisioning — budget + **$1,270/month** in `eu-west-1`. +2. **Deploy Option A (lean, $454/mo) for the first production quarter**, then move to Option B once + real volume is observed. The migration between them is instance-class changes and a replica + count — hours of work, no re-architecture. +3. **Adopt Amazon Bedrock.** It closes open item BL-3 (`05-security:720`) under the existing AWS + agreement, with no new data processor and no new DPA to negotiate. This is the strongest + platform-specific argument for AWS in this whole document. +4. **Replace ClamAV with GuardDuty Malware Protection for S3** at ~$4/mo (§7). Better detection, + smaller image, one fewer scheduled job. +5. **Configure the free S3 Gateway Endpoint on day one.** It is the single highest-value free + configuration change available and its value grows with document volume. +6. **Fix the local-disk attachment path** ([file_decoder.py:18](backend/inbox/file_decoder.py#L18)) + before the first deploy. It is the only repository finding with a real cost consequence. +7. **Set an AWS Budget of $1,400/month with alerts at 80% and 100%**, plus a separate $300 Bedrock + budget wired to the kill switch already specified at `05-security:376`. +8. **Buy no commitments until production has 30 days of real baseline**, then take 1-year + no-upfront terms only. The volume assumptions are labelled ASSUMPTION for a reason. +9. **Re-validate every line in the AWS Pricing Calculator** for the chosen region before this + document is used to commit spend (§4). + +--- + +## Appendix A — Unit prices used + +| Service | Unit | Price (`us-east-1`, Aug 2026) | +|---|---|---| +| Fargate | vCPU-hour / GB-hour | $0.04048 / $0.004445 | +| Fargate Spot | — | ~70% off on-demand | +| ALB | hour / LCU-hour | $0.0225 / $0.008 | +| RDS `db.m7g.large` PostgreSQL | instance-hour (Single-AZ) | ~$0.1733 | +| RDS `db.t4g.large` / `db.t4g.medium` | instance-hour | ~$0.1296 / ~$0.0650 | +| RDS gp3 storage | GB-month (Single-AZ / Multi-AZ) | $0.115 / $0.23 | +| RDS backup beyond free tier | GB-month | $0.095 | +| ElastiCache `cache.t4g.small` / `.micro` | node-hour | ~$0.0324 / ~$0.016 | +| S3 Standard | GB-month | $0.023 | +| S3 PUT / GET | per 1,000 | $0.005 / $0.0004 | +| S3 Glacier Instant Retrieval | GB-month | $0.004 | +| S3 Gateway VPC Endpoint | — | **free** | +| CloudFront egress | GB (first 10 TB, after 1 TB/mo free) | $0.085 | +| AWS WAF | web ACL / rule / million requests | $5.00 / $1.00 / $0.60 | +| AWS WAF Bot Control | month / million requests | $10.00 / $1.00 | +| NAT Gateway | hour / GB processed | $0.045 / $0.045 | +| Route 53 | hosted zone / million queries | $0.50 / $0.40 | +| Secrets Manager | secret-month / 10k API calls | $0.40 / $0.05 | +| KMS | key-month / 10k requests | $1.00 / $0.03 | +| ECR | GB-month | $0.10 | +| CloudWatch Logs | GB ingest / GB-month stored | $0.50 / $0.03 | +| CloudWatch | custom metric-month / alarm-month | $0.30 / $0.10 | +| GuardDuty Malware Protection for S3 | GB scanned / 1,000 objects | $0.60 / $0.187 | +| Textract `DetectDocumentText` | 1,000 pages | $1.50 | +| SES | 1,000 outbound emails | $0.10 | +| Bedrock — Claude Sonnet 4.5 | 1M input / 1M output tokens | $3.00 / $15.00 | +| Bedrock — Claude Sonnet 4.5 cache | 1M cache write / 1M cache read | $3.75 / $0.30 | +| Bedrock — Claude Haiku 4.5 | 1M input / 1M output tokens | $1.00 / $5.00 | +| ACM public certificates | — | **free** | +| SSM Parameter Store (Standard) | — | **free** | +| CloudTrail management events (first trail) | — | **free** | + +## Appendix B — Cost allocation tags + +Apply these on every resource from day one; retrofitting tags is the reason most AWS bills are +unattributable. + +| Tag | Values | +|---|---| +| `Project` | `hr-ats-portal` | +| `Environment` | `production` \| `staging` | +| `Component` | `web` \| `worker` \| `database` \| `cache` \| `storage` \| `edge` \| `ai` \| `observability` | +| `CostCentre` | (Utopia Brands HR) | +| `Owner` | `talha` \| `ahmed` | +| `DataClass` | `candidate-pii` \| `audit` \| `public` | + +Activate them as **cost allocation tags** in the Billing console — they are not usable in Cost +Explorer until you do, and activation is not retroactive. diff --git a/index.html b/index.html index c2ccd8d..e24d668 100644 --- a/index.html +++ b/index.html @@ -264,6 +264,7 @@ + diff --git a/js/api.js b/js/api.js new file mode 100644 index 0000000..304253a --- /dev/null +++ b/js/api.js @@ -0,0 +1,25 @@ +/* ============================================================ + api.js — minimal HTTP client for the FastAPI backend + ============================================================ */ +window.Api = { + base: 'http://localhost:8000', + + async get(path, params) { + const url = new URL(path.replace(/^\//, ''), this.base.endsWith('/') ? this.base : this.base + '/'); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, value); + } + }); + } + const res = await fetch(url.toString()); + let body = null; + try { body = await res.json(); } catch (_) { body = null; } + if (!res.ok) { + const detail = body && body.detail != null ? body.detail : res.statusText; + throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail)); + } + return body; + } +}; diff --git a/js/app.js b/js/app.js index 0ddf485..5978053 100644 --- a/js/app.js +++ b/js/app.js @@ -88,7 +88,10 @@ App.updateBadges = function () { setBadge('navJobsBadge', openJobs); const unread = DB.notifications.filter(n => n.unread).length; setBadge('navNotifBadge', unread); - const inboxUnread = DB.inbox.filter(i => i.unread).length + DB.emails.filter(e => e.unread).length; + const emailUnread = (window.Inbox && Array.isArray(Inbox._emails)) + ? Inbox._emails.filter(e => e.unread).length + : 0; + const inboxUnread = DB.inbox.filter(i => i.unread).length + emailUnread; setBadge('navInboxBadge', inboxUnread); const openTasks = DB.tasks.filter(t => !t.done).length; setBadge('navTaskBadge', openTasks); diff --git a/js/inbox.js b/js/inbox.js index 4247188..28e0008 100644 --- a/js/inbox.js +++ b/js/inbox.js @@ -27,7 +27,7 @@ Views.inbox = function () { 'Processed': DB.inbox.filter(i => i.processing === 'Processed').length, 'Rejected': DB.inbox.filter(i => i.processing === 'Rejected').length, 'Duplicates': DB.inbox.filter(i => i.duplicate).length, - 'Email': DB.emails.filter(e => e.unread).length + 'Email': (Inbox._emails || []).filter(e => e.unread).length }; } @@ -254,33 +254,70 @@ Inbox.reject = function (id) { }; // ---------------- Email (Outlook) tab ---------------- +Inbox._emails = []; +Inbox._lastSync = null; + +Inbox._mapApiEmail = function (row) { + const from = row.sender_name || row.fromEmail || 'Unknown'; + return { + id: String(row.id), + from, + fromEmail: row.fromEmail || '', + subject: row.subject || '', + body: row.body || '', + when: row.when ? new Date(row.when) : new Date(), + unread: !!row.unread, + attachment: row.attachment_name || 'Resume.pdf', + attachmentSize: '—', + atsScore: 70, + imported: false, + jobId: null, + jobTitle: '' + }; +}; + +Inbox._syncLabel = function () { + if (!Inbox._lastSync) return 'Not synced yet'; + const mins = Math.max(0, Math.round((Date.now() - Inbox._lastSync.getTime()) / 60000)); + if (mins < 1) return 'Just now'; + return DB.relTime(mins); +}; + +Inbox._loadEmails = async function () { + const res = await Api.get('/inbox/fetch'); + const rows = Array.isArray(res.data) ? res.data : []; + Inbox._emails = rows.map(Inbox._mapApiEmail); + Inbox._lastSync = new Date(); + return Inbox._emails; +}; + +Inbox._refreshEmailCounts = function () { + const tab = document.querySelector('#inboxTabs .tab[data-tab="Email"]'); + if (tab) { + const countEl = tab.querySelector('.k-count'); + if (countEl) countEl.textContent = Inbox._emails.filter(e => e.unread).length; + } + if (window.App && App.updateBadges) App.updateBadges(); +}; + Inbox._emailView = function () { - const st = Inbox._state; - const listHtml = DB.emails.map(e => ` -
- ${UI.avatar(e.from, e.initials, e.color)} -
-
${e.from}
-
${e.subject}
-
Outlook${e.imported ? UI.badge('Imported', 'b-green') : ''}
-
-
${DB.fmtShort(e.when)}
-
`).join(''); return `
Outlook · Microsoft Graph API - Last sync: 2 min ago · ${DB.emails.filter(e => e.unread).length} unread - + Loading… +
-
${listHtml}
+
${UI.icon('mail')}

Loading…

Fetching mailbox from the server.

`; }; + Inbox._bindEmail = function (state) { const detail = document.getElementById('emailDetail'); + function renderDetail() { - const e = DB.emails.find(x => x.id === state.emailSelected); + const e = Inbox._emails.find(x => x.id === state.emailSelected); if (!e) { detail.innerHTML = `
${UI.icon('mail')}

Select an email

Preview email body and resume attachments here.

`; return; } detail.innerHTML = `
@@ -304,20 +341,69 @@ Inbox._bindEmail = function (state) {
`; } - document.querySelectorAll('#emailList .inbox-item').forEach(row => row.onclick = () => { - state.emailSelected = row.dataset.email; - const e = DB.emails.find(x => x.id === state.emailSelected); if (e) e.unread = false; - document.querySelectorAll('#emailList .inbox-item').forEach(r => r.classList.remove('active', 'unread')); - row.classList.add('active'); - renderDetail(); App.updateBadges(); - }); + + function paintList() { + const list = document.getElementById('emailList'); + const meta = document.getElementById('emailSyncMeta'); + if (!list) return; + if (!Inbox._emails.length) { + list.innerHTML = `
${UI.icon('mail')}

Nothing here

No emails in the mailbox.

`; + } else { + list.innerHTML = Inbox._emails.map(e => ` +
+ ${UI.avatar(e.from, e.initials, e.color)} +
+
${e.from}
+
${e.subject}
+
Outlook${e.imported ? UI.badge('Imported', 'b-green') : ''}
+
+
${DB.fmtShort(e.when)}
+
`).join(''); + list.querySelectorAll('.inbox-item').forEach(row => row.onclick = () => { + state.emailSelected = row.dataset.email; + const e = Inbox._emails.find(x => x.id === state.emailSelected); if (e) e.unread = false; + list.querySelectorAll('.inbox-item').forEach(r => r.classList.remove('active', 'unread')); + row.classList.add('active'); + renderDetail(); Inbox._refreshEmailCounts(); + }); + } + if (meta) meta.textContent = `Last sync: ${Inbox._syncLabel()} · ${Inbox._emails.filter(e => e.unread).length} unread`; + renderDetail(); + Inbox._refreshEmailCounts(); + } + + Inbox._paintEmail = paintList; renderDetail(); + + Inbox._loadEmails() + .then(() => paintList()) + .catch(err => { + const list = document.getElementById('emailList'); + const meta = document.getElementById('emailSyncMeta'); + if (list) list.innerHTML = `
${UI.icon('mail')}

Couldn't load mailbox

${err.message || 'Request failed'}

`; + if (meta) meta.textContent = 'Sync failed'; + UI.toast(err.message || 'Failed to load mailbox', 'error'); + renderDetail(); + }); }; + +Inbox.syncMailbox = async function () { + UI.toast('Fetching from Outlook…', 'info'); + try { + await Inbox._loadEmails(); + if (typeof Inbox._paintEmail === 'function') Inbox._paintEmail(); + UI.toast('Mailbox synced', 'success'); + } catch (err) { + UI.toast(err.message || 'Sync failed', 'error'); + } +}; + Inbox._importEmail = function (id) { - const e = DB.emails.find(x => x.id === id); + const e = Inbox._emails.find(x => x.id === id); + if (!e) return; const job = DB.getJob(e.jobId) || DB.jobs[0]; DB.candidates.unshift({ - id: 'CAN-' + (5001 + DB.candidates.length), name: e.from, initials: e.initials, color: e.color, + id: 'CAN-' + (5001 + DB.candidates.length), name: e.from, initials: e.initials || DB.initials(e.from), color: e.color || DB.avatarColor(e.from), email: e.fromEmail, phone: '+1 (555) 000-0000', jobId: job.id, jobTitle: job.title, department: job.department, experience: DB.int(2, 10), currentCompany: DB.pick(DB.companies), currentTitle: job.title, location: DB.pick(DB.locations), stage: 'Applied', status: 'Applied', aiScore: e.atsScore, source: 'Microsoft Outlook', recruiter: DB.pick(DB.recruiters).name, recruiterId: '', @@ -327,6 +413,7 @@ Inbox._importEmail = function (id) { noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled' }); e.imported = true; e.unread = false; - Inbox._bindEmail(Inbox._state); App.updateBadges(); + if (typeof Inbox._paintEmail === 'function') Inbox._paintEmail(); + App.updateBadges(); UI.toast(`${e.from} imported from Outlook → ${job.title}`, 'success'); };