From 4086bbdeb4d3f7ad23ec14af4602bdab77617805 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 4 Sep 2026 16:48:30 +0500 Subject: [PATCH] permission module base applied --- backend/org_settings/app.py | 152 +++++++++++++++++++- backend/org_settings/models.py | 186 +++++++++++++++++++++++- backend/org_settings/serializers.py | 19 +++ backend/org_settings/views.py | 116 ++++++++++++++- backend/talent/plugins.py | 59 ++++---- backend/talent/views.py | 20 ++- frontend/src/api/orgSettings.js | 46 ++++++ frontend/src/lib/queryKeys.js | 2 + frontend/src/screens/Settings.jsx | 216 +++++++++++++++++++++++++++- 9 files changed, 776 insertions(+), 40 deletions(-) diff --git a/backend/org_settings/app.py b/backend/org_settings/app.py index 18060f3..381bbdb 100644 --- a/backend/org_settings/app.py +++ b/backend/org_settings/app.py @@ -6,7 +6,7 @@ from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from db_setup import get_session -from org_settings.views import OrgSetting +from org_settings.views import Exclusion, OrgSetting from users.permissions import PermissionTag, require_permission router = APIRouter() @@ -52,3 +52,153 @@ async def update_org_settings( raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +class ExcludeUniversityCreate(BaseModel): + name: str + + +class ExcludeUniversityUpdate(BaseModel): + name: str | None = None + + +class ExcludeCompanyCreate(BaseModel): + name: str + linkedin_url: str | None = None + + +class ExcludeCompanyUpdate(BaseModel): + name: str | None = None + linkedin_url: str | None = None + + +@router.get("/org-settings/exclude-university/fetch") +async def fetch_exclude_universities( + current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service = Exclusion(session=session) + data, total = await service.get_universities() + 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("/org-settings/exclude-university/create") +async def create_exclude_university( + payload: ExcludeUniversityCreate, + current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)), + session: AsyncSession = Depends(get_session), +): + try: + service = Exclusion(session=session) + data = await service.create_university(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("/org-settings/exclude-university/update") +async def update_exclude_university( + payload: ExcludeUniversityUpdate, + current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)), + record_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service = Exclusion(session=session) + data = await service.update_university( + record_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("/org-settings/exclude-university/delete") +async def delete_exclude_university( + current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)), + record_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service = Exclusion(session=session) + data = await service.delete_university(record_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)) + + +@router.get("/org-settings/exclude-company/fetch") +async def fetch_exclude_companies( + current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service = Exclusion(session=session) + data, total = await service.get_companies() + 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("/org-settings/exclude-company/create") +async def create_exclude_company( + payload: ExcludeCompanyCreate, + current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)), + session: AsyncSession = Depends(get_session), +): + try: + service = Exclusion(session=session) + data = await service.create_company(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("/org-settings/exclude-company/update") +async def update_exclude_company( + payload: ExcludeCompanyUpdate, + current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)), + record_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service = Exclusion(session=session) + data = await service.update_company( + record_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("/org-settings/exclude-company/delete") +async def delete_exclude_company( + current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)), + record_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service = Exclusion(session=session) + data = await service.delete_company(record_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/org_settings/models.py b/backend/org_settings/models.py index 7a729ed..37d4051 100644 --- a/backend/org_settings/models.py +++ b/backend/org_settings/models.py @@ -3,7 +3,7 @@ from typing import Any import uuid from datetime import datetime, timezone -from sqlalchemy import DateTime, JSON +from sqlalchemy import DateTime, JSON, func from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, SQLModel, select @@ -76,4 +76,188 @@ class OrgSettings(SQLModel, table=True): return rows +class ExcludeUniversity(SQLModel, table=True): + __tablename__ = "Exclude_University" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + name: str = Field(index=True) + created_by: uuid.UUID | None = Field(default=None, 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_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 get_by_name(cls, session: AsyncSession, name: str, *, exclude_id=None): + cleaned = (name or "").strip() + if not cleaned: + return None + statement = select(cls).where( + func.lower(cls.name) == cleaned.lower(), + cls.is_deleted == False, # noqa: E712 + ) + if exclude_id is not None: + uid = cls._as_uuid(exclude_id) + if uid is not None: + statement = statement.where(cls.id != uid) + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def fetch_rows(cls, session: AsyncSession): + statement = select(cls).where(cls.is_deleted == False).order_by(cls.name.asc()) # noqa: E712 + result = await session.execute(statement) + rows = list(result.scalars().all()) + return rows, len(rows) + + @classmethod + async def fetch_names(cls, session: AsyncSession) -> list[str]: + rows, _ = await cls.fetch_rows(session) + return [r.name for r in rows if r.name] + + @classmethod + async def insert_row(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 update_row(cls, session: AsyncSession, record_id, fields: dict): + row = await cls.get_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_row(cls, session: AsyncSession, record_id): + row = await cls.get_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 + + +class ExcludeCompany(SQLModel, table=True): + __tablename__ = "Exclude_Company" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + name: str = Field(index=True) + linkedin_url: str | None = Field(default=None) + created_by: uuid.UUID | None = Field(default=None, 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_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 get_by_name(cls, session: AsyncSession, name: str, *, exclude_id=None): + cleaned = (name or "").strip() + if not cleaned: + return None + statement = select(cls).where( + func.lower(cls.name) == cleaned.lower(), + cls.is_deleted == False, # noqa: E712 + ) + if exclude_id is not None: + uid = cls._as_uuid(exclude_id) + if uid is not None: + statement = statement.where(cls.id != uid) + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def fetch_rows(cls, session: AsyncSession): + statement = select(cls).where(cls.is_deleted == False).order_by(cls.name.asc()) # noqa: E712 + result = await session.execute(statement) + rows = list(result.scalars().all()) + return rows, len(rows) + + @classmethod + async def fetch_names(cls, session: AsyncSession) -> list[str]: + rows, _ = await cls.fetch_rows(session) + return [r.name for r in rows if r.name] + + @classmethod + async def fetch_linkedin_urls(cls, session: AsyncSession) -> list[str]: + rows, _ = await cls.fetch_rows(session) + return [r.linkedin_url for r in rows if r.linkedin_url] + + @classmethod + async def insert_row(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 update_row(cls, session: AsyncSession, record_id, fields: dict): + row = await cls.get_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_row(cls, session: AsyncSession, record_id): + row = await cls.get_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/org_settings/serializers.py b/backend/org_settings/serializers.py index c6fdb95..283dce1 100644 --- a/backend/org_settings/serializers.py +++ b/backend/org_settings/serializers.py @@ -5,3 +5,22 @@ def serialize_org_setting(row) -> dict: "category": row.category, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } + + +def serialize_exclude_university(row) -> dict: + return { + "id": str(row.id) if row.id else None, + "name": row.name, + "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_exclude_company(row) -> dict: + return { + "id": str(row.id) if row.id else None, + "name": row.name, + "linkedin_url": row.linkedin_url, + "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/org_settings/views.py b/backend/org_settings/views.py index 91eb446..0462ce1 100644 --- a/backend/org_settings/views.py +++ b/backend/org_settings/views.py @@ -3,8 +3,12 @@ import uuid from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from org_settings.models import OrgSettings -from org_settings.serializers import serialize_org_setting +from org_settings.models import ExcludeCompany, ExcludeUniversity, OrgSettings +from org_settings.serializers import ( + serialize_exclude_company, + serialize_exclude_university, + serialize_org_setting, +) VALID_CATEGORIES = ( "general", @@ -70,3 +74,111 @@ class OrgSetting: }) rows = await OrgSettings.upsert_settings(self.session, cleaned, _user_id(current_user)) return [serialize_org_setting(r) for r in rows] + + +MAX_NAME_LEN = 200 +MAX_URL_LEN = 500 + + +def _clean_name(value, *, label="name"): + name = (value or "").strip() + if not name: + raise HTTPException(status_code=422, detail=f"{label} is required") + if len(name) > MAX_NAME_LEN: + raise HTTPException(status_code=422, detail=f"{label} must be {MAX_NAME_LEN} characters or fewer") + return name + + +def _clean_linkedin_url(value): + url = (value or "").strip() or None + if url is None: + return None + if len(url) > MAX_URL_LEN: + raise HTTPException(status_code=422, detail=f"linkedin_url must be {MAX_URL_LEN} characters or fewer") + lowered = url.lower() + if not (lowered.startswith("http://") or lowered.startswith("https://")): + raise HTTPException(status_code=422, detail="linkedin_url must be an http(s) URL") + return url + + +class Exclusion: + def __init__(self, session: AsyncSession): + self.session = session + + async def get_universities(self): + rows, total = await ExcludeUniversity.fetch_rows(self.session) + return [serialize_exclude_university(r) for r in rows], total + + async def create_university(self, payload, current_user): + name = _clean_name(payload.get("name"), label="name") + if await ExcludeUniversity.get_by_name(self.session, name): + raise HTTPException(status_code=409, detail="That university is already excluded") + row = await ExcludeUniversity.insert_row(self.session, { + "name": name, + "created_by": _user_id(current_user), + }) + return serialize_exclude_university(row) + + async def update_university(self, record_id, payload, current_user): + _user_id(current_user) + fields = {} + if "name" in payload: + name = _clean_name(payload.get("name"), label="name") + existing = await ExcludeUniversity.get_by_name(self.session, name, exclude_id=record_id) + if existing: + raise HTTPException(status_code=409, detail="That university is already excluded") + fields["name"] = name + if not fields: + raise HTTPException(status_code=400, detail="No fields to update") + row = await ExcludeUniversity.update_row(self.session, record_id, fields) + if not row: + raise HTTPException(status_code=404, detail="Excluded university not found") + return serialize_exclude_university(row) + + async def delete_university(self, record_id, current_user): + _user_id(current_user) + row = await ExcludeUniversity.soft_delete_row(self.session, record_id) + if not row: + raise HTTPException(status_code=404, detail="Excluded university not found") + return {"id": str(row.id), "deleted": True} + + async def get_companies(self): + rows, total = await ExcludeCompany.fetch_rows(self.session) + return [serialize_exclude_company(r) for r in rows], total + + async def create_company(self, payload, current_user): + name = _clean_name(payload.get("name"), label="name") + linkedin_url = _clean_linkedin_url(payload.get("linkedin_url")) + if await ExcludeCompany.get_by_name(self.session, name): + raise HTTPException(status_code=409, detail="That company is already excluded") + row = await ExcludeCompany.insert_row(self.session, { + "name": name, + "linkedin_url": linkedin_url, + "created_by": _user_id(current_user), + }) + return serialize_exclude_company(row) + + async def update_company(self, record_id, payload, current_user): + _user_id(current_user) + fields = {} + if "name" in payload: + name = _clean_name(payload.get("name"), label="name") + existing = await ExcludeCompany.get_by_name(self.session, name, exclude_id=record_id) + if existing: + raise HTTPException(status_code=409, detail="That company is already excluded") + fields["name"] = name + if "linkedin_url" in payload: + fields["linkedin_url"] = _clean_linkedin_url(payload.get("linkedin_url")) + if not fields: + raise HTTPException(status_code=400, detail="No fields to update") + row = await ExcludeCompany.update_row(self.session, record_id, fields) + if not row: + raise HTTPException(status_code=404, detail="Excluded company not found") + return serialize_exclude_company(row) + + async def delete_company(self, record_id, current_user): + _user_id(current_user) + row = await ExcludeCompany.soft_delete_row(self.session, record_id) + if not row: + raise HTTPException(status_code=404, detail="Excluded company not found") + return {"id": str(row.id), "deleted": True} diff --git a/backend/talent/plugins.py b/backend/talent/plugins.py index 1f49ab0..e71ee70 100644 --- a/backend/talent/plugins.py +++ b/backend/talent/plugins.py @@ -35,41 +35,32 @@ APIFY_TIMEOUT = float(os.getenv("APIFY_TIMEOUT", "30")) APIFY_MAX_COST_USD = float(os.getenv("APIFY_MAX_COST_USD", "1.0")) -def _csv_env(name: str, default: str) -> list[str]: - return [s.strip() for s in os.getenv(name, default).split(",") if s.strip()] - - -# The user's own companies: their CURRENT employees must never appear in sourced -# results. Names drive the always-on server-side filter (case-insensitive -# substring, so "Utopia Brands Pakistan" matches too). URLs drive the actor's -# excludeCurrentCompanies filter, which wants full LinkedIn company URLs and -# stops those profiles from being scraped (and paid for) at all. -APIFY_EXCLUDE_COMPANIES = _csv_env("APIFY_EXCLUDE_COMPANIES", "Utopia Brands,Utopia Deals") -APIFY_EXCLUDE_COMPANY_URLS = _csv_env( - "APIFY_EXCLUDE_COMPANY_URLS", - "https://www.linkedin.com/company/utopiadeals," - "https://www.linkedin.com/company/utopia-brands-usa," - "https://www.linkedin.com/company/utopiabrands", -) - - -def _matches_excluded(text) -> bool: +def _matches_excluded(text, names) -> bool: haystack = " ".join(str(text or "").lower().split()) - return bool(haystack) and any( - name.lower() in haystack for name in APIFY_EXCLUDE_COMPANIES - ) + if not haystack: + return False + return any(str(name).lower() in haystack for name in (names or []) if name) -def is_excluded_profile(profile: dict) -> bool: - """True when the person currently works at one of the excluded companies. +def is_excluded_profile(profile: dict, *, companies=None, universities=None) -> bool: + """True when the person matches a configured company or university exclusion. - The headline is only consulted when no current company was extracted, so an - "ex-Utopia" headline on someone now elsewhere does not exclude them. + Company: current employer, or headline only when no company was extracted so + an "ex-…" headline on someone now elsewhere does not exclude them. + University: any education school name on the sourced profile. + Lists come from Exclude_Company / Exclude_University — never hardcoded. """ company = (profile or {}).get("current_company") - if _matches_excluded(company): + if _matches_excluded(company, companies): return True - return not company and _matches_excluded((profile or {}).get("headline")) + if not company and _matches_excluded((profile or {}).get("headline"), companies): + return True + if universities: + raw = (profile or {}).get("raw") or {} + for edu in extract_education(raw): + if _matches_excluded(edu.get("school"), universities): + return True + return False # Apify run status -> talent_runs.status. Transitional states stay "running"; # unknown values also stay "running" so we never commit a terminal state we @@ -189,7 +180,12 @@ def _skill_terms(*entry_lists) -> list[str]: def build_actor_input( - job: dict, *, max_results: int, overrides: dict | None = None, start_page: int = 1 + job: dict, + *, + max_results: int, + overrides: dict | None = None, + start_page: int = 1, + exclude_company_urls: list[str] | None = None, ) -> dict: """Deterministic actor input from job fields. No LLM involved. @@ -225,8 +221,9 @@ def build_actor_input( ) if experience_ids: actor_input["yearsOfExperienceIds"] = experience_ids - if APIFY_EXCLUDE_COMPANY_URLS: - actor_input["excludeCurrentCompanies"] = APIFY_EXCLUDE_COMPANY_URLS + urls = [u.strip() for u in (exclude_company_urls or []) if u and str(u).strip()] + if urls: + actor_input["excludeCurrentCompanies"] = urls if start_page and int(start_page) > 1: actor_input["startPage"] = min(int(start_page), 100) if "location" in overrides and overrides["location"] is not None: diff --git a/backend/talent/views.py b/backend/talent/views.py index c17ed80..1c7828b 100644 --- a/backend/talent/views.py +++ b/backend/talent/views.py @@ -3,6 +3,7 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from job.job_post.models import JobPosts +from org_settings.models import ExcludeCompany, ExcludeUniversity from talent import plugins from talent.enums import OutreachStatus from talent.matching import annotate_applications @@ -24,6 +25,13 @@ class Talent: def __init__(self, session: AsyncSession): self.session = session + async def _exclusion_lists(self): + """Names and LinkedIn URLs from Settings — empty lists mean exclude nothing.""" + company_names = await ExcludeCompany.fetch_names(self.session) + company_urls = await ExcludeCompany.fetch_linkedin_urls(self.session) + university_names = await ExcludeUniversity.fetch_names(self.session) + return company_names, company_urls, university_names + async def _get_job(self, job_post_id): job = await JobPosts.get_job_post_by_id(self.session, job_post_id) if not job or job.is_deleted: @@ -60,8 +68,12 @@ class Talent: "experience_min": job.experience_min, "experience_max": job.experience_max, } + _company_names, company_urls, _university_names = await self._exclusion_lists() actor_input = plugins.build_actor_input( - job_fields, max_results=max_results, overrides=overrides + job_fields, + max_results=max_results, + overrides=overrides, + exclude_company_urls=company_urls, ) # Re-running the same search continues deeper into LinkedIn's result @@ -80,6 +92,7 @@ class Talent: max_results=max_results, overrides=overrides, start_page=max(prior_pages) + 1, + exclude_company_urls=company_urls, ) run = await TalentRuns.insert_run(self.session, { "job_post_id": job.id, @@ -149,10 +162,13 @@ class Talent: items = await plugins.get_dataset_items(dataset_id, limit=run.max_results) except (httpx.HTTPError, plugins.ApifyError, RuntimeError) as exc: raise HTTPException(status_code=502, detail=f"Apify dataset fetch failed: {exc}") + company_names, _company_urls, university_names = await self._exclusion_lists() normalized = [ p for p in (plugins.normalize_profile(i) for i in items) - if p and not plugins.is_excluded_profile(p) + if p and not plugins.is_excluded_profile( + p, companies=company_names, universities=university_names + ) ] job = await JobPosts.get_job_post_by_id(self.session, run.job_post_id) if job: diff --git a/frontend/src/api/orgSettings.js b/frontend/src/api/orgSettings.js index 858a2ba..ffc8983 100644 --- a/frontend/src/api/orgSettings.js +++ b/frontend/src/api/orgSettings.js @@ -10,6 +10,52 @@ export function update(settings) { return request('/org-settings/update', { method: 'PUT', body: { settings } }) } +export function listUniversities() { + return request('/org-settings/exclude-university/fetch') +} + +export function createUniversity(body) { + return request('/org-settings/exclude-university/create', { method: 'POST', body }) +} + +export function updateUniversity(recordId, body) { + return request('/org-settings/exclude-university/update', { + method: 'PATCH', + params: { record_id: recordId }, + body, + }) +} + +export function removeUniversity(recordId) { + return request('/org-settings/exclude-university/delete', { + method: 'DELETE', + params: { record_id: recordId }, + }) +} + +export function listCompanies() { + return request('/org-settings/exclude-company/fetch') +} + +export function createCompany(body) { + return request('/org-settings/exclude-company/create', { method: 'POST', body }) +} + +export function updateCompany(recordId, body) { + return request('/org-settings/exclude-company/update', { + method: 'PATCH', + params: { record_id: recordId }, + body, + }) +} + +export function removeCompany(recordId) { + return request('/org-settings/exclude-company/delete', { + method: 'DELETE', + params: { record_id: recordId }, + }) +} + /** Flatten `{data:[{key,value,category}]}` into a key → value map. */ export function toMap(res) { const rows = Array.isArray(res?.data) ? res.data : [] diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 459de3e..dcc40de 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -59,6 +59,8 @@ export const qk = { orgSettings: { all: () => ['orgSettings'], list: (p = {}) => ['orgSettings', 'list', p], + universities: () => ['orgSettings', 'exclude-universities'], + companies: () => ['orgSettings', 'exclude-companies'], }, savedSearches: { all: () => ['savedSearches'], diff --git a/frontend/src/screens/Settings.jsx b/frontend/src/screens/Settings.jsx index 41be6bf..a25f1df 100644 --- a/frontend/src/screens/Settings.jsx +++ b/frontend/src/screens/Settings.jsx @@ -1,8 +1,10 @@ /* ============================================================ Settings — org settings tabs persist via GET/PUT /org-settings/*. - Users + Appearance stay as before. Email Templates stay decorative - (explicitly out of Section C wiring scope). The Permissions tab remains - chrome; Access Control is the authoritative RBAC surface. + Excluding Universities / Companies are CRUD lists on Exclude_University + and Exclude_Company (not org_settings key/value). Users + Appearance stay + as before. Email Templates stay decorative (explicitly out of Section C + wiring scope). The Permissions tab remains chrome; Access Control is the + authoritative RBAC surface. ============================================================ */ import { useEffect, useMemo, useRef, useState } from 'react' @@ -35,6 +37,7 @@ function humaniseSlug(slug) { const TABS = [ 'General', 'Users', 'Approvals', 'Permissions', 'Notifications', 'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance', + 'Excluding Universities', 'Excluding Companies', ] const ORG_TABS = new Set(['General', 'Notifications', 'Career Portal', 'Branding', 'Security']) @@ -140,6 +143,7 @@ export default function Settings() { ({ key: t, label: t, @@ -158,6 +162,8 @@ export default function Settings() { {tab === 'Branding' && { saveRef.current = fn }} />} {tab === 'Security' && { saveRef.current = fn }} />} {tab === 'Appearance' && } + {tab === 'Excluding Universities' && } + {tab === 'Excluding Companies' && } ) @@ -1219,3 +1225,207 @@ function Appearance() { ) } + +function ExcludeUniversities() { + return ( + orgSettingsApi.createUniversity({ name })} + removeFn={orgSettingsApi.removeUniversity} + addedNoun="university" + /> + ) +} + +function ExcludeCompanies() { + return ( + orgSettingsApi.createCompany({ name, linkedin_url: extra || null })} + removeFn={orgSettingsApi.removeCompany} + addedNoun="company" + /> + ) +} + +function ExclusionList({ + title, subtitle, emptyTitle, emptyBody, + nameLabel, namePlaceholder, + extraLabel, extraPlaceholder, extraKey, extraHeader, + queryKey, listFn, createFn, removeFn, addedNoun, +}) { + const { toast } = useToast() + const { can } = usePermission() + const qc = useQueryClient() + const canConfigure = can('settings.configure') + const [name, setName] = useState('') + const [extra, setExtra] = useState('') + + const query = useQuery({ + queryKey, + queryFn: () => listFn().then((r) => r.data ?? []), + }) + const rows = query.data ?? [] + + const create = useMutation({ + mutationFn: () => createFn(name.trim(), extra.trim()), + onSuccess: () => { + qc.invalidateQueries({ queryKey }) + setName('') + setExtra('') + toast(`${addedNoun[0].toUpperCase()}${addedNoun.slice(1)} excluded`, 'success') + }, + onError: (err) => toast(friendlyAuthError(err, `Could not add this ${addedNoun}.`), 'error'), + }) + + const remove = useMutation({ + mutationFn: (id) => removeFn(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey }) + toast(`Removed from exclusion list`, 'success') + }, + onError: (err) => toast(friendlyAuthError(err, `Could not remove this ${addedNoun}.`), 'error'), + }) + + function submit() { + if (!name.trim() || create.isPending) return + create.mutate() + } + + if (query.isPending) { + return ( +
+ Fetching the exclusion list. +
+ ) + } + if (query.isError) { + return ( +
+ + {friendlyAuthError(query.error, 'This tab needs settings.view.')} + +
+ ) + } + + return ( +
+
+
+

{title}

+ {subtitle} +
+
+
+
{ e.preventDefault(); submit() }} + style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }} + > +
+ + setName(e.target.value)} + placeholder={namePlaceholder} + disabled={!canConfigure || create.isPending} + /> +
+ {extraKey && ( +
+ + setExtra(e.target.value)} + placeholder={extraPlaceholder} + disabled={!canConfigure || create.isPending} + /> +
+ )} +
+ +
+
+ {!canConfigure && ( +
+ Adding or removing entries requires settings.configure. +
+ )} +
+ + {rows.length === 0 ? ( +
+ {emptyBody} +
+ ) : ( +
+ + + + + {extraHeader && } + + + + + + {rows.map((row) => ( + + + {extraHeader && ( + + )} + + + + ))} + +
{nameLabel}{extraHeader}AddedActions
+
{row.name}
+
+ {row[extraKey] ? ( + {row[extraKey]} + ) : '—'} + {row.created_at ? fmtDate(row.created_at) : '—'} + +
+
+ )} +
+ ) +}