Backend_CODEBASE #1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<domain>/
|
||||
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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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 `<package>/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()
|
||||
|
|
@ -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())
|
||||
|
|
@ -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))
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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<br/>DNS + health checks"]
|
||||
CF["CloudFront<br/>static bundle + /api behaviour<br/>1 TB/mo egress free"]
|
||||
WAF["AWS WAF<br/>managed rules + Bot Control<br/>on the careers form"]
|
||||
ACM["ACM<br/>TLS certs — free"]
|
||||
end
|
||||
|
||||
subgraph VPC["VPC — 2 Availability Zones"]
|
||||
subgraph PUB["Public subnets"]
|
||||
ALB["Application Load Balancer"]
|
||||
NAT["NAT Gateway x2<br/>outbound to Graph + job boards"]
|
||||
end
|
||||
subgraph PRIV["Private subnets — no inbound from internet"]
|
||||
WEB["ECS Fargate service: web<br/>2 vCPU / 4 GB<br/>desired 2, autoscale 2-4"]
|
||||
WRK["ECS Fargate service: worker<br/>2 vCPU / 4 GB<br/>desired 1, autoscale 1-3"]
|
||||
MIG["ECS RunTask: migrate<br/>one-off, per deploy"]
|
||||
end
|
||||
subgraph DATA["Data — private, encrypted at rest"]
|
||||
RDS[("RDS PostgreSQL 16<br/>db.m7g.large Multi-AZ<br/>PITR 14 days")]
|
||||
EC[("ElastiCache for Valkey<br/>cache.t4g.small x2<br/>cache / rate limit / sessions")]
|
||||
end
|
||||
VPE["S3 Gateway Endpoint<br/>FREE — keeps CV traffic off NAT"]
|
||||
end
|
||||
|
||||
subgraph STORE["Storage & AI — regional services"]
|
||||
S3A[("S3: candidate-documents<br/>SSE-KMS, versioning off,<br/>7-day soft delete")]
|
||||
S3B[("S3: audit-archive<br/>Object Lock COMPLIANCE<br/>Glacier Instant Retrieval")]
|
||||
S3C[("S3: static frontend<br/>OAC-restricted to CloudFront")]
|
||||
BR["Amazon Bedrock<br/>ATS scoring + chatbot"]
|
||||
TX["Amazon Textract<br/>OCR fallback only"]
|
||||
GD["GuardDuty<br/>Malware Protection for S3"]
|
||||
end
|
||||
|
||||
subgraph OPS["Security & ops"]
|
||||
SM["Secrets Manager<br/>Graph creds, DB password"]
|
||||
KMS["KMS<br/>4 customer-managed keys"]
|
||||
CW["CloudWatch<br/>logs, metrics, alarms"]
|
||||
CT["CloudTrail + AWS Config"]
|
||||
ECR["ECR<br/>one image, keep last 10"]
|
||||
end
|
||||
|
||||
EXT["Microsoft Graph<br/>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.
|
||||
|
|
@ -264,6 +264,7 @@
|
|||
<script src="js/data.js"></script>
|
||||
<script src="js/charts.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/dashboard.js"></script>
|
||||
<script src="js/jobs.js"></script>
|
||||
<script src="js/candidates.js"></script>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
139
js/inbox.js
139
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 => `
|
||||
<div class="inbox-item ${e.unread ? 'unread' : ''} ${st.emailSelected === e.id ? 'active' : ''}" data-email="${e.id}">
|
||||
${UI.avatar(e.from, e.initials, e.color)}
|
||||
<div class="ii-main">
|
||||
<div class="ii-name">${e.from}</div>
|
||||
<div class="ii-pos">${e.subject}</div>
|
||||
<div class="ii-meta"><span class="source-chip" style="--chip:#0078d4"><svg viewBox="0 0 24 24"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>Outlook</span>${e.imported ? UI.badge('Imported', 'b-green') : ''}</div>
|
||||
</div>
|
||||
<div class="ii-time">${DB.fmtShort(e.when)}</div>
|
||||
</div>`).join('');
|
||||
return `
|
||||
<div style="padding:12px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px">
|
||||
<span class="integration-status"><span class="pulse"></span>Outlook · Microsoft Graph API</span>
|
||||
<span class="text-muted text-sm">Last sync: 2 min ago · ${DB.emails.filter(e => e.unread).length} unread</span>
|
||||
<button class="btn btn-secondary btn-sm" style="margin-left:auto" onclick="UI.toast('Fetching from Outlook…','info');setTimeout(()=>UI.toast('Mailbox synced','success'),800)">${UI.icon('refresh')} Sync Mailbox</button>
|
||||
<span class="text-muted text-sm" id="emailSyncMeta">Loading…</span>
|
||||
<button class="btn btn-secondary btn-sm" style="margin-left:auto" onclick="Inbox.syncMailbox()">${UI.icon('refresh')} Sync Mailbox</button>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="split-list" id="emailList">${listHtml}</div>
|
||||
<div class="split-list" id="emailList"><div class="empty-state">${UI.icon('mail')}<h3>Loading…</h3><p>Fetching mailbox from the server.</p></div></div>
|
||||
<div class="split-detail" id="emailDetail"></div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
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 = `<div class="empty-state" style="padding:100px 20px">${UI.icon('mail')}<h3>Select an email</h3><p>Preview email body and resume attachments here.</p></div>`; return; }
|
||||
detail.innerHTML = `<div style="padding:24px">
|
||||
<div class="flex items-center gap-12" style="margin-bottom:6px">
|
||||
|
|
@ -304,20 +341,69 @@ Inbox._bindEmail = function (state) {
|
|||
</div>
|
||||
</div>`;
|
||||
}
|
||||
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 = `<div class="empty-state">${UI.icon('mail')}<h3>Nothing here</h3><p>No emails in the mailbox.</p></div>`;
|
||||
} else {
|
||||
list.innerHTML = Inbox._emails.map(e => `
|
||||
<div class="inbox-item ${e.unread ? 'unread' : ''} ${state.emailSelected === e.id ? 'active' : ''}" data-email="${e.id}">
|
||||
${UI.avatar(e.from, e.initials, e.color)}
|
||||
<div class="ii-main">
|
||||
<div class="ii-name">${e.from}</div>
|
||||
<div class="ii-pos">${e.subject}</div>
|
||||
<div class="ii-meta"><span class="source-chip" style="--chip:#0078d4"><svg viewBox="0 0 24 24"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>Outlook</span>${e.imported ? UI.badge('Imported', 'b-green') : ''}</div>
|
||||
</div>
|
||||
<div class="ii-time">${DB.fmtShort(e.when)}</div>
|
||||
</div>`).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 = `<div class="empty-state">${UI.icon('mail')}<h3>Couldn't load mailbox</h3><p>${err.message || 'Request failed'}</p></div>`;
|
||||
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');
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue