Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/app/celery_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
"task": "app.tasks.digest.send_daily_digest",
"schedule": crontab(hour=12, minute=30), # 6:00 PM IST = 12:30 PM UTC
},
"reconcile-usage-periods": {
"task": "reconcile_usage_periods",
"schedule": crontab(hour=2, minute=0), # 2:00 AM UTC daily
},
}


Expand Down
4 changes: 3 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from app.middleware.setup import SecurityHeadersMiddleware, RequestLoggingMiddleware
from app.middleware.logging import setup_logging
from app.middleware.cors import WildcardCORSMiddleware
from app.routers import auth, workspace, jobs, resumes, ai, billing, extension, webhooks, files, health, users
from app.routers import auth, workspace, jobs, resumes, ai, builder, billing, extension, webhooks, files, health, admin, users

setup_logging()
logger = structlog.get_logger()
Expand Down Expand Up @@ -48,10 +48,12 @@ async def lifespan(app: FastAPI):
app.include_router(jobs.router)
app.include_router(resumes.router)
app.include_router(ai.router)
app.include_router(builder.router)
app.include_router(billing.router)
app.include_router(extension.router)
app.include_router(webhooks.router)
app.include_router(files.router)
app.include_router(admin.router)


@app.get("/extension-version.json")
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from app.models.builder_resume import BuilderResume, BuilderResumeVersion
from app.models.download_log import DownloadLog
from app.models.prompt_log import PromptLog
from app.models.usage import UserUsage

__all__ = [
"User",
Expand All @@ -37,4 +38,5 @@
"BuilderResumeVersion",
"DownloadLog",
"PromptLog",
"UserUsage",
]
7 changes: 6 additions & 1 deletion backend/app/models/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class Subscription(Base):
suspended: Mapped[bool] = mapped_column(Boolean, default=False)
lifetime_deal: Mapped[bool] = mapped_column(Boolean, default=False)
provider: Mapped[str] = mapped_column(String, default="razorpay")
last_payment_status: Mapped[str | None] = mapped_column(String, nullable=True)
last_payment_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

Expand All @@ -39,6 +43,7 @@ class PaymentEvent(Base):
provider: Mapped[str] = mapped_column(String, default="razorpay")
event_id: Mapped[str] = mapped_column(String, nullable=False, unique=True)
event_type: Mapped[str] = mapped_column(String, nullable=False)
workspace_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False)
workspace_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=True)
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
payload: Mapped[dict] = mapped_column(JSONB, default={})
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
20 changes: 20 additions & 0 deletions backend/app/models/usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, Integer, DateTime, ForeignKey, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base


class UserUsage(Base):
__tablename__ = "user_usage"
__table_args__ = (UniqueConstraint("user_id", "period_start", name="uq_user_usage_user_period"),)

id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
period_start: Mapped[date] = mapped_column(Date, nullable=False)
period_end: Mapped[date] = mapped_column(Date, nullable=False)
job_tracks_used: Mapped[int] = mapped_column(Integer, default=0)
cover_letters_used: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
34 changes: 34 additions & 0 deletions backend/app/routers/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import get_db
from app.dependencies.auth import require_admin
from app.schemas.admin import (
AdminOverviewOut,
AdminSubscriptionOut,
AdminUsageRowOut,
RevenuePointOut,
)
from app.services import admin as admin_service

router = APIRouter(prefix="/api/v1/admin", tags=["admin"])


@router.get("/stats/overview", response_model=AdminOverviewOut, dependencies=[Depends(require_admin)])
async def stats_overview(db: AsyncSession = Depends(get_db)):
return await admin_service.get_overview(db)


@router.get("/stats/revenue", response_model=list[RevenuePointOut], dependencies=[Depends(require_admin)])
async def stats_revenue(days: int = 30, db: AsyncSession = Depends(get_db)):
return await admin_service.get_revenue_series(db, days=days)


@router.get("/subscriptions", response_model=list[AdminSubscriptionOut], dependencies=[Depends(require_admin)])
async def list_subscriptions(limit: int = 50, offset: int = 0, db: AsyncSession = Depends(get_db)):
return await admin_service.list_subscriptions(db, limit=limit, offset=offset)


@router.get("/usage", response_model=list[AdminUsageRowOut], dependencies=[Depends(require_admin)])
async def list_usage(limit: int = 50, db: AsyncSession = Depends(get_db)):
return await admin_service.list_usage(db, limit=limit)
94 changes: 79 additions & 15 deletions backend/app/routers/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@
AtsScoreRequest,
AtsScoreResult,
)
from app.schemas.usage import PlanLimitErrorOut
from app.services import ai as ai_service
from app.services import workspace as workspace_service
from app.services import usage as usage_service
from app.services import user as user_service
from app.services import builder as builder_service
from app.models.job import Job

router = APIRouter(prefix="/api/v1/ai", tags=["ai"])
Expand Down Expand Up @@ -66,6 +69,25 @@ def _parse_resume_chat(content: str) -> tuple[str, str | None]:
return answer, updated


def _compute_section_completion(content: dict) -> dict:
"""Deterministic Section Completion fallback when the model omits it from builder_score."""
contact = content.get("contact") or {}
checks = [
("Contact", bool(contact.get("name") or contact.get("email"))),
("Summary", bool(content.get("about"))),
("Skills", bool(content.get("skills"))),
("Experience", bool(content.get("work"))),
("Education", bool(content.get("education"))),
("Projects", bool(content.get("projects"))),
("Certifications", bool(content.get("certifications"))),
]
ok_count = sum(1 for _, ok in checks if ok)
return {
"score": round(100 * ok_count / len(checks)) if checks else 0,
"checks": [{"label": label, "ok": ok_flag} for label, ok_flag in checks],
}


async def _log_ai_call(db, workspace_id, user_id, job_id, res, purpose):
await ai_service.log_ai_cost(
db,
Expand Down Expand Up @@ -97,13 +119,36 @@ async def ai_generate(
db: AsyncSession = Depends(get_db),
):
ws = await get_workspace_info(user, db)

allowed = await ai_service.check_entitlement(db, str(ws.id), data.job_id)
if not allowed:
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="Free trial limit reached")
user_id = uuid.UUID(user["user_id"])

is_cover_letter = data.prompt_name == "generate_cover_letter"
if is_cover_letter:
check = await usage_service.can_generate_cover_letter(db, user_id)
if not check.allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=PlanLimitErrorOut(
message=f"You've reached your {check.plan_name} plan's cover letter limit. Upgrade to generate more.",
currentPlan=check.plan_name,
limit=check.limit,
used=check.used,
).model_dump(),
)

render_vars = dict(data.vars)

# Builder prompts expect {{title}} / {{jd}} / {{resume_json}}, but the frontend only sends
# vars:{job_id}. Resolve them server-side from the Job and seeded BuilderResume.
BUILDER_PROMPTS = {"builder_job_match", "builder_score", "builder_suggestions"}
if data.prompt_name in BUILDER_PROMPTS and data.job_id:
job = (
await db.execute(select(Job).where(Job.id == uuid.UUID(data.job_id)))
).scalar_one_or_none()
builder = await builder_service.get_or_create_builder(db, ws.id, uuid.UUID(data.job_id))
render_vars.setdefault("title", job.title if job else "")
render_vars.setdefault("jd", (job.description or "") if job else "")
render_vars.setdefault("resume_json", json.dumps(builder.content, default=str))

profile = await user_service.get_profile(db, uuid.UUID(user["user_id"]))
profile_block = _build_profile_block(profile)

Expand Down Expand Up @@ -159,6 +204,28 @@ async def ai_generate(
except (json.JSONDecodeError, ValueError):
pass

if is_cover_letter:
await usage_service.increment_cover_letter_usage(db, user_id)

# Persist builder analysis back onto the BuilderResume so /seed refetch (triggered by the
# frontend's query invalidate) returns the latest Job Match / Score / Suggestions.
if data.job_id and data.prompt_name in BUILDER_PROMPTS:
builder = await builder_service.get_or_create_builder(db, ws.id, uuid.UUID(data.job_id))
try:
parsed = json.loads(result["content"])
if isinstance(parsed, dict):
if data.prompt_name == "builder_score" and not parsed.get("section"):
parsed["section"] = _compute_section_completion(builder.content)
if data.prompt_name == "builder_job_match":
builder.job_match = parsed
elif data.prompt_name == "builder_score":
builder.score = parsed
elif data.prompt_name == "builder_suggestions":
builder.suggestions = parsed
await db.flush()
except (json.JSONDecodeError, ValueError):
pass

# Tailored resumes: the whole-document call tends to update Skills but leave Experience
# bullets untouched. Run a focused, best-effort rewrite of the Experience section and splice it in.
if data.prompt_name == "tailor_resume":
Expand Down Expand Up @@ -212,10 +279,6 @@ async def edit_resume(
):
ws = await get_workspace_info(user, db)

allowed = await ai_service.check_entitlement(db, str(ws.id), data.job_id)
if not allowed:
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="Free trial limit reached")

profile = await user_service.get_profile(db, uuid.UUID(user["user_id"]))
profile_block = _build_profile_block(profile)

Expand All @@ -240,10 +303,6 @@ async def ats_score(
):
ws = await get_workspace_info(user, db)

allowed = await ai_service.check_entitlement(db, str(ws.id), data.job_id)
if not allowed:
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="Free trial limit reached")

job = (await db.execute(select(Job).where(Job.id == uuid.UUID(data.job_id)))).scalar_one_or_none()
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
Expand Down Expand Up @@ -311,6 +370,11 @@ async def check_entitlement(
user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ws = await get_workspace_info(user, db)
allowed = await ai_service.check_entitlement(db, str(ws.id), job_id)
return {"allowed": allowed}
user_id = uuid.UUID(user["user_id"])
job_tracks = await usage_service.can_create_job_track(db, user_id)
cover_letters = await usage_service.can_generate_cover_letter(db, user_id)
return {
"allowed": job_tracks.allowed and cover_letters.allowed,
"job_track": {"allowed": job_tracks.allowed, "used": job_tracks.used, "limit": job_tracks.limit},
"cover_letter": {"allowed": cover_letters.allowed, "used": cover_letters.used, "limit": cover_letters.limit},
}
Loading