Merge branch 'RecruiterHub' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into RecruiterHub

pull/16/head
ahmed.mujtaba 2026-08-13 19:25:45 +05:00
commit a1f0b4797a
13 changed files with 888 additions and 84 deletions

View File

@ -12,6 +12,7 @@ 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
from tasks.app import router as tasks_router
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
logger=logging.getLogger("main")
@ -83,3 +84,4 @@ app.include_router(confirmation_router)
app.include_router(candidate_router)
app.include_router(analytics_router)
app.include_router(offer_router)
app.include_router(tasks_router)

View File

@ -0,0 +1,66 @@
-- 004_tasks_rbac.sql
-- Manual one-shot: the `tasks` permission module (8 tags), a `tasks_management`
-- bundle holding them, and the bundle attached to the staff roles that work the
-- task list. Mirrors 001's idempotent pattern; applied automatically at startup
-- by alembic_setup.run_manual_sql() and recorded in manual_migrations.
--
-- The all_access bundle is a fixed id list seeded before this module existed,
-- so system_administrator gets tasks access through THIS bundle, not that one.
-- Users must log in again after this applies — permissions are resolved from
-- the DB per request, but the frontend caches the list from /users/me.
-- =============================================================================
-- 1. The 8 tasks.* permission tags
-- =============================================================================
INSERT INTO app.permission_tags
(tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
VALUES
('tasks.view', 'tasks', 'view', NULL, NOW(), NOW(), true, false),
('tasks.create', 'tasks', 'create', NULL, NOW(), NOW(), true, false),
('tasks.edit', 'tasks', 'edit', NULL, NOW(), NOW(), true, false),
('tasks.delete', 'tasks', 'delete', NULL, NOW(), NOW(), true, false),
('tasks.approve', 'tasks', 'approve', NULL, NOW(), NOW(), true, false),
('tasks.export', 'tasks', 'export', NULL, NOW(), NOW(), true, false),
('tasks.manage', 'tasks', 'manage', NULL, NOW(), NOW(), true, false),
('tasks.configure', 'tasks', 'configure', NULL, NOW(), NOW(), true, false)
ON CONFLICT (tag_name) DO NOTHING;
-- =============================================================================
-- 2. Bundle holding all eight tasks tags
-- =============================================================================
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
SELECT
'tasks_management',
'Recruiting task list: view, create, complete and manage tasks',
(
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
FROM app.permission_tags
WHERE is_deleted = false
AND module = 'tasks'
),
true,
NOW(),
NOW(),
true,
false
WHERE NOT EXISTS (
SELECT 1 FROM app.permissions WHERE name = 'tasks_management'
);
-- =============================================================================
-- 3. Attach the bundle to the staff roles (idempotent; same role list as 001)
-- =============================================================================
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 = 'tasks_management'
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));

View File

@ -0,0 +1,61 @@
-- 005_tasks_rbac_restrict.sql
-- Manual one-shot: restrict task CREATION in the permission database to
-- system_administrator / hr_administrator / recruiter. 004 attached the full
-- tasks_management bundle (all 8 tags) to six roles; here hiring_manager,
-- department_head and ceo swap it for a view-only bundle so the permission DB
-- itself says who may create — the tasks service additionally enforces the
-- creator-role check at request time. Idempotent; auto-applied at startup by
-- alembic_setup.run_manual_sql().
-- =============================================================================
-- 1. View-only bundle (tasks.view, tasks.export)
-- =============================================================================
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
SELECT
'tasks_viewer',
'Recruiting task list: read-only access',
(
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
FROM app.permission_tags
WHERE is_deleted = false
AND tag_name IN ('tasks.view', 'tasks.export')
),
true,
NOW(),
NOW(),
true,
false
WHERE NOT EXISTS (
SELECT 1 FROM app.permissions WHERE name = 'tasks_viewer'
);
-- =============================================================================
-- 2. Remove the full tasks_management bundle from the non-creator roles
-- (jsonb arrays hold numbers, so `-` text removal does not apply — rebuild)
-- =============================================================================
UPDATE app.roles r
SET permissions = (
SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb)
FROM jsonb_array_elements(COALESCE(r.permissions, '[]'::jsonb)) elem
WHERE elem <> to_jsonb(p.id)
),
updated_at = NOW()
FROM app.permissions p
WHERE p.name = 'tasks_management'
AND r.role_name IN ('hiring_manager', 'department_head', 'ceo')
AND COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id);
-- =============================================================================
-- 3. Attach the view-only bundle to those roles
-- =============================================================================
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 = 'tasks_viewer'
AND r.role_name IN (
'hiring_manager',
'department_head',
'ceo'
)
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));

118
backend/tasks/app.py Normal file
View File

@ -0,0 +1,118 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from db_setup import get_session
from tasks.views import Task
from users.permissions import PermissionTag, require_permission
router = APIRouter()
class TaskCreate(BaseModel):
title: str
priority: str | None = None
due_date: datetime | None = None
assignee_id: str | None = None # defaults to the caller server-side
inbox_id: int | None = None
job_post_id: str | None = None
class TaskUpdate(BaseModel):
title: str | None = None
status: str | None = None # open | done — the complete/reopen flip
priority: str | None = None
due_date: datetime | None = None
assignee_id: str | None = None
inbox_id: int | None = None
job_post_id: str | None = None
@router.get("/tasks/fetch")
async def fetch_tasks(
current_user: dict = Depends(require_permission(PermissionTag.TASKS_VIEW)),
status: str | None = Query(None),
priority: str | None = Query(None),
assignee_id: str | None = Query(None),
top: int | None = Query(None),
skip: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
):
try:
service = Task(session=session)
data, total = await service.get_tasks(status, priority, assignee_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.get("/tasks/assignees/fetch")
async def fetch_task_assignees(
current_user: dict = Depends(require_permission(PermissionTag.TASKS_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Assignee picker: active recruiter-role users (role id resolved from the
roles table). Separate from /users/fetch so assigning a task never requires
rbac_users.view."""
try:
service = Task(session=session)
data, total = await service.get_assignees()
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("/tasks/create")
async def create_task(
payload: TaskCreate,
current_user: dict = Depends(require_permission(PermissionTag.TASKS_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service = Task(session=session)
data = await service.create_task(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("/tasks/update")
async def update_task(
payload: TaskUpdate,
current_user: dict = Depends(require_permission(PermissionTag.TASKS_EDIT)),
task_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
try:
service = Task(session=session)
data = await service.update_task(task_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.delete("/tasks/delete")
async def delete_task(
current_user: dict = Depends(require_permission(PermissionTag.TASKS_DELETE)),
task_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
try:
service = Task(session=session)
data = await service.delete_task(task_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))

111
backend/tasks/models.py Normal file
View File

@ -0,0 +1,111 @@
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 Tasks(SQLModel, table=True):
__tablename__ = "tasks"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
title: str
status: str = Field(default="open") # open | done
priority: str = Field(default="medium") # high | medium | low
due_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
# The assignee is a real users row; views rejects candidate-role accounts.
assignee_id: uuid.UUID = Field(index=True, foreign_key="users.id")
# Optional links to the work the task is about. Schema-only for now — the
# Tasks screen does not surface them yet.
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.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))
is_deleted: bool = Field(default=False)
@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_task_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, cls.is_deleted == False) # noqa: E712
)
return result.scalars().first()
@classmethod
async def fetch_tasks(
cls,
session: AsyncSession,
*,
status: str | None = None,
priority: str | None = None,
assignee_id: uuid.UUID | None = None,
top: int | None = None,
skip: int = 0,
):
statement = select(cls).where(cls.is_deleted == False) # noqa: E712
if status:
statement = statement.where(cls.status == status)
if priority:
statement = statement.where(cls.priority == priority)
if assignee_id is not None:
statement = statement.where(cls.assignee_id == assignee_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_task(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_task_by_id(session, row.id)
@classmethod
async def update_task(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_task_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 soft_delete_task(cls, session: AsyncSession, record_id):
row = await cls.get_task_by_id(session, record_id)
if not row:
return None
row.is_deleted = True
row.updated_at = _now()
session.add(row)
await session.commit()
return row
import users.models as _users_models # noqa: E402, F401

View File

@ -0,0 +1,21 @@
def serialize_task(row, users_by_id=None) -> dict:
"""`users_by_id` is one batched Users lookup done in views — never a lazy
per-row load. Assignee name/role ride along so the screen can render the
real user without a second request."""
users_by_id = users_by_id or {}
assignee = users_by_id.get(row.assignee_id)
return {
"id": str(row.id) if row.id else None,
"title": row.title,
"status": row.status,
"priority": row.priority,
"due_date": row.due_date.isoformat() if row.due_date else None,
"assignee_id": str(row.assignee_id) if row.assignee_id else None,
"assignee_name": assignee.name if assignee else None,
"assignee_role": assignee.role.role_name if assignee and assignee.role else None,
"inbox_id": row.inbox_id,
"job_post_id": str(row.job_post_id) if row.job_post_id 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,
}

222
backend/tasks/views.py Normal file
View File

@ -0,0 +1,222 @@
import uuid
from datetime import timezone
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from role.models import Roles
from tasks.models import Tasks
from tasks.serializers import serialize_task
from users.models import Users
VALID_STATUS = ("open", "done")
VALID_PRIORITY = ("high", "medium", "low")
# Role names, not ids: the ids are resolved from the roles table at request
# time so a re-seeded database that renumbers roles cannot silently shift who
# may do what. recruiter is role_id 3 on the current seed.
ASSIGNEE_ROLE = "recruiter"
CREATOR_ROLES = ("system_administrator", "hr_administrator", "recruiter")
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
def _aware(value):
"""Date-only payloads parse to naive midnight; stored naive, asyncpg would
silently read them as local-shifted UTC (see backend/README timezone note)."""
if value is not None and value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
class Task:
def __init__(self, session: AsyncSession):
self.session = session
async def _users_map(self, ids):
ids = [i for i in set(ids) if i]
if not ids:
return {}
result = await self.session.execute(
select(Users).options(selectinload(Users.role)).where(Users.id.in_(ids))
)
return {u.id: u for u in result.scalars().all()}
async def _validate_assignee(self, assignee_id):
"""Assignees must be recruiter-role accounts (role resolved from the DB):
existing, not deleted, role_name == ASSIGNEE_ROLE."""
user = await Users.get_user_by_id(self.session, str(assignee_id))
if user is None or user.is_deleted:
raise HTTPException(status_code=422, detail="Assignee not found")
role_name = user.role.role_name if user.role else None
if role_name != ASSIGNEE_ROLE:
raise HTTPException(
status_code=422,
detail=f"Tasks can only be assigned to {ASSIGNEE_ROLE} accounts",
)
return user
async def _require_creator_role(self, current_user):
"""Creation is limited to system admin / HR admin / recruiter. The role
ids are looked up from the roles table, and the permission-tag guard on
the route (tasks.create) still applies on top of this."""
result = await self.session.execute(
select(Roles).where(Roles.role_name.in_(CREATOR_ROLES))
)
allowed_ids = {r.id for r in result.scalars().all()}
if current_user.get("role_id") not in allowed_ids:
raise HTTPException(
status_code=403,
detail="Only system administrators, HR administrators and recruiters can create tasks",
)
async def get_assignees(self):
"""The assignee picker: every active recruiter-role user. Separate from
/users/fetch so callers do not need rbac_users.view to assign a task."""
role = await Roles.get_role_by_name(self.session, ASSIGNEE_ROLE)
if role is None:
raise HTTPException(status_code=500, detail=f"Role {ASSIGNEE_ROLE} is not seeded")
rows = await Users.get_users(self.session, top=500, role_id=role.id)
data = [
{
"id": str(u.id),
"name": u.name,
"email": u.email,
"role_id": u.role_id,
"role_name": role.role_name,
"is_active": u.is_active,
}
for u in rows
]
return data, len(data)
async def get_tasks(self, status=None, priority=None, assignee_id=None, top=None, skip=0):
if status and status not in VALID_STATUS:
raise HTTPException(status_code=422, detail=f"status must be one of {', '.join(VALID_STATUS)}")
if priority and priority not in VALID_PRIORITY:
raise HTTPException(status_code=422, detail=f"priority must be one of {', '.join(VALID_PRIORITY)}")
aid = None
if assignee_id:
aid = _as_uuid(assignee_id)
if aid is None:
raise HTTPException(status_code=422, detail="Invalid assignee_id")
rows, total = await Tasks.fetch_tasks(
self.session,
status=status,
priority=priority,
assignee_id=aid,
top=top,
skip=skip or 0,
)
users_by_id = await self._users_map([r.assignee_id for r in rows])
return [serialize_task(r, users_by_id) for r in rows], total
async def create_task(self, payload, current_user):
await self._require_creator_role(current_user)
title = (payload.get("title") or "").strip()
if not title:
raise HTTPException(status_code=422, detail="title is required")
priority = payload.get("priority") or "medium"
if priority not in VALID_PRIORITY:
raise HTTPException(status_code=422, detail=f"priority must be one of {', '.join(VALID_PRIORITY)}")
creator = _user_id(current_user)
# Assignee must be a recruiter. Omitting it only works when the caller
# IS a recruiter (assign to self); an admin must pick one explicitly.
if payload.get("assignee_id"):
assignee = _as_uuid(payload["assignee_id"])
if assignee is None:
raise HTTPException(status_code=422, detail="Invalid assignee_id")
elif current_user.get("role_name") == ASSIGNEE_ROLE:
assignee = creator
else:
raise HTTPException(status_code=422, detail="assignee_id is required")
await self._validate_assignee(assignee)
fields = {
"title": title,
"priority": priority,
"status": "open",
"assignee_id": assignee,
"created_by": creator,
}
if payload.get("due_date") is not None:
fields["due_date"] = _aware(payload["due_date"])
if payload.get("inbox_id") is not None:
fields["inbox_id"] = int(payload["inbox_id"])
if payload.get("job_post_id"):
jid = _as_uuid(payload["job_post_id"])
if jid is None:
raise HTTPException(status_code=422, detail="Invalid job_post_id")
fields["job_post_id"] = jid
row = await Tasks.insert_task(self.session, fields)
users_by_id = await self._users_map([row.assignee_id])
return serialize_task(row, users_by_id)
async def update_task(self, task_id, payload, current_user):
row = await Tasks.get_task_by_id(self.session, task_id)
if not row:
raise HTTPException(status_code=404, detail="Task not found")
_user_id(current_user)
fields = {}
for key in ("title", "status", "priority", "due_date", "assignee_id", "inbox_id", "job_post_id"):
if key not in payload:
continue
value = payload[key]
if key == "title":
value = (value or "").strip()
if not value:
raise HTTPException(status_code=422, detail="title cannot be blank")
elif key == "status":
if value not in VALID_STATUS:
raise HTTPException(status_code=422, detail=f"status must be one of {', '.join(VALID_STATUS)}")
elif key == "priority":
if value not in VALID_PRIORITY:
raise HTTPException(status_code=422, detail=f"priority must be one of {', '.join(VALID_PRIORITY)}")
elif key == "assignee_id":
value = _as_uuid(value)
if value is None:
raise HTTPException(status_code=422, detail="Invalid assignee_id")
await self._validate_assignee(value)
elif key == "due_date":
value = _aware(value)
elif key == "job_post_id" and value is not None:
value = _as_uuid(value)
if value is None:
raise HTTPException(status_code=422, detail="Invalid job_post_id")
fields[key] = value
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
updated = await Tasks.update_task(self.session, task_id, fields)
if not updated:
raise HTTPException(status_code=404, detail="Task not found")
users_by_id = await self._users_map([updated.assignee_id])
return serialize_task(updated, users_by_id)
async def delete_task(self, task_id, current_user):
_user_id(current_user)
row = await Tasks.soft_delete_task(self.session, task_id)
if not row:
raise HTTPException(status_code=404, detail="Task not found")
return {"id": str(row.id), "deleted": True}

View File

@ -38,6 +38,7 @@ class PermissionModule(str, Enum):
JOB_BOARD = "job_board"
SETTINGS = "settings"
RBAC_USERS = "rbac_users"
TASKS = "tasks"
class PermissionAction(str, Enum):
@ -156,6 +157,14 @@ class PermissionTag(str, Enum):
RBAC_USERS_EXPORT = "rbac_users.export"
RBAC_USERS_MANAGE = "rbac_users.manage"
RBAC_USERS_CONFIGURE = "rbac_users.configure"
TASKS_VIEW = "tasks.view"
TASKS_CREATE = "tasks.create"
TASKS_EDIT = "tasks.edit"
TASKS_DELETE = "tasks.delete"
TASKS_APPROVE = "tasks.approve"
TASKS_EXPORT = "tasks.export"
TASKS_MANAGE = "tasks.manage"
TASKS_CONFIGURE = "tasks.configure"
def _assert_vocabulary_complete() -> None:

53
frontend/src/api/tasks.js Normal file
View File

@ -0,0 +1,53 @@
import { request } from '../lib/apiClient'
/**
* Tasks backend/tasks/app.py.
* Permissioned with TASKS_VIEW / TASKS_CREATE / TASKS_EDIT / TASKS_DELETE.
* Assignees are real `users` rows; the server rejects candidate-role accounts.
*/
export function list({ status, priority, assigneeId, top, skip } = {}) {
return request('/tasks/fetch', {
params: { status, priority, assignee_id: assigneeId, top, skip },
})
}
export function create(body) {
return request('/tasks/create', { method: 'POST', body })
}
export function update(taskId, body) {
return request('/tasks/update', { method: 'PATCH', params: { task_id: taskId }, body })
}
export function remove(taskId) {
return request('/tasks/delete', { method: 'DELETE', params: { task_id: taskId } })
}
/**
* The assignee picker active recruiter-role users only (role id resolved
* from the roles table server-side). A dedicated endpoint so assigning a task
* never needs rbac_users.view.
*/
export function listAssignees() {
return request('/tasks/assignees/fetch')
}
/**
* tasks row -> the shape Tasks.jsx renders. `done` folds the open|done status,
* `due` becomes a Date for the overdue comparison (null = no deadline), and the
* assignee is the real user's name with their role riding along.
*/
export function toTaskView(row) {
return {
id: row.id,
title: row.title,
done: row.status === 'done',
priority: row.priority ? row.priority[0].toUpperCase() + row.priority.slice(1) : 'Medium',
due: row.due_date ? new Date(row.due_date) : null,
assignee: row.assignee_name || '—',
assigneeRole: row.assignee_role || null,
assigneeId: row.assignee_id,
created: row.created_at ? new Date(row.created_at) : null,
}
}

View File

@ -28,7 +28,7 @@ export const ROUTES = [
{ path: 'import', title: 'CV Import', icon: 'upload', group: 'Recruiting', permission: 'candidates.create' },
{ path: 'jobboard', title: 'Job Board', icon: 'layers', group: 'Recruiting', permission: 'job_board.view' },
{ path: 'recruiterhub', title: 'Recruiter Hub', icon: 'check-circle', group: 'Recruiting', permission: 'analytics.view' },
{ path: 'tasks', title: 'Tasks', icon: 'check-square', group: 'Recruiting', permission: null, badge: 'tasks' },
{ path: 'tasks', title: 'Tasks', icon: 'check-square', group: 'Recruiting', permission: 'tasks.view', badge: 'tasks' },
{ path: 'aiassistant', title: 'AI Assistant', icon: 'sparkles', group: 'Recruiting', permission: null, tag: 'AI' },
// --- Hiring ---

View File

@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query'
import { seedQuery } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import * as inboxApi from '../api/inbox'
import * as tasksApi from '../api/tasks'
const SIDEBAR_KEY = 'tf-sidebar'
@ -88,7 +89,6 @@ export function useHotkeys({ onEscape }) {
export function useBadges() {
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
const { data: tasks = [] } = useQuery(seedQuery('tasks'))
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
const { data: matchingTotal = 0 } = useQuery({
queryKey: qk.mailbox.assignments({ assigned: false }),
@ -97,11 +97,24 @@ export function useBadges() {
return res?.total ?? 0
},
})
// Live: open tasks across the team (same semantics the seed badge had). A
// 403 for users without tasks.view resolves to 0 rather than an error badge.
const { data: tasksTotal = 0 } = useQuery({
queryKey: qk.tasks.list({ badge: 'open' }),
queryFn: async () => {
try {
const res = await tasksApi.list({ status: 'open', top: 1 })
return res?.total ?? 0
} catch {
return 0
}
},
})
return {
jobs: jobs.filter((j) => j.status === 'Open').length,
notifications: notifications.filter((n) => n.unread).length,
tasks: tasks.filter((t) => !t.done).length,
tasks: tasksTotal,
inbox: inbox.filter((i) => i.unread).length,
matching: matchingTotal,
}

View File

@ -58,6 +58,11 @@ export const qk = {
offers: { all: () => ['offers'], list: (p = {}) => ['offers', 'list', p] },
interviews: { all: () => ['interviews'], range: (p = {}) => ['interviews', 'range', p] },
activity: { all: () => ['activity'], feed: (p = {}) => ['activity', 'feed', p] },
tasks: {
all: () => ['tasks'],
list: (p = {}) => ['tasks', 'list', p],
assignees: () => ['tasks', 'assignees'],
},
// --- seed-backed buckets ---
// These are not "server state" — the cache IS the store for them, so every

View File

@ -1,29 +1,67 @@
/* ============================================================
Tasks the recruiting task list, on live backend data.
Rows come from GET /tasks/fetch via toTaskView; create/complete/reopen go
through POST /tasks/create and PATCH /tasks/update. Assignees are RECRUITER
accounts only: the picker lists GET /tasks/assignees/fetch (role resolved
from the roles table server-side) and the server 422s any other role.
Creation is limited to system_administrator / hr_administrator / recruiter
enforced server-side against the roles table, mirrored here so the button
doesn't invite a 403. Fields the backend does not carry (task type, notes,
candidate link) are gone rather than rendered as placeholders the Inbox
screen precedent. Saved Searches stays decorative seed chrome.
============================================================ */
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { useFormState } from '../components/AuthLayout'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { fmtDate, fmtShort, getCandidate, savedSearches, TODAY } from '../data/seed'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as tasksApi from '../api/tasks'
import { fmtDate, fmtShort, savedSearches } from '../data/seed'
const FILTERS = ['All', 'Open', 'Completed', 'Overdue', 'High', 'Medium', 'Low']
const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' }
// Mirrors backend/tasks/views.py CREATOR_ROLES the server is the enforcer,
// this only keeps the button honest.
const CREATOR_ROLES = ['system_administrator', 'hr_administrator', 'recruiter']
async function fetchTasks() {
const res = await tasksApi.list()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(tasksApi.toTaskView)
}
async function fetchAssignees() {
const res = await tasksApi.listAssignees()
return Array.isArray(res?.data) ? res.data : []
}
export default function Tasks() {
const { toast } = useToast()
const { can, user } = useAuth()
const navigate = useNavigate()
const { data: tasks = [] } = useQuery(seedQuery('tasks'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateTasks = useSeedMutation('tasks')
const qc = useQueryClient()
const canCreate = can('tasks.create') && CREATOR_ROLES.includes(user?.role_name)
const canEdit = can('tasks.edit')
const tasksQuery = useQuery({ queryKey: qk.tasks.list(), queryFn: fetchTasks })
const assigneesQuery = useQuery({ queryKey: qk.tasks.assignees(), queryFn: fetchAssignees })
const tasks = tasksQuery.data ?? []
const [filter, setFilter] = useState('All')
const [detail, setDetail] = useState(null)
const [adding, setAdding] = useState(false)
const isOverdue = (t) => !t.done && t.due < TODAY
const now = new Date()
const isOverdue = (t) => !t.done && t.due && t.due < now
const list = useMemo(() => {
if (filter === 'Open') return tasks.filter((t) => !t.done)
@ -31,28 +69,62 @@ export default function Tasks() {
if (filter === 'Overdue') return tasks.filter(isOverdue)
if (['High', 'Medium', 'Low'].includes(filter)) return tasks.filter((t) => t.priority === filter)
return tasks
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tasks, filter])
const openCount = tasks.filter((t) => !t.done).length
const overdueCount = tasks.filter(isOverdue).length
// Writing to the cache is what makes the sidebar badge update the prototype
// had to remember to call App.updateBadges() at each of these call sites.
function toggle(id) {
let nowDone = false
updateTasks((ts) =>
ts.map((t) => {
if (t.id !== id) return t
nowDone = !t.done
return { ...t, done: nowDone }
}),
)
toast(nowDone ? 'Task completed' : 'Task reopened', nowDone ? 'success' : 'info')
}
// Optimistic flip with rollback: the checkbox must not lag the click, but a
// 403/422 must snap it back rather than lie.
const flip = useMutation({
mutationFn: ({ id, done }) => tasksApi.update(id, { status: done ? 'done' : 'open' }),
onMutate: async ({ id, done }) => {
await qc.cancelQueries({ queryKey: qk.tasks.all() })
const previous = qc.getQueryData(qk.tasks.list())
qc.setQueryData(qk.tasks.list(), (old = []) =>
old.map((t) => (t.id === id ? { ...t, done } : t)),
)
return { previous }
},
onError: (err, _vars, ctx) => {
if (ctx?.previous) qc.setQueryData(qk.tasks.list(), ctx.previous)
toast(friendlyAuthError(err, 'Could not update the task.'), 'error')
},
onSuccess: (_res, { done }) => {
toast(done ? 'Task completed' : 'Task reopened', done ? 'success' : 'info')
},
onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }),
})
function complete(id) {
updateTasks((ts) => ts.map((t) => (t.id === id ? { ...t, done: true } : t)))
toast('Task completed', 'success')
const createTask = useMutation({
mutationFn: (body) => tasksApi.create(body),
onError: (err) => toast(friendlyAuthError(err, 'Could not create the task.'), 'error'),
onSuccess: () => {
setAdding(false)
toast('Task created', 'success')
},
onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }),
})
// Soft delete server-side: the row keeps living in Postgres with is_deleted,
// it just leaves every fetch. Guarded by tasks.delete.
const deleteTask = useMutation({
mutationFn: (id) => tasksApi.remove(id),
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the task.'), 'error'),
onSuccess: () => {
setDetail(null)
toast('Task deleted', 'success')
},
onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }),
})
function toggle(task) {
if (!canEdit) {
toast('Requires tasks.edit', 'info')
return
}
flip.mutate({ id: task.id, done: !task.done })
}
return (
@ -63,7 +135,12 @@ export default function Tasks() {
<p className="page-sub">{openCount} open · {overdueCount} overdue</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => setAdding(true)}>
<button
className="btn btn-primary"
disabled={!canCreate}
title={!canCreate ? 'Requires tasks.create' : undefined}
onClick={() => setAdding(true)}
>
<Icon name="plus" /> New Task
</button>
</div>
@ -82,7 +159,13 @@ export default function Tasks() {
</div>
<div className="card-body">
<div className="list-tight">
{list.length === 0 ? (
{tasksQuery.isPending ? (
<EmptyState icon="check-square" title="Loading…">Fetching tasks from the server.</EmptyState>
) : tasksQuery.isError ? (
<EmptyState icon="alert" title="Couldnt load tasks">
{friendlyAuthError(tasksQuery.error, 'Request failed')}
</EmptyState>
) : list.length === 0 ? (
<EmptyState icon="check-square" title="All caught up">No tasks in this view.</EmptyState>
) : (
list.map((t) => {
@ -91,11 +174,11 @@ export default function Tasks() {
<div className="list-row" style={{ alignItems: 'center' }} key={t.id}>
<span
className={`checkbox ${t.done ? 'on' : ''}`}
onClick={(e) => { e.stopPropagation(); toggle(t.id) }}
onClick={(e) => { e.stopPropagation(); toggle(t) }}
role="checkbox"
aria-checked={t.done}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(t.id) } }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(t) } }}
>
<Icon name="check" />
</span>
@ -106,7 +189,10 @@ export default function Tasks() {
>
{t.title}
</div>
<div className="lr-sub"><Icon name="users" /> {t.assignee} · {t.type}</div>
<div className="lr-sub">
<Icon name="users" /> {t.assignee}
{t.assigneeRole ? ` · ${t.assigneeRole.replace(/_/g, ' ')}` : ''}
</div>
</div>
<div className="lr-right">
<Badge className={PRIORITY_CLASS[t.priority]}>{t.priority}</Badge>
@ -114,7 +200,7 @@ export default function Tasks() {
className="lr-sub"
style={{ marginTop: 4, ...(overdue ? { color: 'var(--danger)', fontWeight: 600 } : {}) }}
>
{overdue ? 'Overdue · ' : 'Due '}{fmtShort(t.due)}
{t.due ? `${overdue ? 'Overdue · ' : 'Due '}${fmtShort(t.due)}` : 'No due date'}
</div>
</div>
</div>
@ -152,81 +238,108 @@ export default function Tasks() {
{detail && (
<TaskDetail
task={detail}
canEdit={canEdit}
canDelete={can('tasks.delete')}
completing={flip.isPending}
deleting={deleteTask.isPending}
onClose={() => setDetail(null)}
onComplete={() => { complete(detail.id); setDetail(null) }}
onViewCandidate={(id) => { setDetail(null); navigate('/candidates', { state: { openCandidate: id } }) }}
onComplete={() => {
if (!canEdit) return
flip.mutate({ id: detail.id, done: true })
setDetail(null)
}}
onDelete={() => deleteTask.mutate(detail.id)}
/>
)}
{adding && (
<AddTask
recruiters={recruiters}
count={tasks.length}
assignees={assigneesQuery.data ?? []}
me={user}
pending={createTask.isPending}
onClose={() => setAdding(false)}
onSave={(task) => {
updateTasks((ts) => [task, ...ts])
setAdding(false)
toast('Task created', 'success')
}}
onSave={(body) => createTask.mutate(body)}
/>
)}
</div>
)
}
function TaskDetail({ task: t, onClose, onComplete, onViewCandidate }) {
const c = t.candidateId ? getCandidate(t.candidateId) : null
function TaskDetail({ task: t, canEdit, canDelete, completing, deleting, onClose, onComplete, onDelete }) {
// Two-click delete: first click arms the button, second click fires. Cheaper
// than a nested confirm modal and impossible to trigger by accident.
const [confirmDelete, setConfirmDelete] = useState(false)
return (
<Modal
title={t.title}
subtitle={`${t.id} · ${t.type}`}
subtitle={t.done ? 'Completed task' : 'Open task'}
onClose={onClose}
footer={
<>
<button
className={`btn ${confirmDelete ? 'btn-danger' : 'btn-ghost'}`}
style={{ marginRight: 'auto' }}
disabled={!canDelete || deleting}
title={!canDelete ? 'Requires tasks.delete' : undefined}
onClick={() => (confirmDelete ? onDelete() : setConfirmDelete(true))}
>
<Icon name="trash" /> {deleting ? 'Deleting…' : confirmDelete ? 'Confirm delete?' : 'Delete'}
</button>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
{c && (
<button className="btn btn-secondary" onClick={() => onViewCandidate(c.id)}>View Candidate</button>
{!t.done && (
<button
className="btn btn-primary"
disabled={!canEdit || completing}
title={!canEdit ? 'Requires tasks.edit' : undefined}
onClick={onComplete}
>
<Icon name="check" /> Mark Complete
</button>
)}
<button className="btn btn-primary" onClick={onComplete}><Icon name="check" /> Mark Complete</button>
</>
}
>
<div className="info-grid" style={{ marginBottom: 16 }}>
<div className="info-item"><div className="il">Assignee</div><div className="iv">{t.assignee}</div></div>
<div className="info-grid">
<div className="info-item">
<div className="il">Assignee</div>
<div className="iv">
{t.assignee}{t.assigneeRole ? ` (${t.assigneeRole.replace(/_/g, ' ')})` : ''}
</div>
</div>
<div className="info-item"><div className="il">Priority</div><div className="iv">{t.priority}</div></div>
<div className="info-item"><div className="il">Due Date</div><div className="iv">{fmtDate(t.due)}</div></div>
<div className="info-item">
<div className="il">Due Date</div>
<div className="iv">{t.due ? fmtDate(t.due) : 'No due date'}</div>
</div>
<div className="info-item"><div className="il">Status</div><div className="iv">{t.done ? 'Completed' : 'Open'}</div></div>
{c && <div className="info-item"><div className="il">Candidate</div><div className="iv">{c.name}</div></div>}
</div>
<div className="form-field">
<label>Notes</label>
<textarea placeholder="Add task notes…" />
{t.created && (
<div className="info-item"><div className="il">Created</div><div className="iv">{fmtDate(t.created)}</div></div>
)}
</div>
</Modal>
)
}
function AddTask({ recruiters, count, onClose, onSave }) {
const form = useFormState({
title: '', priority: 'Medium', type: 'Interview',
assignee: recruiters[0]?.name ?? '', due: '',
})
function AddTask({ assignees, me, pending, onClose, onSave }) {
// Assignees are recruiter accounts only. '' means "assign to me" and is only
// offered when the caller IS a recruiter (the server 422s otherwise).
const meIsRecruiter = me?.role_name === 'recruiter'
const form = useFormState({ title: '', priority: 'Medium', assignee: '', due: '' })
function submit() {
if (!form.values.title.trim()) {
form.setErrors({ title: 'Required' })
return
if (pending) return
const errors = {}
if (!form.values.title.trim()) errors.title = 'Required'
if (!form.values.assignee && !meIsRecruiter) errors.assignee = 'Pick a recruiter'
form.setErrors(errors)
if (Object.keys(errors).length) return
const body = {
title: form.values.title.trim(),
priority: form.values.priority.toLowerCase(),
}
onSave({
id: `TSK-${50001 + count}`,
title: form.values.title,
candidateId: null,
priority: form.values.priority,
due: form.values.due ? new Date(form.values.due) : new Date('2026-07-16'),
assignee: form.values.assignee,
done: false,
type: form.values.type,
})
if (form.values.assignee) body.assignee_id = form.values.assignee
if (form.values.due) body.due_date = form.values.due
onSave(body)
}
return (
@ -236,8 +349,10 @@ function AddTask({ recruiters, count, onClose, onSave }) {
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit}><Icon name="check" /> Create Task</button>
<button className="btn btn-secondary" onClick={onClose} disabled={pending}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={pending}>
<Icon name="check" /> {pending ? 'Creating…' : 'Create Task'}
</button>
</>
}
>
@ -260,16 +375,24 @@ function AddTask({ recruiters, count, onClose, onSave }) {
</select>
</div>
<div className="form-field">
<label>Type</label>
<select value={form.values.type} onChange={(e) => form.setField('type', e.target.value)}>
{['Interview', 'Review', 'Offer', 'Admin'].map((o) => <option key={o}>{o}</option>)}
</select>
</div>
<div className="form-field">
<label>Assignee</label>
<select value={form.values.assignee} onChange={(e) => form.setField('assignee', e.target.value)}>
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
<label>Assignee <span className="req">*</span></label>
<select
className={form.errors.assignee ? 'err' : ''}
value={form.values.assignee}
onChange={(e) => form.setField('assignee', e.target.value)}
>
{meIsRecruiter
? <option value="">Me{me?.name ? ` (${me.name})` : ''}</option>
: <option value="">Select recruiter</option>}
{assignees
.filter((u) => u.id !== me?.id)
.map((u) => (
<option key={u.id} value={u.id}>
{u.name || u.email} recruiter
</option>
))}
</select>
<FieldError>{form.errors.assignee}</FieldError>
</div>
<div className="form-field">
<label>Due Date</label>