diff --git a/backend/main.py b/backend/main.py index 1755f98..b511947 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/migrations/manual/004_tasks_rbac.sql b/backend/migrations/manual/004_tasks_rbac.sql new file mode 100644 index 0000000..640c598 --- /dev/null +++ b/backend/migrations/manual/004_tasks_rbac.sql @@ -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)); diff --git a/backend/migrations/manual/005_tasks_rbac_restrict.sql b/backend/migrations/manual/005_tasks_rbac_restrict.sql new file mode 100644 index 0000000..1084316 --- /dev/null +++ b/backend/migrations/manual/005_tasks_rbac_restrict.sql @@ -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)); diff --git a/backend/tasks/app.py b/backend/tasks/app.py new file mode 100644 index 0000000..105d680 --- /dev/null +++ b/backend/tasks/app.py @@ -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)) diff --git a/backend/tasks/models.py b/backend/tasks/models.py new file mode 100644 index 0000000..e5bbf1d --- /dev/null +++ b/backend/tasks/models.py @@ -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 diff --git a/backend/tasks/serializers.py b/backend/tasks/serializers.py new file mode 100644 index 0000000..ba50395 --- /dev/null +++ b/backend/tasks/serializers.py @@ -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, + } diff --git a/backend/tasks/views.py b/backend/tasks/views.py new file mode 100644 index 0000000..b34c4d1 --- /dev/null +++ b/backend/tasks/views.py @@ -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} diff --git a/backend/users/permissions.py b/backend/users/permissions.py index a7c11b0..d33063b 100644 --- a/backend/users/permissions.py +++ b/backend/users/permissions.py @@ -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: diff --git a/frontend/src/api/tasks.js b/frontend/src/api/tasks.js new file mode 100644 index 0000000..6420d78 --- /dev/null +++ b/frontend/src/api/tasks.js @@ -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, + } +} diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index aace388..58ea31e 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -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 --- diff --git a/frontend/src/app/useShell.js b/frontend/src/app/useShell.js index 4e55aa4..6540e0d 100644 --- a/frontend/src/app/useShell.js +++ b/frontend/src/app/useShell.js @@ -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, } diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 6501ffd..b33deeb 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -55,6 +55,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 diff --git a/frontend/src/screens/Tasks.jsx b/frontend/src/screens/Tasks.jsx index 62f373d..597b2d3 100644 --- a/frontend/src/screens/Tasks.jsx +++ b/frontend/src/screens/Tasks.jsx @@ -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() {
{openCount} open · {overdueCount} overdue