jwt token integrated

pull/1/head
ahmed.mujtaba 2026-08-04 14:33:07 +05:00
parent caab412f96
commit ef07694254
16 changed files with 1380 additions and 32 deletions

View File

@ -21,4 +21,5 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(inbox_router) app.include_router(inbox_router)
app.include_router(users_router)

View File

@ -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 ###

28
backend/requirements.txt Normal file
View File

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

View File

@ -1,14 +1,136 @@
from typing import Annotated
from fastapi import APIRouter,Depends, Query from fastapi import APIRouter,Depends, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from db_setup import get_session from db_setup import get_session
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from users.models import Users from pydantic import BaseModel, EmailStr
from users.views import User
from users.permissions import CurrentUser
from users.serializers import serialize_token
from users.plugins import create_access_token,create_refresh_token
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
router = APIRouter() 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
role_id: int | None = None
is_active: bool | None = None
class TokenRefresh(BaseModel):
refresh_token: str
@router.post("/users/login")
async def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
session: AsyncSession = Depends(get_session),
):
try:
service=User(session=session)
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})
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") @router.post("/users/create")
async def create_user(user: Users, session: AsyncSession = Depends(get_session)): async def create_user(payload: UserCreate,current_user: CurrentUser,session: AsyncSession = Depends(get_session)):
pass try:
service=User(session=session)
data=await service.create_user(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("/users/fetch")
async def fetch_users(
current_user: CurrentUser,
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: CurrentUser,record_id: str = Query(...),session: AsyncSession = Depends(get_session)):
try:
service=User(session=session)
data=await service.update_user(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("/users/delete")
async def delete_user(current_user: CurrentUser,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))

View File

@ -1,7 +1,10 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlmodel import Field, Relationship, SQLModel 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 from role.models import Roles
@ -19,3 +22,97 @@ class Users(SQLModel, table=True):
updated_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now)
is_active: bool = Field(default=True) is_active: bool = Field(default=True)
is_deleted: bool = Field(default=False) 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):
result = await session.execute(select(cls).where(cls.email == email))
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

View File

@ -0,0 +1,44 @@
"""OAuth2 bearer scheme and the current-user dependency for `/users/*` routes."""
from __future__ import annotations
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from db_setup import get_session
from users.models import Users
from users.plugins import decode_token
from users.serializers import serialize_user
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="users/login")
async def get_current_user(
token: Annotated[str, Depends(oauth2_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(token, 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"},
)
return serialize_user(user)
CurrentUser = Annotated[dict, Depends(get_current_user)]

119
backend/users/plugins.py Normal file
View File

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

View File

@ -0,0 +1,34 @@
from users.models import Users
from users.plugins import ACCESS_TOKEN_EXPIRE_SECONDS
def serialize_user(user: Users) -> 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)
return {
"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,
}
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),
}

77
backend/users/views.py Normal file
View File

@ -0,0 +1,77 @@
from fastapi import HTTPException
from users.models import Users
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 create_user(self,payload):
existing=await Users.get_user_by_email(self.session,payload.get("email"))
if existing:
raise HTTPException(status_code=409,detail="Email already registered")
# this is for password hasshing
fields=clean_user_payload(payload)
if not fields.get("password"):
raise HTTPException(status_code=400,detail="Password is required")
user=await Users.insert_user(self.session,fields)
return serialize_user(user)
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 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

View File

@ -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 | 2025 | `02-system-architecture.md:834`**ASSUMPTION** |
| Applications per year | 20,00060,000 | `02-system-architecture.md:835`**ASSUMPTION** |
| Documents per day at peak | 200600 | `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 14 | `02-system-architecture.md:439` |
| Worker process | 2 vCPU / 4 GB, concurrency 4, autoscale 13 | `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 "14 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 16 | $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 2040
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 34 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 **$4060/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 12 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 **2430 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 12 | Phase 0. Local `docker compose` only (`02:960`). AWS = an account, ECR, and IAM Identity Center | $50 | $100 |
| Months 37 | Staging live, auto-deploying on merge to `main` (`02:493`). Real test-mailbox traffic | $249 | $1,245 |
| Months 812 | **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 34 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 ~$30150/mo | Real engineering work | **Required regardless** |
Levers 16 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 **$30150/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,5003,000/mo**, more than doubling the total |
| 3 | **Bulk job-board feeds arrive.** Risk A3 (`02:1218`) warns volumes could be *"12 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.

View File

@ -264,6 +264,7 @@
<script src="js/data.js"></script> <script src="js/data.js"></script>
<script src="js/charts.js"></script> <script src="js/charts.js"></script>
<script src="js/ui.js"></script> <script src="js/ui.js"></script>
<script src="js/api.js"></script>
<script src="js/dashboard.js"></script> <script src="js/dashboard.js"></script>
<script src="js/jobs.js"></script> <script src="js/jobs.js"></script>
<script src="js/candidates.js"></script> <script src="js/candidates.js"></script>

25
js/api.js Normal file
View File

@ -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;
}
};

View File

@ -88,7 +88,10 @@ App.updateBadges = function () {
setBadge('navJobsBadge', openJobs); setBadge('navJobsBadge', openJobs);
const unread = DB.notifications.filter(n => n.unread).length; const unread = DB.notifications.filter(n => n.unread).length;
setBadge('navNotifBadge', unread); 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); setBadge('navInboxBadge', inboxUnread);
const openTasks = DB.tasks.filter(t => !t.done).length; const openTasks = DB.tasks.filter(t => !t.done).length;
setBadge('navTaskBadge', openTasks); setBadge('navTaskBadge', openTasks);

View File

@ -27,7 +27,7 @@ Views.inbox = function () {
'Processed': DB.inbox.filter(i => i.processing === 'Processed').length, 'Processed': DB.inbox.filter(i => i.processing === 'Processed').length,
'Rejected': DB.inbox.filter(i => i.processing === 'Rejected').length, 'Rejected': DB.inbox.filter(i => i.processing === 'Rejected').length,
'Duplicates': DB.inbox.filter(i => i.duplicate).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 ---------------- // ---------------- 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 () { 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 ` return `
<div style="padding:12px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px"> <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="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> <span class="text-muted text-sm" id="emailSyncMeta">Loading</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> <button class="btn btn-secondary btn-sm" style="margin-left:auto" onclick="Inbox.syncMailbox()">${UI.icon('refresh')} Sync Mailbox</button>
</div> </div>
<div class="split"> <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 class="split-detail" id="emailDetail"></div>
</div>`; </div>`;
}; };
Inbox._bindEmail = function (state) { Inbox._bindEmail = function (state) {
const detail = document.getElementById('emailDetail'); const detail = document.getElementById('emailDetail');
function renderDetail() { 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; } 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"> detail.innerHTML = `<div style="padding:24px">
<div class="flex items-center gap-12" style="margin-bottom:6px"> <div class="flex items-center gap-12" style="margin-bottom:6px">
@ -304,20 +341,69 @@ Inbox._bindEmail = function (state) {
</div> </div>
</div>`; </div>`;
} }
document.querySelectorAll('#emailList .inbox-item').forEach(row => row.onclick = () => {
state.emailSelected = row.dataset.email; function paintList() {
const e = DB.emails.find(x => x.id === state.emailSelected); if (e) e.unread = false; const list = document.getElementById('emailList');
document.querySelectorAll('#emailList .inbox-item').forEach(r => r.classList.remove('active', 'unread')); const meta = document.getElementById('emailSyncMeta');
row.classList.add('active'); if (!list) return;
renderDetail(); App.updateBadges(); 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(); 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) { 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]; const job = DB.getJob(e.jobId) || DB.jobs[0];
DB.candidates.unshift({ 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, 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), 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: '', 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' noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled'
}); });
e.imported = true; e.unread = false; 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'); UI.toast(`${e.from} imported from Outlook → ${job.title}`, 'success');
}; };