Merge pull request 'permission module base applied' (#69) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 37s Details

Reviewed-on: #69
pull/70/head^2
ahmed.mujtaba 2026-09-04 11:49:42 +00:00
commit 0365ee3a8f
9 changed files with 776 additions and 40 deletions

View File

@ -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))

View File

@ -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

View File

@ -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,
}

View File

@ -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}

View File

@ -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:

View File

@ -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:

View File

@ -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 : []

View File

@ -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'],

View File

@ -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() {
<Tabs
value={tab}
onChange={setTab}
className="tabs tabs-wrap"
tabs={TABS.map((t) => ({
key: t,
label: t,
@ -158,6 +162,8 @@ export default function Settings() {
{tab === 'Branding' && <Branding registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Security' && <Security registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Appearance' && <Appearance />}
{tab === 'Excluding Universities' && <ExcludeUniversities />}
{tab === 'Excluding Companies' && <ExcludeCompanies />}
</div>
</div>
)
@ -1219,3 +1225,207 @@ function Appearance() {
</div>
)
}
function ExcludeUniversities() {
return (
<ExclusionList
title="Excluded Universities"
subtitle="Talent sourcing skips profiles whose education matches a name on this list"
emptyTitle="No universities excluded"
emptyBody="Add a university name. Matching is case-insensitive and substring-based, so “NUST” also matches “NUST Islamabad”."
nameLabel="University name"
namePlaceholder="e.g. National University of Sciences and Technology"
queryKey={qk.orgSettings.universities()}
listFn={orgSettingsApi.listUniversities}
createFn={(name) => orgSettingsApi.createUniversity({ name })}
removeFn={orgSettingsApi.removeUniversity}
addedNoun="university"
/>
)
}
function ExcludeCompanies() {
return (
<ExclusionList
title="Excluded Companies"
subtitle="Talent sourcing skips people currently at these companies. Optional LinkedIn URLs stop those profiles being scraped at all."
emptyTitle="No companies excluded"
emptyBody="Add a company name. Matching is case-insensitive and substring-based, so “Utopia Brands” also matches “Utopia Brands Pakistan”."
nameLabel="Company name"
namePlaceholder="e.g. Utopia Brands"
extraLabel="LinkedIn company URL"
extraPlaceholder="https://www.linkedin.com/company/…"
extraKey="linkedin_url"
extraHeader="LinkedIn URL"
queryKey={qk.orgSettings.companies()}
listFn={orgSettingsApi.listCompanies}
createFn={(name, extra) => 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 (
<div className="card"><div className="card-body">
<EmptyState icon="settings" title="Loading…">Fetching the exclusion list.</EmptyState>
</div></div>
)
}
if (query.isError) {
return (
<div className="card"><div className="card-body">
<EmptyState icon="settings" title="Couldnt load exclusions">
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
</EmptyState>
</div></div>
)
}
return (
<div className="card">
<div className="card-head">
<div>
<h3>{title}</h3>
<span className="ch-sub">{subtitle}</span>
</div>
</div>
<div className="card-body">
<form
noValidate
onSubmit={(e) => { e.preventDefault(); submit() }}
style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}
>
<div className="form-field" style={{ flex: '1 1 220px' }}>
<label htmlFor={`ex-${addedNoun}-name`}>{nameLabel}</label>
<input
id={`ex-${addedNoun}-name`}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={namePlaceholder}
disabled={!canConfigure || create.isPending}
/>
</div>
{extraKey && (
<div className="form-field" style={{ flex: '1 1 260px' }}>
<label htmlFor={`ex-${addedNoun}-extra`}>{extraLabel}</label>
<input
id={`ex-${addedNoun}-extra`}
type="url"
value={extra}
onChange={(e) => setExtra(e.target.value)}
placeholder={extraPlaceholder}
disabled={!canConfigure || create.isPending}
/>
</div>
)}
<div className="form-field">
<button
type="submit"
className="btn btn-primary"
disabled={!canConfigure || create.isPending || !name.trim()}
>
<Icon name="plus" /> {create.isPending ? 'Adding…' : 'Add'}
</button>
</div>
</form>
{!canConfigure && (
<div className="alert alert-danger" style={{ marginTop: 14 }}>
Adding or removing entries requires <code>settings.configure</code>.
</div>
)}
</div>
{rows.length === 0 ? (
<div className="card-body" style={{ paddingTop: 0 }}>
<EmptyState icon="settings" title={emptyTitle}>{emptyBody}</EmptyState>
</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>{nameLabel}</th>
{extraHeader && <th>{extraHeader}</th>}
<th>Added</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>
<div className="cell-primary">{row.name}</div>
</td>
{extraHeader && (
<td className="text-muted">
{row[extraKey] ? (
<a href={row[extraKey]} target="_blank" rel="noreferrer">{row[extraKey]}</a>
) : '—'}
</td>
)}
<td className="text-muted">{row.created_at ? fmtDate(row.created_at) : '—'}</td>
<td style={{ textAlign: 'right' }}>
<button
className="act-btn danger"
aria-label={`Remove ${row.name}`}
disabled={!canConfigure || (remove.isPending && remove.variables === row.id)}
onClick={() => remove.mutate(row.id)}
>
<Icon name="trash" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}