Dashboard_Wiring #11

Merged
ahmed.mujtaba merged 5 commits from Dashboard_Wiring into main 2026-08-12 11:33:01 +00:00
5301 changed files with 1301636 additions and 149 deletions

View File

@ -110,6 +110,7 @@ backend/
├── Dockerfile # image for the Taskiq worker / scheduler
├── alembic.ini # generated by alembic_setup.py, not hand-written
├── migrations/ # generated env.py + versions/
│ └── manual/ # one-shot SQL (enum labels, RBAC seed, backfills)
├── LLM_CONTEXT_PROMPT.md # house-style prompt to paste into an LLM before editing
├── users/ # accounts, login, signup, RBAC enforcement
@ -117,10 +118,15 @@ backend/
├── forget_password/ # reset-code request → verify → new password
├── notifications/ # email-confirmation tokens and mail
├── inbox/ # mailbox sync, attachments, applications
├── analytics/ # dashboard KPIs + charts (views only — no tables)
├── offer/ # offers + offer_status_history
├── job/
│ ├── app.py # routes for both sub-domains
│ ├── job_post/ # job ads + Buffer publishing
│ └── candidate/ # CV reading, candidate profile
│ ├── candidate/ # CV reading, candidate profile, stage transitions model
│ ├── assignment/ # job_assignments + application_assignments
│ ├── cost/ # hiring_costs
│ └── pipeline/ # stage-change service (single writer)
├── agent/ # LangGraph CV → job-post matching agent
└── taskiq_management/ # broker, scheduler, DLQ middleware, smoke task
```
@ -602,6 +608,22 @@ python alembic_setup.py current
python alembic_setup.py head
```
Alembic autogenerate does **not** detect new PostgreSQL enum labels. Permission-tag
rows and analytics role bundles are also seeded out-of-band. Those live in
`migrations/manual/` and must be run by hand in psql (autocommit for `ADD VALUE`):
```bash
# After `python alembic_setup.py upgrade` has created the new tables/columns:
psql "$DATABASE_URL" -f migrations/manual/001_dashboard_rbac_and_enum.sql
```
`001_dashboard_rbac_and_enum.sql` extends `candidate_application_status`, seeds all 104
`permission_tags`, creates the `analytics_dashboard` bundle and attaches it to the
system roles that need the dashboard, seeds the eleven BRD `source_channels`, and
backfills `source_channel_id` / stage-transition / requisition-status rows.
```
Migrations run under a Postgres advisory lock, so several workers booting at once cannot
migrate concurrently. Empty revisions are suppressed. Alembic's own `alembic_version` table is
excluded from autogenerate, as is anything outside the configured schemas.

109
backend/analytics/app.py Normal file
View File

@ -0,0 +1,109 @@
from datetime import datetime
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 analytics.views import Analytics
from users.permissions import PermissionTag,require_permission
from dotenv import load_dotenv
load_dotenv()
router = APIRouter()
@router.get("/analytics/kpis/fetch")
async def fetch_kpis(
current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)),
from_date: datetime | None = Query(None),
to_date: datetime | None = Query(None),
department: str | None = Query(None),
recruiter_id: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
try:
service=Analytics(session=session)
data=await service.get_kpis(from_date,to_date,department,recruiter_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/analytics/funnel/fetch")
async def fetch_funnel(
current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)),
from_date: datetime | None = Query(None),
to_date: datetime | None = Query(None),
department: str | None = Query(None),
recruiter_id: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
try:
service=Analytics(session=session)
data=await service.get_funnel(from_date,to_date,department,recruiter_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/analytics/hiring-trend/fetch")
async def fetch_hiring_trend(
current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)),
months: int = Query(7,ge=1),
from_date: datetime | None = Query(None),
to_date: datetime | None = Query(None),
department: str | None = Query(None),
recruiter_id: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
try:
service=Analytics(session=session)
data=await service.get_hiring_trend(months,from_date,to_date,department,recruiter_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/analytics/source-performance/fetch")
async def fetch_source_performance(
current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)),
from_date: datetime | None = Query(None),
to_date: datetime | None = Query(None),
department: str | None = Query(None),
recruiter_id: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
try:
service=Analytics(session=session)
data=await service.get_source_performance(from_date,to_date,department,recruiter_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/analytics/recruiter-performance/fetch")
async def fetch_recruiter_performance(
current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)),
top: int = Query(5,ge=1),
from_date: datetime | None = Query(None),
to_date: datetime | None = Query(None),
department: str | None = Query(None),
recruiter_id: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
try:
service=Analytics(session=session)
data=await service.get_recruiter_performance(top,from_date,to_date,department,recruiter_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))

View File

@ -0,0 +1,22 @@
"""Analytics responses are mostly assembled as dicts in views.
Keep helpers here only when reuse across methods would otherwise duplicate.
"""
def serialize_stage_count(stage,count) -> dict:
return {"stage": stage,"count": int(count or 0)}
def serialize_source_count(source,count) -> dict:
return {"source": source or "Unknown","count": int(count or 0)}
def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire) -> dict:
return {
"id": str(user_id) if user_id else None,
"name": name,
"hires": int(hires or 0),
"open_reqs": int(open_reqs or 0),
"avg_time_to_hire": float(avg_time_to_hire) if avg_time_to_hire is not None else None,
}

557
backend/analytics/views.py Normal file
View File

@ -0,0 +1,557 @@
import uuid
from datetime import datetime,timedelta,timezone
from sqlalchemy import and_,func,or_,select
from sqlalchemy.ext.asyncio import AsyncSession
from analytics.serializers import (
serialize_recruiter_row,
serialize_source_count,
serialize_stage_count,
)
from inbox.enums import Candidate_application_Status
from inbox.models import Inbox,Inbox_Messages,SourceChannels
from job.assignment.models import JobAssignments
from job.candidate.models import ApplicationStageTransitions,Interviews
from job.cost.models import HiringCosts
from job.job_post.models import JobPosts
from offer.models import Offers
from role.models import EnumRoles,Roles
from users.models import Users
def _as_uuid(value):
if value in (None,""):
return None
try:
return uuid.UUID(str(value))
except (TypeError,ValueError):
return None
def _month_start(dt: datetime) -> datetime:
return datetime(dt.year,dt.month,1,tzinfo=timezone.utc)
def _next_month_start(dt: datetime) -> datetime:
if dt.month==12:
return datetime(dt.year+1,1,1,tzinfo=timezone.utc)
return datetime(dt.year,dt.month+1,1,tzinfo=timezone.utc)
def _resolve_windows(from_date,to_date):
"""Return (from_date, to_date, prior_from, prior_to). Missing bounds → current calendar month."""
now=datetime.now(timezone.utc)
if from_date is None and to_date is None:
from_date=_month_start(now)
to_date=_next_month_start(now)
elif from_date is None:
# open-ended lower bound: treat as same length as a calendar month ending at to_date
to_date=to_date if to_date.tzinfo else to_date.replace(tzinfo=timezone.utc)
from_date=_month_start(to_date)
elif to_date is None:
from_date=from_date if from_date.tzinfo else from_date.replace(tzinfo=timezone.utc)
to_date=_next_month_start(from_date)
else:
if from_date.tzinfo is None:
from_date=from_date.replace(tzinfo=timezone.utc)
if to_date.tzinfo is None:
to_date=to_date.replace(tzinfo=timezone.utc)
duration=to_date-from_date
prior_to=from_date
prior_from=from_date-duration
return from_date,to_date,prior_from,prior_to
def _month_key(dt):
"""Normalize date_trunc / python month buckets for dict lookup."""
if dt is None:
return None
if getattr(dt,"tzinfo",None) is None:
dt=dt.replace(tzinfo=timezone.utc)
else:
dt=dt.astimezone(timezone.utc)
return datetime(dt.year,dt.month,1,tzinfo=timezone.utc)
def _days_expr(end_col,start_col):
return func.extract("epoch",end_col-start_col)/86400.0
class Analytics:
def __init__(self,session:AsyncSession):
self.session=session
async def _count_jobs(self,status,from_date=None,to_date=None,department=None,recruiter_id=None,*,closed_in_window=False):
statement=select(func.count()).select_from(JobPosts).where(JobPosts.is_deleted==False) # noqa: E712
if status:
statement=statement.where(JobPosts.requisition_status==status)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
if closed_in_window:
if from_date is not None:
statement=statement.where(JobPosts.closed_at>=from_date)
if to_date is not None:
statement=statement.where(JobPosts.closed_at<to_date)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_open_snapshot(self,as_of,department=None,recruiter_id=None):
"""Jobs that existed and were still open at `as_of` (best-effort)."""
statement=select(func.count()).select_from(JobPosts).where(
JobPosts.is_deleted==False, # noqa: E712
JobPosts.created_at<as_of,
or_(JobPosts.closed_at.is_(None),JobPosts.closed_at>=as_of),
JobPosts.requisition_status=="open",
)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_candidates(self,from_date=None,to_date=None,department=None,recruiter_id=None):
statement=(
select(func.count())
.select_from(Inbox)
.join(Users,Inbox.user_id==Users.id)
.join(Roles,Users.role_id==Roles.id)
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
)
if from_date is not None:
statement=statement.where(Inbox.created_at>=from_date)
if to_date is not None:
statement=statement.where(Inbox.created_at<to_date)
# Best-effort department/recruiter via linked message → job post
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_offers(self,statuses,from_date=None,to_date=None,department=None,recruiter_id=None,*,exclude_draft=False):
statement=select(func.count()).select_from(Offers)
if exclude_draft:
statement=statement.where(Offers.status!="draft")
elif statuses:
statement=statement.where(Offers.status.in_(statuses))
stamp=func.coalesce(Offers.sent_at,Offers.responded_at,Offers.created_at)
if from_date is not None:
statement=statement.where(stamp>=from_date)
if to_date is not None:
statement=statement.where(stamp<to_date)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,Offers.job_post_id==JobPosts.id)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_hires(self,from_date=None,to_date=None,department=None,recruiter_id=None):
# Prefer HIRED transitions in window; fall back path uses inbox_messages status.
hired=ApplicationStageTransitions
statement=select(func.count()).select_from(hired).where(hired.to_stage==Candidate_application_Status.HIRED.value)
if from_date is not None:
statement=statement.where(hired.valid_from>=from_date)
if to_date is not None:
statement=statement.where(hired.valid_from<to_date)
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox,hired.inbox_id==Inbox.id)
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
result=await self.session.execute(statement)
count=int(result.scalar_one() or 0)
if count:
return count
# Fallback: messages currently HIRED, windowed via inbox.created_at
msg=(
select(func.count())
.select_from(Inbox_Messages)
.join(Inbox,Inbox.message_id==Inbox_Messages.id)
.where(Inbox_Messages.application_status==Candidate_application_Status.HIRED)
)
if from_date is not None:
msg=msg.where(Inbox.created_at>=from_date)
if to_date is not None:
msg=msg.where(Inbox.created_at<to_date)
if department or recruiter_id:
msg=msg.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
if department:
msg=msg.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
msg=msg.where(or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid))
result=await self.session.execute(msg)
return int(result.scalar_one() or 0)
async def _avg_time_to_hire(self,from_date=None,to_date=None,department=None,recruiter_id=None):
entry=ApplicationStageTransitions.__table__.alias("entry")
hire=ApplicationStageTransitions.__table__.alias("hire")
days=_days_expr(hire.c.valid_from,entry.c.valid_from)
statement=(
select(func.avg(days))
.select_from(
hire.join(
entry,
and_(
hire.c.inbox_id==entry.c.inbox_id,
entry.c.from_stage.is_(None),
),
)
)
.where(hire.c.to_stage==Candidate_application_Status.HIRED.value)
)
if from_date is not None:
statement=statement.where(hire.c.valid_from>=from_date)
if to_date is not None:
statement=statement.where(hire.c.valid_from<to_date)
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox,hire.c.inbox_id==Inbox.id)
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
result=await self.session.execute(statement)
value=result.scalar_one()
return float(value) if value is not None else None
async def _avg_time_to_fill(self,from_date=None,to_date=None,department=None,recruiter_id=None):
days=_days_expr(JobPosts.closed_at,JobPosts.created_at)
statement=select(func.avg(days)).select_from(JobPosts).where(
JobPosts.is_deleted==False, # noqa: E712
JobPosts.requisition_status=="closed",
JobPosts.closed_at.is_not(None),
)
if from_date is not None:
statement=statement.where(JobPosts.closed_at>=from_date)
if to_date is not None:
statement=statement.where(JobPosts.closed_at<to_date)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
value=result.scalar_one()
return float(value) if value is not None else None
async def _cost_per_hire(self,hires,from_date=None,to_date=None,department=None,recruiter_id=None):
if not hires:
return None
statement=select(func.coalesce(func.sum(HiringCosts.amount),0.0))
if from_date is not None:
statement=statement.where(HiringCosts.incurred_at>=from_date)
if to_date is not None:
statement=statement.where(HiringCosts.incurred_at<to_date)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,HiringCosts.job_post_id==JobPosts.id)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
total=float(result.scalar_one() or 0.0)
return total/hires
async def get_kpis(self,from_date=None,to_date=None,department=None,recruiter_id=None):
window_from,window_to,prior_from,prior_to=_resolve_windows(from_date,to_date)
now=datetime.now(timezone.utc)
today_start=datetime(now.year,now.month,now.day,tzinfo=timezone.utc)
tomorrow=today_start+timedelta(days=1)
open_jobs=await self._count_jobs("open",department=department,recruiter_id=recruiter_id)
open_jobs_prior=await self._count_open_snapshot(window_from,department=department,recruiter_id=recruiter_id)
closed_jobs=await self._count_jobs(
"closed",window_from,window_to,department,recruiter_id,closed_in_window=True
)
closed_jobs_prior=await self._count_jobs(
"closed",prior_from,prior_to,department,recruiter_id,closed_in_window=True
)
total_candidates=await self._count_candidates(window_from,window_to,department,recruiter_id)
total_candidates_prior=await self._count_candidates(prior_from,prior_to,department,recruiter_id)
interviews_today_q=select(func.count()).select_from(Interviews).where(
Interviews.interview_date>=today_start,
Interviews.interview_date<tomorrow,
)
interviews_today=int((await self.session.execute(interviews_today_q)).scalar_one() or 0)
upcoming_q=select(func.count()).select_from(Interviews).where(
Interviews.interview_status.ilike("scheduled"),
Interviews.interview_date>=now,
)
interviews_upcoming=int((await self.session.execute(upcoming_q)).scalar_one() or 0)
next_q=select(func.min(func.coalesce(Interviews.interview_time,Interviews.interview_date))).where(
Interviews.interview_status.ilike("scheduled"),
Interviews.interview_date>=now,
)
next_at=(await self.session.execute(next_q)).scalar_one()
next_interview_at=next_at.isoformat() if next_at else None
offers_accepted=await self._count_offers(["accepted"],window_from,window_to,department,recruiter_id)
offers_accepted_prior=await self._count_offers(["accepted"],prior_from,prior_to,department,recruiter_id)
offers_sent=await self._count_offers(
["sent","negotiating","accepted","declined","expired"],
window_from,window_to,department,recruiter_id,exclude_draft=True,
)
offers_sent_prior=await self._count_offers(
["sent","negotiating","accepted","declined","expired"],
prior_from,prior_to,department,recruiter_id,exclude_draft=True,
)
hires=await self._count_hires(window_from,window_to,department,recruiter_id)
hires_prior=await self._count_hires(prior_from,prior_to,department,recruiter_id)
time_to_hire=await self._avg_time_to_hire(window_from,window_to,department,recruiter_id)
time_to_hire_prior=await self._avg_time_to_hire(prior_from,prior_to,department,recruiter_id)
time_to_fill=await self._avg_time_to_fill(window_from,window_to,department,recruiter_id)
time_to_fill_prior=await self._avg_time_to_fill(prior_from,prior_to,department,recruiter_id)
cost_per_hire=await self._cost_per_hire(hires,window_from,window_to,department,recruiter_id)
cost_per_hire_prior=await self._cost_per_hire(hires_prior,prior_from,prior_to,department,recruiter_id)
return {
"open_jobs": open_jobs,
"open_jobs_prior": open_jobs_prior,
"total_candidates": total_candidates,
"total_candidates_prior": total_candidates_prior,
"interviews_today": interviews_today,
"interviews_upcoming": interviews_upcoming,
"next_interview_at": next_interview_at,
"offers_accepted": offers_accepted,
"offers_accepted_prior": offers_accepted_prior,
"offers_sent": offers_sent,
"offers_sent_prior": offers_sent_prior,
"time_to_hire": time_to_hire,
"time_to_hire_prior": time_to_hire_prior,
"time_to_fill": time_to_fill,
"time_to_fill_prior": time_to_fill_prior,
"cost_per_hire": cost_per_hire,
"cost_per_hire_prior": cost_per_hire_prior,
"closed_jobs": closed_jobs,
"closed_jobs_prior": closed_jobs_prior,
"hires": hires,
"hires_prior": hires_prior,
}
async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None):
statement=select(
Inbox_Messages.application_status,
func.count().label("count"),
).select_from(Inbox_Messages)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
if from_date is not None or to_date is not None:
statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id)
if from_date is not None:
statement=statement.where(Inbox.created_at>=from_date)
if to_date is not None:
statement=statement.where(Inbox.created_at<to_date)
statement=statement.group_by(Inbox_Messages.application_status)
result=await self.session.execute(statement)
counts={str(row[0].value if hasattr(row[0],"value") else row[0]): int(row[1] or 0) for row in result.all()}
return [
serialize_stage_count(stage.value,counts.get(stage.value,0))
for stage in Candidate_application_Status
]
async def get_hiring_trend(self,months=7,from_date=None,to_date=None,department=None,recruiter_id=None):
months=max(1,int(months or 7))
now=datetime.now(timezone.utc)
start=_month_start(now)
# Walk back (months-1) months
for _ in range(months-1):
start=_month_start(start-timedelta(days=1))
month_bucket=func.date_trunc("month",Inbox.created_at)
apps_q=(
select(month_bucket.label("month"),func.count().label("count"))
.select_from(Inbox)
.where(Inbox.created_at>=start)
.group_by(month_bucket)
.order_by(month_bucket)
)
if department or recruiter_id:
apps_q=(
apps_q
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
apps_q=apps_q.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
apps_q=apps_q.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
apps_rows=await self.session.execute(apps_q)
apps_map={}
for month,count in apps_rows.all():
apps_map[_month_key(month)]=int(count or 0)
hire_bucket=func.date_trunc("month",ApplicationStageTransitions.valid_from)
hires_q=(
select(hire_bucket.label("month"),func.count().label("count"))
.select_from(ApplicationStageTransitions)
.where(
ApplicationStageTransitions.to_stage==Candidate_application_Status.HIRED.value,
ApplicationStageTransitions.valid_from>=start,
)
.group_by(hire_bucket)
.order_by(hire_bucket)
)
if department or recruiter_id:
hires_q=(
hires_q
.outerjoin(Inbox,ApplicationStageTransitions.inbox_id==Inbox.id)
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
hires_q=hires_q.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
hires_q=hires_q.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
hire_rows=await self.session.execute(hires_q)
hire_map={}
for month,count in hire_rows.all():
hire_map[_month_key(month)]=int(count or 0)
labels=[]
applications=[]
hires=[]
cursor=start
for _ in range(months):
labels.append(cursor.strftime("%b %Y"))
applications.append(apps_map.get(cursor,0))
hires.append(hire_map.get(cursor,0))
cursor=_next_month_start(cursor)
return {"labels": labels,"applications": applications,"hires": hires}
async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None):
statement=(
select(
func.coalesce(SourceChannels.label,"Unknown").label("source"),
func.count().label("count"),
)
.select_from(Inbox_Messages)
.outerjoin(SourceChannels,Inbox_Messages.source_channel_id==SourceChannels.id)
)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
if from_date is not None or to_date is not None:
statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id)
if from_date is not None:
statement=statement.where(Inbox.created_at>=from_date)
if to_date is not None:
statement=statement.where(Inbox.created_at<to_date)
statement=statement.group_by(SourceChannels.label).order_by(func.count().desc())
result=await self.session.execute(statement)
return [serialize_source_count(source,count) for source,count in result.all()]
async def get_recruiter_performance(self,top=5,from_date=None,to_date=None,department=None,recruiter_id=None):
top=max(1,int(top or 5))
recruiters_q=(
select(Users)
.join(Roles,Users.role_id==Roles.id)
.where(
Roles.role_name==EnumRoles.RECRUITER.value,
Users.is_deleted==False, # noqa: E712
)
)
rid=_as_uuid(recruiter_id)
if rid is not None:
recruiters_q=recruiters_q.where(Users.id==rid)
recruiters=list((await self.session.execute(recruiters_q)).scalars().all())
rows=[]
for user in recruiters:
hires_q=select(func.count()).select_from(Inbox_Messages).where(
Inbox_Messages.recruiter_id==user.id,
Inbox_Messages.application_status==Candidate_application_Status.HIRED,
)
if department:
hires_q=hires_q.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id).where(
JobPosts.department==department
)
if from_date is not None or to_date is not None:
hires_q=hires_q.join(Inbox,Inbox.message_id==Inbox_Messages.id)
if from_date is not None:
hires_q=hires_q.where(Inbox.created_at>=from_date)
if to_date is not None:
hires_q=hires_q.where(Inbox.created_at<to_date)
hires=int((await self.session.execute(hires_q)).scalar_one() or 0)
open_assign=await JobAssignments.count_open_reqs_by_user(self.session,user.id)
open_posts_q=select(func.count()).select_from(JobPosts).where(
JobPosts.current_recruiter_id==user.id,
JobPosts.requisition_status=="open",
JobPosts.is_deleted==False, # noqa: E712
)
if department:
open_posts_q=open_posts_q.where(JobPosts.department==department)
open_posts=int((await self.session.execute(open_posts_q)).scalar_one() or 0)
open_reqs=max(open_assign,open_posts)
avg_tth=await self._avg_time_to_hire(
from_date,to_date,department,recruiter_id=str(user.id)
)
rows.append(serialize_recruiter_row(user.id,user.name,hires,open_reqs,avg_tth))
rows.sort(key=lambda r: r["hires"],reverse=True)
return rows[:top]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -30,6 +30,10 @@ SKIP_SENDER_PREFIXES = ("noreply", "no-reply", "donotreply", "do-not-reply",
"mailer-daemon", "postmaster", "bounce")
def _now() -> datetime:
return datetime.now(timezone.utc)
class Inbox(SQLModel, table=True):
__tablename__ = "inbox"
@ -42,8 +46,12 @@ class Inbox(SQLModel, table=True):
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)
# tz-AWARE, matching every other timestamp the analytics layer filters on.
# A naive column here made asyncpg reject the aware UTC bounds that
# analytics/views.py builds, so /analytics/hiring-trend and /analytics/kpis
# both 500'd before the query ever reached Postgres.
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
favorite: Optional[bool] = Field(default=False)
rating: Optional[float] = Field(default=0.0)
@ -132,6 +140,20 @@ class Inbox(SQLModel, table=True):
result=await session.execute(select(cls).where(cls.id==iid))
return result.scalars().first()
@classmethod
async def get_inbox_with_message(cls,session:AsyncSession,record_id:int|str|None):
"""Inbox row with `messages` selectin-loaded for stage / application writers."""
if record_id is None:
return None
try:
iid=int(record_id)
except (TypeError,ValueError):
return None
result=await session.execute(
select(cls).options(selectinload(cls.messages)).where(cls.id==iid)
)
return result.scalars().first()
@classmethod
async def get_inbox_by_message_id(cls,session:AsyncSession,message_id):
try:
@ -161,7 +183,7 @@ class Inbox(SQLModel, table=True):
return None
for key,value in fields.items():
setattr(row,key,value)
row.updated_at=datetime.now()
row.updated_at=_now()
session.add(row)
await session.commit()
await session.refresh(row)
@ -175,7 +197,7 @@ class Inbox_Alerts(SQLModel, table=True):
alert_sender_name: str
alert_sender_email: str
is_read: bool = Field(default=False)
recieve_time: datetime = Field(default_factory=datetime.now)
recieve_time: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
inbox: list[Inbox] = Relationship(back_populates="alerts")
@ -214,7 +236,15 @@ class Inbox_Messages(SQLModel, table=True):
matched_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
candidate_phone_number: str | None = Field(default="xxx-xxx-xxxx")
candidate_education: str | None = Field(default=None)
current_employment: str | None = Field(default=None)
current_employment: str | None = Field(default=None)
# Denormalised dashboard / list-screen fields. server_default is load-bearing
# for every NOT NULL column — these arrive as ALTERs on a populated table.
ats_score: float | None = Field(default=None)
ats_band: str = Field(default="", sa_column_kwargs={"server_default": ""})
recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
is_duplicate: bool = Field(default=False, sa_column_kwargs={"server_default": "false"})
source_channel_id: int | None = Field(default=None, foreign_key="source_channels.id")
processing_state: str = Field(default="unread", sa_column_kwargs={"server_default": "unread"})
inbox: list[Inbox] = Relationship(back_populates="messages")
@staticmethod
@ -533,3 +563,74 @@ class Inbox_Messages(SQLModel, table=True):
await session.commit()
await session.refresh(row)
return row
class SourceChannels(SQLModel, table=True):
__tablename__ = "source_channels"
id: int | None = Field(default=None, primary_key=True)
key: str = Field(max_length=40, unique=True, index=True)
label: str
is_active: bool = Field(default=True)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id: int):
result = await session.execute(select(cls).where(cls.id == record_id))
return result.scalars().first()
@classmethod
async def get_by_key(cls, session: AsyncSession, key: str):
result = await session.execute(select(cls).where(cls.key == key))
return result.scalars().first()
@classmethod
async def list_active(cls, session: AsyncSession):
result = await session.execute(
select(cls).where(cls.is_active == True).order_by(cls.id.asc()) # noqa: E712
)
return list(result.scalars().all())
class AtsResults(SQLModel, table=True):
__tablename__ = "ats_results"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
inbox_id: int = Field(index=True, foreign_key="inbox.id")
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
overall_score: float = Field(default=0.0)
band: str = Field(default="")
is_current: bool = Field(default=True)
superseded_by_id: uuid.UUID | None = Field(default=None, foreign_key="ats_results.id")
model_name: str | None = Field(default=None)
# default_factory was datetime.now: a naive LOCAL value bound to a timestamptz
# column, which asyncpg reads as UTC. That silently backdated every row by the
# host's offset (+5 h here) instead of raising, unlike the naive-column case.
computed_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_current_for_inbox(cls, session: AsyncSession, inbox_id: int):
result = await session.execute(
select(cls)
.where(cls.inbox_id == int(inbox_id), cls.is_current == True) # noqa: E712
.order_by(cls.computed_at.desc())
)
return result.scalars().first()
@classmethod
async def insert_result(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return row

View File

@ -104,10 +104,13 @@ def serialize_application(message: Inbox_Messages) -> dict:
"match_status": message.match_status,
"match_error": message.match_error,
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
"ats_score": None,
"ats_score": message.ats_score,
"ats_band": message.ats_band or None,
"phone": message.candidate_phone_number,
"experience": message.experience or "",
"current_employment": message.current_employment or "",
"recruiter": None,
"duplicate": None,
"recruiter": str(message.recruiter_id) if message.recruiter_id else None,
"duplicate": message.is_duplicate,
"processing_state": message.processing_state,
"source_channel_id": message.source_channel_id,
}

View File

@ -28,16 +28,32 @@ class ActivityLog:
return row
return None
async def get_activity(self,activity_id=None,inbox_id=None):
async def get_activity(self,activity_id=None,inbox_id=None,top=None,skip=0):
if activity_id:
row=await Activity.get_activity_by_id(self.session,activity_id)
if not row:
raise HTTPException(status_code=404,detail="Activity not found")
return serialize_activity(row)
if inbox_id is None:
raise HTTPException(status_code=400,detail="activity_id or inbox_id is required")
rows=await Activity.get_activity_by_inbox(self.session,int(inbox_id))
return [serialize_activity(r) for r in rows]
if inbox_id is not None:
rows=await Activity.get_activity_by_inbox(self.session,int(inbox_id))
return [serialize_activity(r) for r in rows]
if top is not None:
return await self.get_activity_feed(top=top,skip=skip)
raise HTTPException(status_code=400,detail="activity_id or inbox_id is required")
async def get_activity_feed(self,top,skip=0):
rows,total=await Activity.get_activity_feed(self.session,top=top,skip=skip)
items=[]
for r in rows:
data=serialize_activity(r)
actor_name=None
if r.inbox_id is not None:
inbox=await Inbox.get_inbox_by_id(self.session,r.inbox_id)
if inbox and getattr(inbox,"user",None):
actor_name=inbox.user.name
data["actor_name"]=actor_name
items.append(data)
return items,total
async def create_activity(self,payload):
link=await self._resolve_inbox(payload)

View File

@ -7,6 +7,9 @@ from job.interviews.views import Interview
from job.notes.views import Note
from job.activity.views import ActivityLog
from job.feedback.views import FeedbackView
from job.pipeline.views import Pipeline
from job.assignment.views import Assignment
from job.cost.views import HiringCost
from sqlalchemy.ext.asyncio import AsyncSession
from users.permissions import PermissionTag, require_permission
from job.job_post.views import JobPost,JobPostCreate
@ -86,6 +89,32 @@ class FeedbackUpdate(BaseModel):
reviewed_by: UUID | None = None
class StageChange(BaseModel):
inbox_id: int
to_stage: str
change_reason: str | None = None
class JobAssignmentCreate(BaseModel):
job_post_id: UUID
user_id: UUID
assignment_role: str | None = None
class ApplicationAssignmentCreate(BaseModel):
inbox_id: int
user_id: UUID
assignment_role: str | None = None
class HiringCostCreate(BaseModel):
cost_type: str
amount: float
job_post_id: UUID | None = None
currency: str | None = None
description: str | None = None
incurred_at: datetime | None = None
@router.get("/jobs/alias")
async def get_job_alias():
@ -377,13 +406,29 @@ async def update_candidate(
async def fetch_interview(
interview_id:str=Query(None),
inbox_id:int=Query(None),
from_date:datetime=Query(None),
to_date:datetime=Query(None),
status:str=Query(None),
top:int=Query(None),
skip:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Interview(session=session)
data=await service.get_interview(interview_id=interview_id,inbox_id=inbox_id)
total=1 if isinstance(data,dict) else len(data)
if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or top is not None):
data,total=await service.get_interviews_range(
from_date=from_date,to_date=to_date,status=status,top=top,skip=skip,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
data=await service.get_interview(
interview_id=interview_id,inbox_id=inbox_id,
from_date=from_date,to_date=to_date,status=status,top=top,skip=skip,
)
if isinstance(data,tuple):
data,total=data
else:
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
@ -479,13 +524,21 @@ async def update_note(
async def fetch_activity(
activity_id:str=Query(None),
inbox_id:int=Query(None),
top:int=Query(None),
skip:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=ActivityLog(session=session)
data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id)
total=1 if isinstance(data,dict) else len(data)
if not activity_id and inbox_id is None and top is not None:
data,total=await service.get_activity_feed(top=top,skip=skip)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id,top=top,skip=skip)
if isinstance(data,tuple):
data,total=data
else:
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
@ -558,3 +611,141 @@ async def update_feedback(
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/candidate/stage")
async def change_candidate_stage(
payload:StageChange,
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Pipeline(session=session)
data=await service.change_stage(
payload.inbox_id,payload.to_stage,current_user,change_reason=payload.change_reason,
)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/pipeline/transitions/fetch")
async def fetch_pipeline_transitions(
transition_id:str=Query(None),
inbox_id:int=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Pipeline(session=session)
data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id)
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/assignments/fetch")
async def fetch_job_assignments(
job_post_id:str=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.list_job_assignments(job_post_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/job/assignments/create")
async def create_job_assignment(
payload:JobAssignmentCreate,
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.create_job_assignment(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/assignments/fetch")
async def fetch_application_assignments(
inbox_id:int=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.list_application_assignments(inbox_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/assignments/create")
async def create_application_assignment(
payload:ApplicationAssignmentCreate,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.create_application_assignment(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/costs/fetch")
async def fetch_hiring_costs(
job_post_id:str=Query(None),
from_date:datetime=Query(None),
to_date:datetime=Query(None),
top:int=Query(None),
skip:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=HiringCost(session=session)
data,total=await service.list_costs(
job_post_id=job_post_id,from_date=from_date,to_date=to_date,top=top,skip=skip,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/job/costs/create")
async def create_hiring_cost(
payload:HiringCostCreate,
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=HiringCost(session=session)
data=await service.create_cost(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))

View File

@ -0,0 +1,120 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
def _now() -> datetime:
return datetime.now(timezone.utc)
class JobAssignments(SQLModel, table=True):
__tablename__ = "job_assignments"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
job_post_id: uuid.UUID = Field(index=True, foreign_key="job_posts.id")
user_id: uuid.UUID = Field(foreign_key="users.id")
assignment_role: str = Field(default="primary_recruiter")
valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
assigned_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def fetch_by_job(cls, session: AsyncSession, job_post_id, *, current_only: bool = True):
uid = cls._as_uuid(job_post_id)
if uid is None:
return []
statement = select(cls).where(cls.job_post_id == uid)
if current_only:
statement = statement.where(cls.valid_to.is_(None))
statement = statement.order_by(cls.valid_from.desc())
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def insert_assignment(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_by_id(session, row.id)
@classmethod
async def count_open_reqs_by_user(cls, session: AsyncSession, user_id):
uid = cls._as_uuid(user_id)
if uid is None:
return 0
statement = (
select(func.count())
.select_from(cls)
.where(cls.user_id == uid, cls.valid_to.is_(None))
)
result = await session.execute(statement)
return result.scalar_one()
class ApplicationAssignments(SQLModel, table=True):
__tablename__ = "application_assignments"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
inbox_id: int = Field(index=True, foreign_key="inbox.id")
user_id: uuid.UUID = Field(foreign_key="users.id")
assignment_role: str = Field(default="primary_recruiter")
valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
assigned_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def fetch_by_inbox(cls, session: AsyncSession, inbox_id: int, *, current_only: bool = True):
statement = select(cls).where(cls.inbox_id == int(inbox_id))
if current_only:
statement = statement.where(cls.valid_to.is_(None))
statement = statement.order_by(cls.valid_from.desc())
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def insert_assignment(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_by_id(session, row.id)
import users.models as _users_models # noqa: E402, F401

View File

@ -0,0 +1,24 @@
def serialize_job_assignment(row) -> dict:
return {
"id": str(row.id),
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"user_id": str(row.user_id) if row.user_id else None,
"assignment_role": row.assignment_role,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
"assigned_by": str(row.assigned_by) if row.assigned_by else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
def serialize_application_assignment(row) -> dict:
return {
"id": str(row.id),
"inbox_id": row.inbox_id,
"user_id": str(row.user_id) if row.user_id else None,
"assignment_role": row.assignment_role,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
"assigned_by": str(row.assigned_by) if row.assigned_by else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}

View File

@ -0,0 +1,69 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from job.assignment.models import ApplicationAssignments, JobAssignments
from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment
from role.models import EnumRoles, Roles
from users.models import Users
class Assignment:
def __init__(self,session:AsyncSession):
self.session=session
async def _require_recruiter(self,user_id):
role=await Roles.get_role_by_name(self.session,EnumRoles.RECRUITER.value)
user=await Users.get_user_by_id(self.session,user_id)
if not role or not user or user.role_id!=role.id:
raise HTTPException(status_code=422,detail="user_id must be a recruiter")
return user
async def list_job_assignments(self,job_post_id):
if not job_post_id:
raise HTTPException(status_code=400,detail="job_post_id is required")
rows=await JobAssignments.fetch_by_job(self.session,job_post_id)
return [serialize_job_assignment(r) for r in rows]
async def create_job_assignment(self,payload,current_user):
user_id=payload.get("user_id")
job_post_id=payload.get("job_post_id")
if not user_id or not job_post_id:
raise HTTPException(status_code=422,detail="user_id and job_post_id are required")
await self._require_recruiter(user_id)
fields={
"job_post_id":JobAssignments._as_uuid(job_post_id),
"user_id":JobAssignments._as_uuid(user_id),
"assignment_role":payload.get("assignment_role") or "primary_recruiter",
"assigned_by":JobAssignments._as_uuid(
current_user.get("id") if isinstance(current_user,dict) else None
),
}
if not fields["job_post_id"] or not fields["user_id"] or not fields["assigned_by"]:
raise HTTPException(status_code=422,detail="Invalid job_post_id, user_id, or assigned_by")
row=await JobAssignments.insert_assignment(self.session,fields)
return serialize_job_assignment(row)
async def list_application_assignments(self,inbox_id):
if inbox_id is None:
raise HTTPException(status_code=400,detail="inbox_id is required")
rows=await ApplicationAssignments.fetch_by_inbox(self.session,int(inbox_id))
return [serialize_application_assignment(r) for r in rows]
async def create_application_assignment(self,payload,current_user):
user_id=payload.get("user_id")
inbox_id=payload.get("inbox_id")
if not user_id or inbox_id is None:
raise HTTPException(status_code=422,detail="user_id and inbox_id are required")
await self._require_recruiter(user_id)
fields={
"inbox_id":int(inbox_id),
"user_id":ApplicationAssignments._as_uuid(user_id),
"assignment_role":payload.get("assignment_role") or "primary_recruiter",
"assigned_by":ApplicationAssignments._as_uuid(
current_user.get("id") if isinstance(current_user,dict) else None
),
}
if not fields["user_id"] or not fields["assigned_by"]:
raise HTTPException(status_code=422,detail="Invalid user_id or assigned_by")
row=await ApplicationAssignments.insert_assignment(self.session,fields)
return serialize_application_assignment(row)

View File

@ -2,7 +2,7 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import JSON, DateTime, UniqueConstraint
from sqlalchemy import JSON, DateTime, func, UniqueConstraint
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select
@ -263,6 +263,34 @@ class Interviews(SQLModel, table=True):
)
return result.scalars().all()
@classmethod
async def get_interviews_in_range(
cls,
session: AsyncSession,
*,
from_date=None,
to_date=None,
status: str | None = None,
top: int | None = None,
skip: int = 0,
):
statement = select(cls)
if from_date is not None:
statement = statement.where(cls.interview_date >= from_date)
if to_date is not None:
statement = statement.where(cls.interview_date < to_date)
if status:
statement = statement.where(cls.interview_status == status)
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.interview_date.asc())
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.scalars().all()), total
@classmethod
async def insert_interview(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
@ -384,6 +412,19 @@ class Activity(SQLModel, table=True):
)
return result.scalars().all()
@classmethod
async def get_activity_feed(cls, session: AsyncSession, *, top: int | None = None, skip: int = 0):
statement = select(cls)
count_statement = select(func.count()).select_from(cls)
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.activity_date.desc(), cls.activity_time.desc())
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.scalars().all()), total
@classmethod
async def insert_activity(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
@ -468,4 +509,85 @@ class Feedback(SQLModel, table=True):
return row
class ApplicationStageTransitions(SQLModel, table=True):
"""Temporal history of inbox_messages.application_status changes.
valid_from / valid_to make time-in-stage a subtraction rather than a window
function. NULL valid_to means the stage is still current.
"""
__tablename__ = "application_stage_transitions"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
inbox_id: int = Field(index=True, foreign_key="inbox.id")
from_stage: str | None = Field(default=None)
to_stage: str
valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
changed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
actor_kind: str = Field(default="user")
change_reason: str | None = Field(default=None)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def fetch_by_inbox(cls, session: AsyncSession, inbox_id: int):
result = await session.execute(
select(cls).where(cls.inbox_id == int(inbox_id)).order_by(cls.valid_from.desc())
)
return list(result.scalars().all())
@classmethod
async def get_open_transition(cls, session: AsyncSession, inbox_id: int):
result = await session.execute(
select(cls)
.where(cls.inbox_id == int(inbox_id), cls.valid_to.is_(None))
.order_by(cls.valid_from.desc())
)
return result.scalars().first()
@classmethod
async def insert_transition(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
row = cls(**fields)
session.add(row)
if commit:
await session.commit()
await session.refresh(row)
return row
@classmethod
async def close_open(cls, session: AsyncSession, inbox_id: int, *, at: datetime | None = None, commit: bool = False):
row = await cls.get_open_transition(session, inbox_id)
if not row:
return None
row.valid_to = at or _now()
session.add(row)
if commit:
await session.commit()
await session.refresh(row)
return row
@classmethod
async def count_by_inbox(cls, session: AsyncSession, inbox_id: int):
statement = select(func.count()).select_from(cls).where(cls.inbox_id == int(inbox_id))
result = await session.execute(statement)
return result.scalar_one()
import users.models as _users_models # noqa: E402, F401

View File

@ -0,0 +1,91 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
def _now() -> datetime:
return datetime.now(timezone.utc)
class HiringCosts(SQLModel, table=True):
__tablename__ = "hiring_costs"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
cost_type: str = Field(default="other")
amount: float = Field(default=0.0)
currency: str = Field(default="USD")
incurred_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
description: str | None = Field(default=None)
created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def fetch_costs(
cls,
session: AsyncSession,
*,
job_post_id=None,
from_date=None,
to_date=None,
top: int | None = None,
skip: int = 0,
):
statement = select(cls)
if job_post_id is not None:
uid = cls._as_uuid(job_post_id)
if uid is not None:
statement = statement.where(cls.job_post_id == uid)
if from_date is not None:
statement = statement.where(cls.incurred_at >= from_date)
if to_date is not None:
statement = statement.where(cls.incurred_at < to_date)
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.incurred_at.desc())
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.scalars().all()), total
@classmethod
async def insert_cost(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_by_id(session, row.id)
@classmethod
async def sum_amount(cls, session: AsyncSession, *, from_date=None, to_date=None):
statement = select(func.coalesce(func.sum(cls.amount), 0.0))
if from_date is not None:
statement = statement.where(cls.incurred_at >= from_date)
if to_date is not None:
statement = statement.where(cls.incurred_at < to_date)
result = await session.execute(statement)
return float(result.scalar_one() or 0.0)
import users.models as _users_models # noqa: E402, F401

View File

@ -0,0 +1,13 @@
def serialize_hiring_cost(row) -> dict:
return {
"id": str(row.id),
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"cost_type": row.cost_type,
"amount": row.amount,
"currency": row.currency,
"incurred_at": row.incurred_at.isoformat() if row.incurred_at else None,
"description": row.description,
"created_by": str(row.created_by) if row.created_by else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}

44
backend/job/cost/views.py Normal file
View File

@ -0,0 +1,44 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from job.cost.models import HiringCosts
from job.cost.serializers import serialize_hiring_cost
class HiringCost:
def __init__(self,session:AsyncSession):
self.session=session
async def list_costs(self,job_post_id=None,from_date=None,to_date=None,top=None,skip=0):
rows,total=await HiringCosts.fetch_costs(
self.session,
job_post_id=job_post_id,
from_date=from_date,
to_date=to_date,
top=top,
skip=skip,
)
return [serialize_hiring_cost(r) for r in rows],total
async def create_cost(self,payload,current_user):
cost_type=payload.get("cost_type")
amount=payload.get("amount")
if not cost_type or amount is None:
raise HTTPException(status_code=422,detail="cost_type and amount are required")
created_by=HiringCosts._as_uuid(
current_user.get("id") if isinstance(current_user,dict) else None
)
if not created_by:
raise HTTPException(status_code=422,detail="created_by is required")
fields={
"job_post_id":HiringCosts._as_uuid(payload.get("job_post_id")),
"cost_type":cost_type,
"amount":float(amount),
"currency":payload.get("currency") or "USD",
"description":payload.get("description"),
"created_by":created_by,
}
if payload.get("incurred_at") is not None:
fields["incurred_at"]=payload["incurred_at"]
row=await HiringCosts.insert_cost(self.session,fields)
return serialize_hiring_cost(row)

View File

@ -1,4 +1,6 @@
def serialize_interview(row) -> dict:
inbox=getattr(row,"inbox",None)
user=getattr(inbox,"user",None) if inbox else None
return {
"id": str(row.id),
"inbox_id": row.inbox_id,
@ -6,4 +8,5 @@ def serialize_interview(row) -> dict:
"interview_time": row.interview_time.isoformat() if row.interview_time else None,
"interview_type": row.interview_type,
"interview_status": row.interview_status,
"candidate_name": user.name if user else None,
}

View File

@ -9,16 +9,31 @@ class Interview:
def __init__(self,session:AsyncSession):
self.session=session
async def get_interview(self,interview_id=None,inbox_id=None):
async def get_interview(self,interview_id=None,inbox_id=None,from_date=None,to_date=None,status=None,top=None,skip=0):
if interview_id:
row=await Interviews.get_interview_by_id(self.session,interview_id)
if not row:
raise HTTPException(status_code=404,detail="Interview not found")
return serialize_interview(row)
if inbox_id is None:
raise HTTPException(status_code=400,detail="interview_id or inbox_id is required")
rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id))
return [serialize_interview(r) for r in rows]
if inbox_id is not None:
rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id))
return [serialize_interview(r) for r in rows]
if from_date is not None or to_date is not None or status is not None or top is not None:
return await self.get_interviews_range(
from_date=from_date,to_date=to_date,status=status,top=top,skip=skip,
)
raise HTTPException(status_code=400,detail="interview_id or inbox_id is required")
async def get_interviews_range(self,from_date=None,to_date=None,status=None,top=None,skip=0):
rows,total=await Interviews.get_interviews_in_range(
self.session,
from_date=from_date,
to_date=to_date,
status=status,
top=top,
skip=skip,
)
return [serialize_interview(r) for r in rows],total
async def create_interview(self,payload):
fields={

View File

@ -20,9 +20,15 @@ class JobPosts(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
title: str = Field(index=True)
# foreign_keys is required, not decoration: current_recruiter_id below is a
# SECOND foreign key into users.id, so the join condition is ambiguous without
# it and every mapper fails to initialize. `user` is the AUTHOR of the post —
# current_recruiter_id is deliberately a bare column with no relationship of
# its own, because Users already carries five selectin relations that load on
# every authenticated request. Same pairing as Notes.user / Notes.author.
user: Optional["Users"] = Relationship(
back_populates="job_posts",
sa_relationship_kwargs={"lazy": "joined"},
sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"},
)
platform: str = Field(default="linkedin")
@ -43,6 +49,14 @@ class JobPosts(SQLModel, table=True):
buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
status: str = Field(default="draft")
buffer_error: str | None = Field(default=None)
# requisition_status is the hiring lifecycle (open/closed/on_hold). Distinct from
# `status`, which tracks Buffer publishing (draft/scheduled/published/failed).
# server_default is load-bearing: this column arrives as an ALTER on a populated table.
requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"})
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))

View File

@ -0,0 +1,13 @@
def serialize_stage_transition(row) -> dict:
return {
"id": str(row.id),
"inbox_id": row.inbox_id,
"from_stage": row.from_stage,
"to_stage": row.to_stage,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
"changed_by": str(row.changed_by) if row.changed_by else None,
"actor_kind": row.actor_kind,
"change_reason": row.change_reason,
"created_at": row.created_at.isoformat() if row.created_at else None,
}

View File

@ -0,0 +1,64 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from inbox.enums import Candidate_application_Status
from inbox.models import Inbox
from job.candidate.models import ApplicationStageTransitions
from job.pipeline.serializers import serialize_stage_transition
class Pipeline:
def __init__(self,session:AsyncSession):
self.session=session
async def get_transitions(self,inbox_id=None,transition_id=None):
if transition_id:
row=await ApplicationStageTransitions.get_by_id(self.session,transition_id)
if not row:
raise HTTPException(status_code=404,detail="Transition not found")
return serialize_stage_transition(row)
if inbox_id is None:
raise HTTPException(status_code=400,detail="transition_id or inbox_id is required")
rows=await ApplicationStageTransitions.fetch_by_inbox(self.session,int(inbox_id))
return [serialize_stage_transition(r) for r in rows]
async def change_stage(self,inbox_id,to_stage,current_user,change_reason=None):
inbox=await Inbox.get_inbox_with_message(self.session,inbox_id)
if not inbox:
raise HTTPException(status_code=404,detail="Inbox not found")
message=inbox.messages
if not message:
raise HTTPException(status_code=404,detail="Inbox message not found")
try:
stage=Candidate_application_Status(to_stage)
except ValueError:
raise HTTPException(status_code=422,detail="Invalid to_stage")
current=message.application_status
from_stage=current.value if isinstance(current,Candidate_application_Status) else str(current)
if from_stage==stage.value:
raise HTTPException(status_code=400,detail="already at stage")
changed_by=None
if isinstance(current_user,dict) and current_user.get("id"):
changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id"))
await ApplicationStageTransitions.close_open(self.session,inbox.id,commit=False)
transition_data={
"inbox_id":inbox.id,
"from_stage":from_stage,
"to_stage":stage.value,
"changed_by":changed_by,
"actor_kind":"user",
"change_reason":change_reason,
}
transition=await ApplicationStageTransitions.insert_transition(
self.session,
transition_data,
commit=False,
)
message.application_status=stage
self.session.add(message)
await self.session.commit()
return {
"inbox_id":inbox.id,
"application_status":stage.value,
"transition":serialize_stage_transition(transition),
}

View File

@ -10,6 +10,8 @@ from role.app import router as role_router
from forget_password.app import router as forget_password_router
from job.app import router as candidate_router
from notifications.app import router as confirmation_router
from analytics.app import router as analytics_router
from offer.app import router as offer_router
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
logger=logging.getLogger("main")
@ -79,3 +81,5 @@ app.include_router(role_router)
app.include_router(forget_password_router)
app.include_router(confirmation_router)
app.include_router(candidate_router)
app.include_router(analytics_router)
app.include_router(offer_router)

View File

@ -0,0 +1,242 @@
-- 001_dashboard_rbac_and_enum.sql
-- Manual one-shot: enum labels, permission tags, analytics_dashboard bundle,
-- source channels, and backfills. Run in psql against the app DB.
-- ADD VALUE cannot run inside a transaction that also uses the new labels —
-- run section 1a with autocommit (psql default outside BEGIN).
--
-- Order: (1) alembic upgrade for new tables/columns, (2) this file.
-- Section 1a (enum) can run before or after alembic.
-- =============================================================================
-- 1a. Extend candidate_application_status (unqualified — matches original migration)
-- =============================================================================
ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'SCREENING';
ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'ASSESSMENT';
ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'INTERVIEW';
ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'OFFER';
ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'HIRED';
-- =============================================================================
-- 1b. Seed all 104 permission tags (module x action) idempotently
-- =============================================================================
INSERT INTO app.permission_tags
(tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
VALUES
('dashboard.view', 'dashboard', 'view', NULL, NOW(), NOW(), true, false),
('dashboard.create', 'dashboard', 'create', NULL, NOW(), NOW(), true, false),
('dashboard.edit', 'dashboard', 'edit', NULL, NOW(), NOW(), true, false),
('dashboard.delete', 'dashboard', 'delete', NULL, NOW(), NOW(), true, false),
('dashboard.approve', 'dashboard', 'approve', NULL, NOW(), NOW(), true, false),
('dashboard.export', 'dashboard', 'export', NULL, NOW(), NOW(), true, false),
('dashboard.manage', 'dashboard', 'manage', NULL, NOW(), NOW(), true, false),
('dashboard.configure', 'dashboard', 'configure', NULL, NOW(), NOW(), true, false),
('inbox.view', 'inbox', 'view', NULL, NOW(), NOW(), true, false),
('inbox.create', 'inbox', 'create', NULL, NOW(), NOW(), true, false),
('inbox.edit', 'inbox', 'edit', NULL, NOW(), NOW(), true, false),
('inbox.delete', 'inbox', 'delete', NULL, NOW(), NOW(), true, false),
('inbox.approve', 'inbox', 'approve', NULL, NOW(), NOW(), true, false),
('inbox.export', 'inbox', 'export', NULL, NOW(), NOW(), true, false),
('inbox.manage', 'inbox', 'manage', NULL, NOW(), NOW(), true, false),
('inbox.configure', 'inbox', 'configure', NULL, NOW(), NOW(), true, false),
('jobs.view', 'jobs', 'view', NULL, NOW(), NOW(), true, false),
('jobs.create', 'jobs', 'create', NULL, NOW(), NOW(), true, false),
('jobs.edit', 'jobs', 'edit', NULL, NOW(), NOW(), true, false),
('jobs.delete', 'jobs', 'delete', NULL, NOW(), NOW(), true, false),
('jobs.approve', 'jobs', 'approve', NULL, NOW(), NOW(), true, false),
('jobs.export', 'jobs', 'export', NULL, NOW(), NOW(), true, false),
('jobs.manage', 'jobs', 'manage', NULL, NOW(), NOW(), true, false),
('jobs.configure', 'jobs', 'configure', NULL, NOW(), NOW(), true, false),
('candidates.view', 'candidates', 'view', NULL, NOW(), NOW(), true, false),
('candidates.create', 'candidates', 'create', NULL, NOW(), NOW(), true, false),
('candidates.edit', 'candidates', 'edit', NULL, NOW(), NOW(), true, false),
('candidates.delete', 'candidates', 'delete', NULL, NOW(), NOW(), true, false),
('candidates.approve', 'candidates', 'approve', NULL, NOW(), NOW(), true, false),
('candidates.export', 'candidates', 'export', NULL, NOW(), NOW(), true, false),
('candidates.manage', 'candidates', 'manage', NULL, NOW(), NOW(), true, false),
('candidates.configure', 'candidates', 'configure', NULL, NOW(), NOW(), true, false),
('pipeline.view', 'pipeline', 'view', NULL, NOW(), NOW(), true, false),
('pipeline.create', 'pipeline', 'create', NULL, NOW(), NOW(), true, false),
('pipeline.edit', 'pipeline', 'edit', NULL, NOW(), NOW(), true, false),
('pipeline.delete', 'pipeline', 'delete', NULL, NOW(), NOW(), true, false),
('pipeline.approve', 'pipeline', 'approve', NULL, NOW(), NOW(), true, false),
('pipeline.export', 'pipeline', 'export', NULL, NOW(), NOW(), true, false),
('pipeline.manage', 'pipeline', 'manage', NULL, NOW(), NOW(), true, false),
('pipeline.configure', 'pipeline', 'configure', NULL, NOW(), NOW(), true, false),
('interviews.view', 'interviews', 'view', NULL, NOW(), NOW(), true, false),
('interviews.create', 'interviews', 'create', NULL, NOW(), NOW(), true, false),
('interviews.edit', 'interviews', 'edit', NULL, NOW(), NOW(), true, false),
('interviews.delete', 'interviews', 'delete', NULL, NOW(), NOW(), true, false),
('interviews.approve', 'interviews', 'approve', NULL, NOW(), NOW(), true, false),
('interviews.export', 'interviews', 'export', NULL, NOW(), NOW(), true, false),
('interviews.manage', 'interviews', 'manage', NULL, NOW(), NOW(), true, false),
('interviews.configure', 'interviews', 'configure', NULL, NOW(), NOW(), true, false),
('assessments.view', 'assessments', 'view', NULL, NOW(), NOW(), true, false),
('assessments.create', 'assessments', 'create', NULL, NOW(), NOW(), true, false),
('assessments.edit', 'assessments', 'edit', NULL, NOW(), NOW(), true, false),
('assessments.delete', 'assessments', 'delete', NULL, NOW(), NOW(), true, false),
('assessments.approve', 'assessments', 'approve', NULL, NOW(), NOW(), true, false),
('assessments.export', 'assessments', 'export', NULL, NOW(), NOW(), true, false),
('assessments.manage', 'assessments', 'manage', NULL, NOW(), NOW(), true, false),
('assessments.configure', 'assessments', 'configure', NULL, NOW(), NOW(), true, false),
('offers.view', 'offers', 'view', NULL, NOW(), NOW(), true, false),
('offers.create', 'offers', 'create', NULL, NOW(), NOW(), true, false),
('offers.edit', 'offers', 'edit', NULL, NOW(), NOW(), true, false),
('offers.delete', 'offers', 'delete', NULL, NOW(), NOW(), true, false),
('offers.approve', 'offers', 'approve', NULL, NOW(), NOW(), true, false),
('offers.export', 'offers', 'export', NULL, NOW(), NOW(), true, false),
('offers.manage', 'offers', 'manage', NULL, NOW(), NOW(), true, false),
('offers.configure', 'offers', 'configure', NULL, NOW(), NOW(), true, false),
('reports.view', 'reports', 'view', NULL, NOW(), NOW(), true, false),
('reports.create', 'reports', 'create', NULL, NOW(), NOW(), true, false),
('reports.edit', 'reports', 'edit', NULL, NOW(), NOW(), true, false),
('reports.delete', 'reports', 'delete', NULL, NOW(), NOW(), true, false),
('reports.approve', 'reports', 'approve', NULL, NOW(), NOW(), true, false),
('reports.export', 'reports', 'export', NULL, NOW(), NOW(), true, false),
('reports.manage', 'reports', 'manage', NULL, NOW(), NOW(), true, false),
('reports.configure', 'reports', 'configure', NULL, NOW(), NOW(), true, false),
('analytics.view', 'analytics', 'view', NULL, NOW(), NOW(), true, false),
('analytics.create', 'analytics', 'create', NULL, NOW(), NOW(), true, false),
('analytics.edit', 'analytics', 'edit', NULL, NOW(), NOW(), true, false),
('analytics.delete', 'analytics', 'delete', NULL, NOW(), NOW(), true, false),
('analytics.approve', 'analytics', 'approve', NULL, NOW(), NOW(), true, false),
('analytics.export', 'analytics', 'export', NULL, NOW(), NOW(), true, false),
('analytics.manage', 'analytics', 'manage', NULL, NOW(), NOW(), true, false),
('analytics.configure', 'analytics', 'configure', NULL, NOW(), NOW(), true, false),
('job_board.view', 'job_board', 'view', NULL, NOW(), NOW(), true, false),
('job_board.create', 'job_board', 'create', NULL, NOW(), NOW(), true, false),
('job_board.edit', 'job_board', 'edit', NULL, NOW(), NOW(), true, false),
('job_board.delete', 'job_board', 'delete', NULL, NOW(), NOW(), true, false),
('job_board.approve', 'job_board', 'approve', NULL, NOW(), NOW(), true, false),
('job_board.export', 'job_board', 'export', NULL, NOW(), NOW(), true, false),
('job_board.manage', 'job_board', 'manage', NULL, NOW(), NOW(), true, false),
('job_board.configure', 'job_board', 'configure', NULL, NOW(), NOW(), true, false),
('settings.view', 'settings', 'view', NULL, NOW(), NOW(), true, false),
('settings.create', 'settings', 'create', NULL, NOW(), NOW(), true, false),
('settings.edit', 'settings', 'edit', NULL, NOW(), NOW(), true, false),
('settings.delete', 'settings', 'delete', NULL, NOW(), NOW(), true, false),
('settings.approve', 'settings', 'approve', NULL, NOW(), NOW(), true, false),
('settings.export', 'settings', 'export', NULL, NOW(), NOW(), true, false),
('settings.manage', 'settings', 'manage', NULL, NOW(), NOW(), true, false),
('settings.configure', 'settings', 'configure', NULL, NOW(), NOW(), true, false),
('rbac_users.view', 'rbac_users', 'view', NULL, NOW(), NOW(), true, false),
('rbac_users.create', 'rbac_users', 'create', NULL, NOW(), NOW(), true, false),
('rbac_users.edit', 'rbac_users', 'edit', NULL, NOW(), NOW(), true, false),
('rbac_users.delete', 'rbac_users', 'delete', NULL, NOW(), NOW(), true, false),
('rbac_users.approve', 'rbac_users', 'approve', NULL, NOW(), NOW(), true, false),
('rbac_users.export', 'rbac_users', 'export', NULL, NOW(), NOW(), true, false),
('rbac_users.manage', 'rbac_users', 'manage', NULL, NOW(), NOW(), true, false),
('rbac_users.configure', 'rbac_users', 'configure', NULL, NOW(), NOW(), true, false)
ON CONFLICT (tag_name) DO NOTHING;
-- Bundle holding dashboard / analytics / offers / interviews.view tags
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
SELECT
'analytics_dashboard',
'Dashboard KPI tiles, analytics charts, offers, and interview list',
(
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
FROM app.permission_tags
WHERE is_deleted = false
AND (
module IN ('dashboard', 'analytics', 'offers')
OR tag_name = 'interviews.view'
)
),
true,
NOW(),
NOW(),
true,
false
WHERE NOT EXISTS (
SELECT 1 FROM app.permissions WHERE name = 'analytics_dashboard'
);
-- Append the bundle id to the named system roles (idempotent)
UPDATE app.roles r
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
updated_at = NOW()
FROM app.permissions p
WHERE p.name = 'analytics_dashboard'
AND r.role_name IN (
'system_administrator',
'hr_administrator',
'recruiter',
'hiring_manager',
'department_head',
'ceo'
)
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
-- =============================================================================
-- 1c. Source channels (eleven BRD channels)
-- =============================================================================
INSERT INTO app.source_channels (key, label, is_active, created_at, updated_at)
VALUES
('microsoft_outlook', 'Microsoft Outlook', true, NOW(), NOW()),
('career_portal', 'Career Portal', true, NOW(), NOW()),
('manual_cv_upload', 'Manual CV Upload', true, NOW(), NOW()),
('linkedin', 'LinkedIn', true, NOW(), NOW()),
('indeed', 'Indeed', true, NOW(), NOW()),
('rozee', 'Rozee', true, NOW(), NOW()),
('mustakbil', 'Mustakbil', true, NOW(), NOW()),
('employee_referral', 'Employee Referral', true, NOW(), NOW()),
('recruitment_agency', 'Recruitment Agency', true, NOW(), NOW()),
('campus_hiring', 'Campus Hiring', true, NOW(), NOW()),
('walk_in', 'Walk-in', true, NOW(), NOW())
ON CONFLICT (key) DO NOTHING;
-- =============================================================================
-- Backfills (require new columns/tables from alembic)
-- =============================================================================
-- Source channel from message_to board tags; default Microsoft Outlook
UPDATE app.inbox_messages m
SET source_channel_id = sc.id
FROM app.source_channels sc
WHERE m.source_channel_id IS NULL
AND (
(LOWER(m.message_to) LIKE '%linkedin%' AND sc.key = 'linkedin')
OR (LOWER(m.message_to) LIKE '%indeed%' AND sc.key = 'indeed')
OR (LOWER(m.message_to) LIKE '%rozee%' AND sc.key = 'rozee')
OR (LOWER(m.message_to) LIKE '%mustakbil%' AND sc.key = 'mustakbil')
OR (LOWER(m.message_to) LIKE '%referral%' AND sc.key = 'employee_referral')
OR (LOWER(m.message_to) LIKE '%agency%' AND sc.key = 'recruitment_agency')
OR (LOWER(m.message_to) LIKE '%campus%' AND sc.key = 'campus_hiring')
OR (LOWER(m.message_to) LIKE '%portal%' AND sc.key = 'career_portal')
OR (LOWER(m.message_to) LIKE '%walk%' AND sc.key = 'walk_in')
OR (LOWER(m.message_to) LIKE '%manual%' AND sc.key = 'manual_cv_upload')
);
UPDATE app.inbox_messages m
SET source_channel_id = sc.id
FROM app.source_channels sc
WHERE m.source_channel_id IS NULL
AND sc.key = 'microsoft_outlook';
-- One open stage-transition row per application (cannot invent history)
INSERT INTO app.application_stage_transitions
(id, inbox_id, from_stage, to_stage, valid_from, valid_to, changed_by, actor_kind, change_reason, created_at)
SELECT
gen_random_uuid(),
i.id,
NULL,
m.application_status::text,
COALESCE(i.created_at, NOW()),
NULL,
NULL,
'system',
'backfill',
NOW()
FROM app.inbox i
JOIN app.inbox_messages m ON m.id = i.message_id
WHERE NOT EXISTS (
SELECT 1 FROM app.application_stage_transitions t WHERE t.inbox_id = i.id
);
-- Requisition status from is_active / is_deleted
UPDATE app.job_posts
SET requisition_status = CASE
WHEN is_active AND NOT is_deleted THEN 'open'
ELSE 'closed'
END
WHERE requisition_status IS NULL OR requisition_status = '';

View File

@ -0,0 +1,53 @@
"""auto
Revision ID: 2515ed7e2966
Revises: 72853b8d2126
Create Date: 2026-08-07 14:49:26.409410+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 = '2515ed7e2966'
down_revision: Union[str, None] = '72853b8d2126'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Deliberately NOT schema-qualified: Inbox_Messages.application_status carries
# type.schema = None, so the ORM emits the type name unqualified and resolves it
# through search_path. Creating it as app.<name> would not match.
application_status = sa.Enum(
'PROCESS', 'PENDING', 'APPROVED', 'REJECTED', 'ONHOLD', 'CLOSED',
name='candidate_application_status',
)
def upgrade() -> None:
# op.add_column does NOT emit CREATE TYPE, so the ALTER below would fail with
# `type "candidate_application_status" does not exist`.
application_status.create(op.get_bind(), checkfirst=True)
# server_default is required, not cosmetic: the column is NOT NULL and every
# existing row would be null without it, which Postgres rejects outright.
op.add_column(
'inbox_messages',
sa.Column(
'application_status',
application_status,
nullable=False,
server_default='CLOSED',
),
schema='app',
)
def downgrade() -> None:
op.drop_column('inbox_messages', 'application_status', schema='app')
# drop_column leaves the TYPE behind; without this, re-running upgrade fails
# with `type "candidate_application_status" already exists`.
application_status.drop(op.get_bind(), checkfirst=True)

View File

@ -0,0 +1,40 @@
"""auto
Revision ID: 8c67df87f19c
Revises: 2515ed7e2966
Create Date: 2026-08-07 14:55:42.858317+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 = '8c67df87f19c'
down_revision: Union[str, None] = '2515ed7e2966'
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.alter_column('inbox_messages', 'application_status',
existing_type=postgresql.ENUM('PROCESS', 'PENDING', 'APPROVED', 'REJECTED', 'ONHOLD', 'CLOSED', name='candidate_application_status'),
server_default=None,
existing_nullable=False,
schema='app')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('inbox_messages', 'application_status',
existing_type=postgresql.ENUM('PROCESS', 'PENDING', 'APPROVED', 'REJECTED', 'ONHOLD', 'CLOSED', name='candidate_application_status'),
server_default=sa.text("'CLOSED'::candidate_application_status"),
existing_nullable=False,
schema='app')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""add experience to inbox_messages
Revision ID: fcd3f4d69b60
Revises: 8c67df87f19c
Create Date: 2026-08-10 09:56:55.290497+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 = 'fcd3f4d69b60'
down_revision: Union[str, None] = '8c67df87f19c'
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('experience', 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', 'experience', schema='app')
# ### end Alembic commands ###

View File

@ -0,0 +1,34 @@
"""auto
Revision ID: ed78f1706042
Revises: fcd3f4d69b60
Create Date: 2026-08-11 10:07:53.324376+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 = 'ed78f1706042'
down_revision: Union[str, None] = 'fcd3f4d69b60'
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('candidate_phone_number', sqlmodel.sql.sqltypes.AutoString(), nullable=True), schema='app')
op.add_column('inbox_messages', sa.Column('current_employment', 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', 'current_employment', schema='app')
op.drop_column('inbox_messages', 'candidate_phone_number', schema='app')
# ### end Alembic commands ###

View File

@ -0,0 +1,90 @@
"""auto
Revision ID: d0b62342a32f
Revises: ed78f1706042
Create Date: 2026-08-11 11:49:25.965217+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 = 'd0b62342a32f'
down_revision: Union[str, None] = 'ed78f1706042'
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('notes',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('note', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=True),
sa.Column('created_by', sa.Uuid(), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['app.users.id'], name=op.f('fk_notes_created_by_users')),
sa.ForeignKeyConstraint(['user_id'], ['app.users.id'], name=op.f('fk_notes_user_id_users')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_notes')),
schema='app'
)
op.create_table('activity',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('activity_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('activity_date', sa.DateTime(), nullable=False),
sa.Column('activity_time', sa.DateTime(), nullable=False),
sa.Column('activity_status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('inbox_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['inbox_id'], ['app.inbox.id'], name=op.f('fk_activity_inbox_id_inbox')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_activity')),
schema='app'
)
op.create_table('feedback',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('review', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('financial_status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('score', sa.Float(), nullable=False),
sa.Column('note', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('reviewed_by', sa.Uuid(), nullable=True),
sa.Column('inbox_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['inbox_id'], ['app.inbox.id'], name=op.f('fk_feedback_inbox_id_inbox')),
sa.ForeignKeyConstraint(['reviewed_by'], ['app.users.id'], name=op.f('fk_feedback_reviewed_by_users')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_feedback')),
schema='app'
)
op.create_table('interviews',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('interview_date', sa.DateTime(), nullable=False),
sa.Column('interview_time', sa.DateTime(), nullable=False),
sa.Column('interview_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('interview_status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('inbox_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['inbox_id'], ['app.inbox.id'], name=op.f('fk_interviews_inbox_id_inbox')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_interviews')),
schema='app'
)
op.add_column('inbox', sa.Column('favorite', sa.Boolean(), nullable=True), schema='app')
op.add_column('inbox', sa.Column('rating', sa.Float(), nullable=True), schema='app')
op.add_column('inbox_messages', sa.Column('candidate_education', 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', 'candidate_education', schema='app')
op.drop_column('inbox', 'rating', schema='app')
op.drop_column('inbox', 'favorite', schema='app')
op.drop_table('interviews', schema='app')
op.drop_table('feedback', schema='app')
op.drop_table('activity', schema='app')
op.drop_table('notes', schema='app')
# ### end Alembic commands ###

View File

@ -0,0 +1,110 @@
"""auto
Revision ID: 681a78158447
Revises: d0b62342a32f
Create Date: 2026-08-11 13:11:58.032282+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 = '681a78158447'
down_revision: Union[str, None] = 'd0b62342a32f'
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.alter_column('activity', 'activity_date',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
schema='app')
op.alter_column('activity', 'activity_time',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
schema='app')
op.alter_column('feedback', 'created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
schema='app')
op.alter_column('feedback', 'updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
schema='app')
op.alter_column('interviews', 'interview_date',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
schema='app')
op.alter_column('interviews', 'interview_time',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
schema='app')
op.alter_column('notes', 'created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
schema='app')
op.alter_column('notes', 'updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
schema='app')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('notes', 'updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
schema='app')
op.alter_column('notes', 'created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
schema='app')
op.alter_column('interviews', 'interview_time',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
schema='app')
op.alter_column('interviews', 'interview_date',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
schema='app')
op.alter_column('feedback', 'updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
schema='app')
op.alter_column('feedback', 'created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
schema='app')
op.alter_column('activity', 'activity_time',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
schema='app')
op.alter_column('activity', 'activity_date',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
schema='app')
# ### end Alembic commands ###

View File

@ -0,0 +1,36 @@
"""auto
Revision ID: 67c4c7974afa
Revises: 681a78158447
Create Date: 2026-08-11 13:52:11.674108+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 = '67c4c7974afa'
down_revision: Union[str, None] = '681a78158447'
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('assigned_job_post_id', sa.Uuid(), nullable=True), schema='app')
op.create_index(op.f('ix_inbox_messages_assigned_job_post_id'), 'inbox_messages', ['assigned_job_post_id'], unique=False, schema='app')
op.create_foreign_key(op.f('fk_inbox_messages_assigned_job_post_id_job_posts'), 'inbox_messages', 'job_posts', ['assigned_job_post_id'], ['id'], source_schema='app', referent_schema='app')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('fk_inbox_messages_assigned_job_post_id_job_posts'), 'inbox_messages', schema='app', type_='foreignkey')
op.drop_index(op.f('ix_inbox_messages_assigned_job_post_id'), table_name='inbox_messages', schema='app')
op.drop_column('inbox_messages', 'assigned_job_post_id', schema='app')
# ### end Alembic commands ###

View File

@ -0,0 +1,52 @@
"""auto
Revision ID: 4b34909ae551
Revises: 67c4c7974afa
Create Date: 2026-08-12 07:46:38.028948+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 = '4b34909ae551'
down_revision: Union[str, None] = '67c4c7974afa'
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('manual_upload_candidate',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('candidate_email', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('candidate_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('candidate_phone', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('job_post_id', sa.Uuid(), nullable=True),
sa.Column('full_text', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('current_company', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=True),
sa.Column('platform', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_by', sa.Uuid(), nullable=True),
sa.Column('experience', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['app.users.id'], name=op.f('fk_manual_upload_candidate_created_by_users')),
sa.ForeignKeyConstraint(['job_post_id'], ['app.job_posts.id'], name=op.f('fk_manual_upload_candidate_job_post_id_job_posts')),
sa.ForeignKeyConstraint(['user_id'], ['app.users.id'], name=op.f('fk_manual_upload_candidate_user_id_users')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_manual_upload_candidate')),
schema='app'
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('manual_upload_candidate', schema='app')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""auto
Revision ID: 1a7c0e30aaab
Revises: 4b34909ae551
Create Date: 2026-08-12 08:17:21.212665+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 = '1a7c0e30aaab'
down_revision: Union[str, None] = '4b34909ae551'
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('manual_upload_candidate', sa.Column('referral_by', sqlmodel.sql.sqltypes.AutoString(), server_default='', nullable=False), schema='app')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('manual_upload_candidate', 'referral_by', schema='app')
# ### end Alembic commands ###

View File

@ -0,0 +1,34 @@
"""auto
Revision ID: 6ec5e105dce3
Revises: 1a7c0e30aaab
Create Date: 2026-08-12 08:45:01.957310+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 = '6ec5e105dce3'
down_revision: Union[str, None] = '1a7c0e30aaab'
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('manual_upload_candidate', sa.Column('file_name', sqlmodel.sql.sqltypes.AutoString(), server_default='', nullable=False), schema='app')
op.add_column('manual_upload_candidate', sa.Column('file_path', sqlmodel.sql.sqltypes.AutoString(), server_default='', nullable=False), schema='app')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('manual_upload_candidate', 'file_path', schema='app')
op.drop_column('manual_upload_candidate', 'file_name', schema='app')
# ### end Alembic commands ###

125
backend/offer/app.py Normal file
View File

@ -0,0 +1,125 @@
from datetime import datetime
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 offer.views import Offer
from users.permissions import PermissionTag,require_permission
from dotenv import load_dotenv
load_dotenv()
router = APIRouter()
class OfferCreate(BaseModel):
inbox_id: int
job_post_id: str
candidate_user_id: str
status: str | None = "draft"
base_salary: float | None = None
currency: str | None = None
salary_period: str | None = None
signing_bonus: float | None = None
annual_bonus_pct: float | None = None
equity_units: int | None = None
equity_instrument: str | None = None
start_date: datetime | None = None
expiry_date: datetime | None = None
change_reason: str | None = None
class OfferUpdate(BaseModel):
status: str | None = None
base_salary: float | None = None
currency: str | None = None
salary_period: str | None = None
signing_bonus: float | None = None
annual_bonus_pct: float | None = None
equity_units: int | None = None
equity_instrument: str | None = None
start_date: datetime | None = None
expiry_date: datetime | None = None
sent_at: datetime | None = None
responded_at: datetime | None = None
closed_at: datetime | None = None
issued_by: str | None = None
inbox_id: int | None = None
job_post_id: str | None = None
candidate_user_id: str | None = None
change_reason: str | None = None
class OfferIssue(BaseModel):
change_reason: str | None = None
@router.get("/offers/fetch")
async def fetch_offers(
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)),
offer_id: str | None = Query(None),
status: str | None = Query(None),
inbox_id: int | None = Query(None),
top: int | None = Query(None),
skip: int = Query(0,ge=0),
session: AsyncSession = Depends(get_session),
):
try:
service=Offer(session=session)
data,total=await service.get_offers(offer_id,status,inbox_id,top,skip)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/offers/create")
async def create_offer(
payload: OfferCreate,
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=Offer(session=session)
data=await service.create_offer(payload.model_dump(exclude_unset=True),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.patch("/offers/update")
async def update_offer(
payload: OfferUpdate,
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_EDIT)),
offer_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
try:
service=Offer(session=session)
data=await service.update_offer(offer_id,payload.model_dump(exclude_unset=True),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.post("/offers/issue")
async def issue_offer(
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_APPROVE)),
offer_id: str = Query(...),
payload: OfferIssue | None = None,
session: AsyncSession = Depends(get_session),
):
try:
service=Offer(session=session)
data=await service.issue_offer(offer_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))

152
backend/offer/models.py Normal file
View File

@ -0,0 +1,152 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
def _now() -> datetime:
return datetime.now(timezone.utc)
class Offers(SQLModel, table=True):
__tablename__ = "offers"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
inbox_id: int = Field(index=True, foreign_key="inbox.id")
job_post_id: uuid.UUID = Field(foreign_key="job_posts.id")
candidate_user_id: uuid.UUID = Field(foreign_key="users.id")
status: str = Field(default="draft")
base_salary: float = Field(default=0.0)
currency: str = Field(default="USD")
salary_period: str = Field(default="annual")
signing_bonus: float | None = Field(default=None)
annual_bonus_pct: float | None = Field(default=None)
equity_units: int | None = Field(default=None)
equity_instrument: str | None = Field(default=None)
start_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
expiry_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
responded_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
issued_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_offer_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def fetch_offers(
cls,
session: AsyncSession,
*,
status: str | None = None,
inbox_id: int | None = None,
top: int | None = None,
skip: int = 0,
):
statement = select(cls)
if status:
statement = statement.where(cls.status == status)
if inbox_id is not None:
statement = statement.where(cls.inbox_id == int(inbox_id))
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.created_at.desc())
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.scalars().all()), total
@classmethod
async def insert_offer(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_offer_by_id(session, row.id)
@classmethod
async def update_offer(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_offer_by_id(session, record_id)
if not row:
return None
for key, value in fields.items():
setattr(row, key, value)
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def count_by_status(cls, session: AsyncSession, status: str, *, from_date=None, to_date=None):
statement = select(func.count()).select_from(cls).where(cls.status == status)
if from_date is not None:
statement = statement.where(cls.created_at >= from_date)
if to_date is not None:
statement = statement.where(cls.created_at < to_date)
result = await session.execute(statement)
return result.scalar_one()
class OfferStatusHistory(SQLModel, table=True):
__tablename__ = "offer_status_history"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
offer_id: uuid.UUID = Field(index=True, foreign_key="offers.id")
from_status: str | None = Field(default=None)
to_status: str
valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
changed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
actor_kind: str = Field(default="user")
change_reason: str | None = Field(default=None)
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_offer(cls, session: AsyncSession, offer_id):
uid = cls._as_uuid(offer_id)
if uid is None:
return []
result = await session.execute(
select(cls).where(cls.offer_id == uid).order_by(cls.valid_from.desc())
)
return list(result.scalars().all())
@classmethod
async def insert_history(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
row = cls(**fields)
session.add(row)
if commit:
await session.commit()
await session.refresh(row)
return row
import users.models as _users_models # noqa: E402, F401

4
backend/offer/plugins.py Normal file
View File

@ -0,0 +1,4 @@
def non_validation_values():
fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct",
"equity_units","equity_instrument","start_date","expiry_date")
return fields

View File

@ -0,0 +1,38 @@
def serialize_offer(row) -> dict:
return {
"id": str(row.id) if row.id else None,
"inbox_id": row.inbox_id,
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"candidate_user_id": str(row.candidate_user_id) if row.candidate_user_id else None,
"status": row.status,
"base_salary": row.base_salary,
"currency": row.currency,
"salary_period": row.salary_period,
"signing_bonus": row.signing_bonus,
"annual_bonus_pct": row.annual_bonus_pct,
"equity_units": row.equity_units,
"equity_instrument": row.equity_instrument,
"start_date": row.start_date.isoformat() if row.start_date else None,
"expiry_date": row.expiry_date.isoformat() if row.expiry_date else None,
"sent_at": row.sent_at.isoformat() if row.sent_at else None,
"responded_at": row.responded_at.isoformat() if row.responded_at else None,
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
"issued_by": str(row.issued_by) if row.issued_by else None,
"created_by": str(row.created_by) if row.created_by else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def serialize_offer_history(row) -> dict:
return {
"id": str(row.id) if row.id else None,
"offer_id": str(row.offer_id) if row.offer_id else None,
"from_status": row.from_status,
"to_status": row.to_status,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
"changed_by": str(row.changed_by) if row.changed_by else None,
"actor_kind": row.actor_kind,
"change_reason": row.change_reason,
}

146
backend/offer/views.py Normal file
View File

@ -0,0 +1,146 @@
import uuid
from datetime import datetime,timezone
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from offer.models import Offers,OfferStatusHistory
from offer.serializers import serialize_offer
from offer.plugins import non_validation_values
def _as_uuid(value):
if value in (None,""):
return None
try:
return uuid.UUID(str(value))
except (TypeError,ValueError):
return None
def _user_id(current_user):
if not current_user or not current_user.get("id"):
raise HTTPException(status_code=401,detail="Not authenticated")
uid=_as_uuid(current_user["id"])
if uid is None:
raise HTTPException(status_code=401,detail="Invalid user id")
return uid
class Offer:
def __init__(self,session:AsyncSession):
self.session=session
async def get_offers(self,offer_id=None,status=None,inbox_id=None,top=None,skip=0):
if offer_id is not None:
row=await Offers.get_offer_by_id(self.session,offer_id)
if not row:
raise HTTPException(status_code=404,detail="Offer not found")
return serialize_offer(row),1
rows,total=await Offers.fetch_offers(
self.session,
status=status,
inbox_id=inbox_id,
top=top,
skip=skip or 0,
)
return [serialize_offer(r) for r in rows],total
async def create_offer(self,payload,current_user):
if not payload.get("inbox_id"):
raise HTTPException(status_code=422,detail="inbox_id is required")
job_post_id=_as_uuid(payload.get("job_post_id"))
if job_post_id is None:
raise HTTPException(status_code=422,detail="job_post_id is required")
candidate_user_id=_as_uuid(payload.get("candidate_user_id"))
if candidate_user_id is None:
raise HTTPException(status_code=422,detail="candidate_user_id is required")
created_by=_user_id(current_user)
status=payload.get("status") or "draft"
fields={
"inbox_id": int(payload["inbox_id"]),
"job_post_id": job_post_id,
"candidate_user_id": candidate_user_id,
"created_by": created_by,
"status": status,
}
for key in non_validation_values():
if key in payload and payload[key] is not None:
fields[key]=payload[key]
row=await Offers.insert_offer(self.session,fields)
history_data={
"offer_id": row.id,
"from_status": None,
"to_status": status,
"changed_by": created_by,
"actor_kind": "user",
"change_reason": payload.get("change_reason"),
}
await OfferStatusHistory.insert_history(self.session,history_data)
return serialize_offer(row)
async def update_offer(self,offer_id,payload,current_user):
row=await Offers.get_offer_by_id(self.session,offer_id)
if not row:
raise HTTPException(status_code=404,detail="Offer not found")
changed_by=_user_id(current_user)
fields={}
for key in (
"status","base_salary","currency","salary_period","signing_bonus","annual_bonus_pct",
"equity_units","equity_instrument","start_date","expiry_date","sent_at",
"responded_at","closed_at","issued_by","inbox_id","job_post_id","candidate_user_id",
):
if key not in payload:
continue
value=payload[key]
if key in ("job_post_id","candidate_user_id","issued_by") and value is not None:
value=_as_uuid(value)
if value is None:
raise HTTPException(status_code=422,detail=f"Invalid {key}")
fields[key]=value
if not fields:
raise HTTPException(status_code=400,detail="No fields to update")
new_status=fields.get("status")
if new_status is not None and new_status!=row.status:
await OfferStatusHistory.insert_history(self.session,{
"offer_id": row.id,
"from_status": row.status,
"to_status": new_status,
"changed_by": changed_by,
"actor_kind": "user",
"change_reason": payload.get("change_reason"),
},commit=False)
updated=await Offers.update_offer(self.session,offer_id,fields)
if not updated:
raise HTTPException(status_code=404,detail="Offer not found")
return serialize_offer(updated)
async def issue_offer(self,offer_id,current_user):
row=await Offers.get_offer_by_id(self.session,offer_id)
if not row:
raise HTTPException(status_code=404,detail="Offer not found")
issued_by=_user_id(current_user)
now=datetime.now(timezone.utc)
from_status=row.status
fields={"issued_by": issued_by,"sent_at": now}
to_status=from_status
if from_status=="draft":
fields["status"]="sent"
to_status="sent"
updated=await Offers.update_offer(self.session,offer_id,fields)
if not updated:
raise HTTPException(status_code=404,detail="Offer not found")
history_data={
"offer_id": updated.id,
"from_status": from_status,
"to_status": to_status,
"changed_by": issued_by,
"actor_kind": "user",
"change_reason": "issued",
}
await OfferStatusHistory.insert_history(self.session,history_data)
return serialize_offer(updated)

View File

@ -1,11 +1,15 @@
from datetime import datetime
from datetime import datetime, timezone
from enum import Enum
from sqlalchemy import Column, UniqueConstraint, func, or_
from sqlalchemy import Column, DateTime, UniqueConstraint, func, or_
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select
def _now() -> datetime:
return datetime.now(timezone.utc)
class EnumRoles(str, Enum):
"""Canonical keys for the eight seeded system roles. `Roles.role_name` is a varchar."""
@ -30,8 +34,8 @@ class PermissionTags(SQLModel, table=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)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_active: bool = Field(default=True)
is_deleted: bool = Field(default=False)
@ -104,8 +108,8 @@ class Permissions(SQLModel, table=True):
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)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_active: bool = Field(default=True)
is_deleted: bool = Field(default=False)
@ -182,7 +186,7 @@ class Permissions(SQLModel, table=True):
return None
for key, value in fields.items():
setattr(row, key, value)
row.updated_at = datetime.now()
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
@ -195,7 +199,7 @@ class Permissions(SQLModel, table=True):
return None
row.is_deleted = True
row.is_active = False
row.updated_at = datetime.now()
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
@ -210,8 +214,8 @@ class Roles(SQLModel, table=True):
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)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_active: bool = Field(default=True)
is_deleted: bool = Field(default=False)
@ -280,7 +284,7 @@ class Roles(SQLModel, table=True):
return None
for key, value in fields.items():
setattr(row, key, value)
row.updated_at = datetime.now()
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
@ -293,7 +297,7 @@ class Roles(SQLModel, table=True):
return None
row.is_deleted = True
row.is_active = False
row.updated_at = datetime.now()
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)

View File

@ -0,0 +1,58 @@
"""Taskiq CV-upload broker — isolated Redis stream so manual uploads
never sit behind the bulk /email/fetch backlog.
Worker: taskiq worker taskiq_management.cv_broker_setup:cv_broker inbox.cv_tasks
Scheduler: taskiq scheduler taskiq_management.cv_broker_setup:cv_scheduler inbox.cv_tasks
"""
from __future__ import annotations
import os
from dotenv import load_dotenv
from taskiq import TaskiqScheduler
from taskiq.middlewares import SmartRetryMiddleware
from taskiq.schedule_sources import LabelScheduleSource
from taskiq_redis import (
ListRedisScheduleSource,
RedisAsyncResultBackend,
RedisStreamBroker,
)
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY
from taskiq_management.middleware import DeadLetterMiddleware
load_dotenv()
REDIS_URL=os.getenv("REDIS_URL","redis://localhost:6379/0")
CV_QUEUE_NAME=os.getenv("TASKIQ_CV_QUEUE_NAME","cv_upload")
result_backend=RedisAsyncResultBackend(redis_url=REDIS_URL)
cv_schedule_source=ListRedisScheduleSource(url=REDIS_URL,prefix="taskiq:schedule:cv")
cv_broker=(
RedisStreamBroker(
url=REDIS_URL,
queue_name=CV_QUEUE_NAME,
consumer_group_name=os.getenv("TASKIQ_CONSUMER_GROUP","taskiq"),
idle_timeout=int(os.getenv("TASKIQ_IDLE_TIMEOUT_MS","600000")),
)
.with_result_backend(result_backend)
.with_middlewares(
DeadLetterMiddleware(redis_url=REDIS_URL),
SmartRetryMiddleware(
default_retry_count=MAX_RETRIES,
default_retry_label=True,
default_delay=RETRY_DELAY,
use_jitter=True,
use_delay_exponent=True,
max_delay_exponent=float(os.getenv("TASKIQ_MAX_DELAY","120")),
schedule_source=cv_schedule_source,
),
)
)
cv_scheduler=TaskiqScheduler(
broker=cv_broker,
sources=[cv_schedule_source,LabelScheduleSource(cv_broker)],
)

View File

@ -1,8 +1,8 @@
import uuid
from datetime import datetime
from datetime import datetime, timezone
from typing import TYPE_CHECKING,List,Optional
from sqlalchemy import func, or_
from sqlalchemy import DateTime, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import Field, Relationship, SQLModel, select
@ -14,6 +14,11 @@ if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this modul
from inbox.models import Inbox
from job.candidate.models import Feedback, Notes
def _now() -> datetime:
return datetime.now(timezone.utc)
class Users(SQLModel, table=True):
__tablename__ = "users"
@ -27,9 +32,11 @@ class Users(SQLModel, table=True):
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
# user row once per post. Without an explicit strategy the default is a lazy load,
# which raises MissingGreenlet the moment anything touches it under asyncio.
# foreign_keys must match the other side: job_posts.current_recruiter_id is a
# second FK into this table, so this relation has to say it means created_by.
job_posts: List[JobPosts] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin"},
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
)
inbox: List["Inbox"] = Relationship(
back_populates="user",
@ -50,8 +57,8 @@ class Users(SQLModel, table=True):
password: str
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_active: bool = Field(default=False)
is_deleted: bool = Field(default=False)
@ -140,7 +147,7 @@ class Users(SQLModel, table=True):
return None
for key, value in fields.items():
setattr(user, key, value)
user.updated_at = datetime.now()
user.updated_at = _now()
session.add(user)
await session.commit()
await session.refresh(user)
@ -153,7 +160,7 @@ class Users(SQLModel, table=True):
return None
user.is_deleted = True
user.is_active = False
user.updated_at = datetime.now()
user.updated_at = _now()
session.add(user)
await session.commit()
await session.refresh(user)

View File

@ -0,0 +1,8 @@
{
"hash": "4acff9bb",
"configHash": "53a8a5ec",
"lockfileHash": "fac4afd8",
"browserHash": "5b5a9255",
"optimized": {},
"chunks": {}
}

View File

@ -0,0 +1,3 @@
{
"type": "module"
}

File diff suppressed because one or more lines are too long

32
frontend/dist/index.html vendored Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<!-- viewport-fit=cover lets the layout reach under the iOS notch/home bar;
the safe-area insets in styles.css keep content clear of them.
No maximum-scale/user-scalable — pinch-zoom must stay available. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<!-- Per-route titles are set at runtime by useRouteMeta. -->
<title>TalentFlow · Applicant Tracking System</title>
<meta name="theme-color" content="#004d43" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0e1d1f" media="(prefers-color-scheme: dark)" />
<!-- Utopia brand type: Belleza (main headings) + Inter as the metric-
compatible stand-in for Neue Montreal, which is a licensed face.
If Neue Montreal is installed locally it wins via the CSS stack. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-D3fSq-cJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

16
frontend/node_modules/.bin/baseline-browser-mapping generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../baseline-browser-mapping/dist/cli.cjs" "$@"
else
exec node "$basedir/../baseline-browser-mapping/dist/cli.cjs" "$@"
fi

View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\baseline-browser-mapping\dist\cli.cjs" %*

View File

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../baseline-browser-mapping/dist/cli.cjs" $args
} else {
& "$basedir/node$exe" "$basedir/../baseline-browser-mapping/dist/cli.cjs" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../baseline-browser-mapping/dist/cli.cjs" $args
} else {
& "node$exe" "$basedir/../baseline-browser-mapping/dist/cli.cjs" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/browserslist generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../browserslist/cli.js" "$@"
else
exec node "$basedir/../browserslist/cli.js" "$@"
fi

17
frontend/node_modules/.bin/browserslist.cmd generated vendored Normal file
View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\browserslist\cli.js" %*

28
frontend/node_modules/.bin/browserslist.ps1 generated vendored Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../browserslist/cli.js" $args
} else {
& "$basedir/node$exe" "$basedir/../browserslist/cli.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../browserslist/cli.js" $args
} else {
& "node$exe" "$basedir/../browserslist/cli.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/esbuild generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../esbuild/bin/esbuild" "$@"
else
exec node "$basedir/../esbuild/bin/esbuild" "$@"
fi

17
frontend/node_modules/.bin/esbuild.cmd generated vendored Normal file
View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\esbuild\bin\esbuild" %*

28
frontend/node_modules/.bin/esbuild.ps1 generated vendored Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../esbuild/bin/esbuild" $args
} else {
& "$basedir/node$exe" "$basedir/../esbuild/bin/esbuild" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../esbuild/bin/esbuild" $args
} else {
& "node$exe" "$basedir/../esbuild/bin/esbuild" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/jsesc generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../jsesc/bin/jsesc" "$@"
else
exec node "$basedir/../jsesc/bin/jsesc" "$@"
fi

17
frontend/node_modules/.bin/jsesc.cmd generated vendored Normal file
View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\jsesc\bin\jsesc" %*

28
frontend/node_modules/.bin/jsesc.ps1 generated vendored Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../jsesc/bin/jsesc" $args
} else {
& "$basedir/node$exe" "$basedir/../jsesc/bin/jsesc" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../jsesc/bin/jsesc" $args
} else {
& "node$exe" "$basedir/../jsesc/bin/jsesc" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/json5 generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../json5/lib/cli.js" "$@"
else
exec node "$basedir/../json5/lib/cli.js" "$@"
fi

17
frontend/node_modules/.bin/json5.cmd generated vendored Normal file
View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\json5\lib\cli.js" %*

28
frontend/node_modules/.bin/json5.ps1 generated vendored Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../json5/lib/cli.js" $args
} else {
& "$basedir/node$exe" "$basedir/../json5/lib/cli.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../json5/lib/cli.js" $args
} else {
& "node$exe" "$basedir/../json5/lib/cli.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/nanoid generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../nanoid/bin/nanoid.cjs" "$@"
else
exec node "$basedir/../nanoid/bin/nanoid.cjs" "$@"
fi

17
frontend/node_modules/.bin/nanoid.cmd generated vendored Normal file
View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\nanoid\bin\nanoid.cjs" %*

28
frontend/node_modules/.bin/nanoid.ps1 generated vendored Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../nanoid/bin/nanoid.cjs" $args
} else {
& "$basedir/node$exe" "$basedir/../nanoid/bin/nanoid.cjs" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../nanoid/bin/nanoid.cjs" $args
} else {
& "node$exe" "$basedir/../nanoid/bin/nanoid.cjs" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/parser generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../@babel/parser/bin/babel-parser.js" "$@"
else
exec node "$basedir/../@babel/parser/bin/babel-parser.js" "$@"
fi

17
frontend/node_modules/.bin/parser.cmd generated vendored Normal file
View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\@babel\parser\bin\babel-parser.js" %*

28
frontend/node_modules/.bin/parser.ps1 generated vendored Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../@babel/parser/bin/babel-parser.js" $args
} else {
& "$basedir/node$exe" "$basedir/../@babel/parser/bin/babel-parser.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../@babel/parser/bin/babel-parser.js" $args
} else {
& "node$exe" "$basedir/../@babel/parser/bin/babel-parser.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/rollup generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../rollup/dist/bin/rollup" "$@"
else
exec node "$basedir/../rollup/dist/bin/rollup" "$@"
fi

17
frontend/node_modules/.bin/rollup.cmd generated vendored Normal file
View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\rollup\dist\bin\rollup" %*

28
frontend/node_modules/.bin/rollup.ps1 generated vendored Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../rollup/dist/bin/rollup" $args
} else {
& "$basedir/node$exe" "$basedir/../rollup/dist/bin/rollup" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../rollup/dist/bin/rollup" $args
} else {
& "node$exe" "$basedir/../rollup/dist/bin/rollup" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/semver generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../semver/bin/semver.js" "$@"
else
exec node "$basedir/../semver/bin/semver.js" "$@"
fi

17
frontend/node_modules/.bin/semver.cmd generated vendored Normal file
View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\semver\bin\semver.js" %*

28
frontend/node_modules/.bin/semver.ps1 generated vendored Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../semver/bin/semver.js" $args
} else {
& "$basedir/node$exe" "$basedir/../semver/bin/semver.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../semver/bin/semver.js" $args
} else {
& "node$exe" "$basedir/../semver/bin/semver.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/specificity generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../@bramus/specificity/bin/cli.js" "$@"
else
exec node "$basedir/../@bramus/specificity/bin/cli.js" "$@"
fi

17
frontend/node_modules/.bin/specificity.cmd generated vendored Normal file
View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\@bramus\specificity\bin\cli.js" %*

28
frontend/node_modules/.bin/specificity.ps1 generated vendored Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../@bramus/specificity/bin/cli.js" $args
} else {
& "$basedir/node$exe" "$basedir/../@bramus/specificity/bin/cli.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../@bramus/specificity/bin/cli.js" $args
} else {
& "node$exe" "$basedir/../@bramus/specificity/bin/cli.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/tldts generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../tldts/bin/cli.js" "$@"
else
exec node "$basedir/../tldts/bin/cli.js" "$@"
fi

17
frontend/node_modules/.bin/tldts.cmd generated vendored Normal file
View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\tldts\bin\cli.js" %*

28
frontend/node_modules/.bin/tldts.ps1 generated vendored Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../tldts/bin/cli.js" $args
} else {
& "$basedir/node$exe" "$basedir/../tldts/bin/cli.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../tldts/bin/cli.js" $args
} else {
& "node$exe" "$basedir/../tldts/bin/cli.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/update-browserslist-db generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../update-browserslist-db/cli.js" "$@"
else
exec node "$basedir/../update-browserslist-db/cli.js" "$@"
fi

17
frontend/node_modules/.bin/update-browserslist-db.cmd generated vendored Normal file
View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\update-browserslist-db\cli.js" %*

Some files were not shown because too many files have changed in this diff Show More