diff --git a/.cursor/rules/backend-house-style.mdc b/.cursor/rules/backend-house-style.mdc new file mode 100644 index 0000000..99bf390 --- /dev/null +++ b/.cursor/rules/backend-house-style.mdc @@ -0,0 +1,122 @@ +--- +description: Strict HR-ATS backend house style — layering, routes, serializers, auth +globs: backend/**/*.py +alwaysApply: false +--- + +# HR-ATS Backend — singular pattern (mandatory) + +Match existing modules (`users/`, `inbox/`) exactly. Do not introduce alternate frameworks, layers, or response shapes. + +## Package layout (every domain) + +``` +backend// + app.py # routes only — HTTP in/out + views.py # service class — business logic + models.py # SQLModel table + classmethod DB accessors + serializers.py # hand-rolled dict builders (no Pydantic response models) + plugins.py # pure helpers (hash, JWT, clean payload) — NO FastAPI imports + permissions.py # OAuth2 scheme + Depends aliases (auth domains only) +``` + +- Mount with bare `router = APIRouter()`; register in `main.py` via `app.include_router(...)`. +- No package `__init__.py`; run uvicorn from `backend/` so imports are top-level (`users.app`, `db_setup`). +- Config: `load_dotenv()` + `os.getenv(...)` in the module that needs it. Do not extend `db_setup.Settings` for non-DB keys. + +## Layer duties (strict) + +| Layer | Owns | Must NOT | +|---|---|---| +| `app.py` | Routes, request Pydantic models (inline), `JSONResponse`, call serializers for HTTP payloads, inject `CurrentUser` / `session` | Business rules, DB queries, JWT encode logic beyond calling plugins | +| `views.py` | Validate rules, call models, raise `HTTPException`, return ORM rows or serialized dicts for CRUD | Build login/token HTTP envelopes; call `serialize_token` | +| `models.py` | Table fields, `select`/`insert`/`update`/`soft_delete`, `selectinload` when relations are needed | HTTPExceptions, serializers, FastAPI | +| `serializers.py` | `serialize_*` → plain `dict` (`str(uuid)`, `.isoformat()` dates, never password) | DB access, Depends | +| `plugins.py` | Pure functions; raise library errors (e.g. `jwt.*`), not HTTP | Import FastAPI | +| `permissions.py` | `OAuth2PasswordBearer`, `get_current_user`, `CurrentUser = Annotated[...]`, `require_permission` | Route handlers | + +## Route pattern (`app.py`) + +- Verb-in-path: `/users/create`, `/users/fetch`, `/users/login` — not `/auth/token`, not REST nouns-only. +- Every handler uses this wrapper: + +```python +try: + service=User(session=session) + data=await service.some_method(...) + return JSONResponse(content={"data":data,"status_code":200}) +except HTTPException: + raise +except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) +``` + +- List fetch: `{"data":items,"total":total,"status_code":200}`; single-by-id: `total: 1`. +- Login/refresh: build tokens in the route, then serialize: + +```python +user=await service.authenticate_user(...) +tokens=serialize_token(create_access_token(user),create_refresh_token(user),user) +return JSONResponse(content={**tokens,"status_code":200}) +``` + +- Request schemas live **inline** in `app.py` (`UserCreate`, `TokenRefresh`, …). Not in `serializers.py`. +- Dependencies: default form `session: AsyncSession = Depends(get_session)` unless `Annotated` is required (auth form / `CurrentUser` before other defaults). +- Tight spacing house style: `service=User(session=session)`, `detail=str(e)`, `key=value` in calls — match neighbors, do not “pretty-reformat” whole files. + +## Service pattern (`views.py`) + +```python +class User: + def __init__(self,session:AsyncSession): + self.session=session + + async def create_user(self,payload): + ... + return serialize_user(user) # CRUD returns serialized dict +``` + +- Untyped method args (match existing). Raise `HTTPException(status_code=...,detail="...")`. +- Auth methods (`authenticate_user`, `refresh_access_token`) return the **ORM user** only — never `serialize_token`. +- After email lookup that lacks `selectinload(role)`, re-fetch via `get_user_by_id` before anything that touches `user.role`. + +## Model accessors (`models.py`) + +- `@classmethod async def get_* / insert_* / update_* / soft_delete_* / count_*`. +- Soft delete: set `is_deleted=True`, `is_active=False`. +- Use `selectinload(cls.role)` on fetches that will be serialized with role fields. +- Commit inside model write methods (existing pattern). + +## Auth pattern + +- Access + refresh JWTs via `plugins` (`type` claim must be checked in `decode_token`). +- `permissions.CurrentUser` on every protected `/users/*` route; login + refresh stay open. +- `get_current_user`: decode access → DB load by `sub` → reject missing/deleted/inactive → `serialize_user`. +- Login uses `OAuth2PasswordRequestForm`; username field carries email. +- OAuth2 fields (`access_token`, `refresh_token`, `token_type`, `expires_in`) at **response root**; user under `data`. +- `tokenUrl="users/login"` (no leading slash). +- No FastAPI in `plugins.py`. Translate `jwt.PyJWTError` → 401 in `permissions` / `views`. + +## Dependencies / env + +- Pin in `requirements.txt` under comment banners with trailing rationale comments. +- Secrets in `backend/.env`; document keys in `backend/.env.example`. +- JWT times: `datetime.now(timezone.utc)` only (never naive `datetime.now()` for token `iat`/`exp`). + +## Hard bans + +- No new abstraction layers (repositories, use-cases, DTOs beyond inline Pydantic requests). +- No Pydantic response models; no `jsonable_encoder` for these routes. +- No changing response envelope (`data` + `status_code`) or inventing `/api/v1` prefixes. +- No drive-by refactors or reformatting unrelated code. +- No RBAC second scheme — vocabulary and `require_permission` live in `users/permissions.py` only. +- Do not touch `inbox/` when the task is `users/` (and vice versa) unless asked. +- No `__init__.py` packages; no moving request models into `serializers.py`. + +## When adding a new domain endpoint + +1. Accessor on `models.py` if DB changes. +2. Method on service in `views.py`. +3. `serialize_*` in `serializers.py` if new shape. +4. Route in `app.py` with the standard try/except + `JSONResponse`. +5. Protect with `current_user: CurrentUser` if under an authenticated router. diff --git a/.gitignore b/.gitignore index 368e0dd..c9fae03 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,11 @@ env/ .env .env.* !.env.example +!frontend/.env.development +!frontend/.env.production + +# Postman/Insomnia environments holding real API keys +*.postman_environment.local.json # Logs & temp *.log @@ -43,4 +48,9 @@ tmp/ temp/ .cache/ +# Frontend build +node_modules/ +frontend/dist/ + **.pdf +**_**_**.py \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..f299328 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,28 @@ +DB_USERNAME= +DB_PASSWORD= +DB_HOST= +DB_PORT= +DB_NAME= +EMAIL_URL= + +JWT_SECRET_KEY= +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 +JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 +JWT_RESET_TOKEN_EXPIRE_MINUTES=10 + +TEAMS_MAIL_API_URL= +TEAMS_API_TOKEN= + +RESET_CODE_TTL_SECONDS=60 +RESET_CODE_RESEND_SECONDS=30 +RESET_CODE_MAX_ATTEMPTS=5 + +FRONTEND_URL=http://localhost:5173 +CONFIRM_EMAIL_PATH=/auth/confirm-email +CONFIRM_TOKEN_TTL_SECONDS=86400 +CONFIRM_TOKEN_RESEND_SECONDS=60 + +BUFFER_API= +BUFFER_API_URL=https://api.buffer.com +BUFFER_CHANNEL_ID= diff --git a/backend/forget_password/app.py b/backend/forget_password/app.py new file mode 100644 index 0000000..38c408d --- /dev/null +++ b/backend/forget_password/app.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter,Depends +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, EmailStr +from forget_password.views import ForgetPassword +from forget_password.permissions import ResetCredentials +from forget_password.serializers import serialize_reset_token +from users.plugins import create_reset_token +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + + +class ForgetPasswordRequest(BaseModel): + email: EmailStr + + +class ForgetPasswordVerify(BaseModel): + email: EmailStr + code: str + + +class ForgetPasswordNew(BaseModel): + password: str + + +@router.post("/users/forget-password") +async def request_reset_code(payload: ForgetPasswordRequest,session: AsyncSession = Depends(get_session)): + try: + service=ForgetPassword(session=session) + data=await service.request_code(payload.email) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/users/forget-password/verify-code") +async def verify_reset_code(payload: ForgetPasswordVerify,session: AsyncSession = Depends(get_session)): + try: + service=ForgetPassword(session=session) + email,code_id=await service.verify_code(payload.email,payload.code) + tokens=serialize_reset_token(create_reset_token(email,code_id=code_id),email) + return JSONResponse(content={**tokens,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/users/forget-password/new-password") +async def set_new_password( + payload: ForgetPasswordNew, + credentials: ResetCredentials, + session: AsyncSession = Depends(get_session), +): + try: + service=ForgetPassword(session=session) + data=await service.set_new_password(credentials.credentials,payload.password) + 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/forget_password/models.py b/backend/forget_password/models.py new file mode 100644 index 0000000..eb04e29 --- /dev/null +++ b/backend/forget_password/models.py @@ -0,0 +1,106 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, SQLModel, select + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class PasswordResetCodes(SQLModel, table=True): + __tablename__ = "password_reset_codes" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + email: str = Field(index=True) + code_hash: str + expires_at: datetime = Field(sa_type=DateTime(timezone=True)) + attempts: int = Field(default=0) + is_used: bool = Field(default=False) + verified_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id: str) -> uuid.UUID | None: + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_code_by_id(cls, session: AsyncSession, record_id: str): + uid = cls._as_uuid(record_id) + if uid is None: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + + @classmethod + async def get_active_code_by_email(cls, session: AsyncSession, email: str): + """Newest unused code for email (expiry checked in Python by the service).""" + statement = ( + select(cls) + .where(cls.email == email, cls.is_used == False) # noqa: E712 + .order_by(cls.created_at.desc()) + ) + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def insert_code(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_code_by_id(session, row.id) + + @classmethod + async def increment_attempts(cls, session: AsyncSession, record_id: str): + row = await cls.get_code_by_id(session, record_id) + if not row: + return None + row.attempts = (row.attempts or 0) + 1 + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def mark_verified(cls, session: AsyncSession, record_id: str): + row = await cls.get_code_by_id(session, record_id) + if not row: + return None + row.verified_at = _now() + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def mark_used(cls, session: AsyncSession, record_id: str): + row = await cls.get_code_by_id(session, record_id) + if not row: + return None + row.is_used = True + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def invalidate_codes_for_email(cls, session: AsyncSession, email: str): + statement = select(cls).where(cls.email == email, cls.is_used == False) # noqa: E712 + result = await session.execute(statement) + rows = result.scalars().all() + now = _now() + for row in rows: + row.is_used = True + row.updated_at = now + session.add(row) + await session.commit() + return len(rows) diff --git a/backend/forget_password/permissions.py b/backend/forget_password/permissions.py new file mode 100644 index 0000000..39719f4 --- /dev/null +++ b/backend/forget_password/permissions.py @@ -0,0 +1,11 @@ +"""Bearer scheme for password-reset tokens (type=reset JWT).""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +reset_scheme = HTTPBearer() +ResetCredentials = Annotated[HTTPAuthorizationCredentials, Depends(reset_scheme)] diff --git a/backend/forget_password/plugins.py b/backend/forget_password/plugins.py new file mode 100644 index 0000000..fb4b67e --- /dev/null +++ b/backend/forget_password/plugins.py @@ -0,0 +1,77 @@ +"""Forget-password helpers — OTP generation, hashing, and Teams mail send. + +Pure module: no FastAPI imports and no HTTPException. +""" + +from __future__ import annotations + +import os +import secrets +from datetime import datetime, timedelta, timezone + +import httpx +from dotenv import load_dotenv + +from users.plugins import hash_password, verify_password + +load_dotenv() + +TEAMS_MAIL_API_URL = os.getenv("TEAMS_MAIL_API_URL") +TEAMS_API_TOKEN = os.getenv("TEAMS_API_TOKEN") +RESET_CODE_TTL_SECONDS = int(os.getenv("RESET_CODE_TTL_SECONDS", "60")) +RESET_CODE_RESEND_SECONDS = int(os.getenv("RESET_CODE_RESEND_SECONDS", "30")) +RESET_CODE_MAX_ATTEMPTS = int(os.getenv("RESET_CODE_MAX_ATTEMPTS", "5")) +MAIL_ACCEPTED_STATUS = 202 + + +def now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def code_expiry(*, now: datetime | None = None) -> datetime: + return (now or now_utc()) + timedelta(seconds=RESET_CODE_TTL_SECONDS) + + +def generate_code() -> str: + return f"{secrets.randbelow(1_000_000):06d}" + + +def hash_code(code: str) -> str: + return hash_password(code) + + +def verify_code(code: str, code_hash: str) -> bool: + return verify_password(code, code_hash) + + +def render_reset_email(code: str, ttl: int) -> tuple[str, str]: + subject = "Your TalentFlow password reset code" + html = ( + f"

Your password reset code is {code}.

" + f"

It expires in {ttl} seconds. If you did not request this, ignore this email.

" + ) + return subject, html + + +async def send_reset_mail(to_email: str, subject: str, html: str) -> None: + if not TEAMS_MAIL_API_URL or not TEAMS_API_TOKEN: + raise RuntimeError("TEAMS_MAIL_API_URL and TEAMS_API_TOKEN must be set") + fields = [ + ("subject", (None, subject)), + ("body", (None, html)), + ("content_type", (None, "html")), + ("save_to_sent_items", (None, "false")), + ("to", (None, to_email)), + ] + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post( + TEAMS_MAIL_API_URL, + files=fields, + headers={"Authorization": f"Bearer {TEAMS_API_TOKEN}"}, + ) + if response.status_code != MAIL_ACCEPTED_STATUS: + raise httpx.HTTPStatusError( + response.text, + request=response.request, + response=response, + ) diff --git a/backend/forget_password/serializers.py b/backend/forget_password/serializers.py new file mode 100644 index 0000000..a96a7dd --- /dev/null +++ b/backend/forget_password/serializers.py @@ -0,0 +1,27 @@ +from forget_password.plugins import RESET_CODE_RESEND_SECONDS,RESET_CODE_TTL_SECONDS +from users.plugins import RESET_TOKEN_EXPIRE_SECONDS + + +def serialize_reset_request(email: str,expires_at) -> dict: + return { + "email": email, + "expires_at": expires_at.isoformat() if expires_at else None, + "expires_in": RESET_CODE_TTL_SECONDS, + "resend_after": RESET_CODE_RESEND_SECONDS, + } + + +def serialize_reset_token(reset_token: str,email: str) -> dict: + return { + "reset_token": reset_token, + "token_type": "bearer", + "expires_in": RESET_TOKEN_EXPIRE_SECONDS, + "data": {"email": email}, + } + + +def serialize_reset_result(email: str) -> dict: + return { + "email": email, + "password_updated": True, + } diff --git a/backend/forget_password/views.py b/backend/forget_password/views.py new file mode 100644 index 0000000..cb9fc7b --- /dev/null +++ b/backend/forget_password/views.py @@ -0,0 +1,109 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +import httpx +import jwt + +from forget_password.models import PasswordResetCodes +from forget_password.plugins import ( + RESET_CODE_MAX_ATTEMPTS, + RESET_CODE_RESEND_SECONDS, + code_expiry, + generate_code, + hash_code, + now_utc, + render_reset_email, + send_reset_mail, + verify_code, + RESET_CODE_TTL_SECONDS, +) +from forget_password.serializers import serialize_reset_request,serialize_reset_result +from users.models import Users +from users.plugins import decode_token,hash_password + + +class ForgetPassword: + def __init__(self,session:AsyncSession): + self.session=session + + async def request_code(self,email): + user=await Users.get_user_by_email(self.session,email) + if not user or user.is_deleted or not user.is_active: + raise HTTPException(status_code=404,detail="No account found for this email") + + active=await PasswordResetCodes.get_active_code_by_email(self.session,email) + if active: + age=(now_utc()-active.created_at).total_seconds() + if agenow_utc(): + raise HTTPException(status_code=429,detail="Please wait before requesting another code") + + await PasswordResetCodes.invalidate_codes_for_email(self.session,email) + + code=generate_code() + expires_at=code_expiry() + row=await PasswordResetCodes.insert_code(self.session,{ + "email":email, + "code_hash":hash_code(code), + "expires_at":expires_at, + }) + + subject,html=render_reset_email(code,RESET_CODE_TTL_SECONDS) + try: + await send_reset_mail(email,subject,html) + except (httpx.HTTPError,RuntimeError) as e: + await PasswordResetCodes.mark_used(self.session,str(row.id)) + raise HTTPException(status_code=502,detail="Failed to send reset email") from e + + return serialize_reset_request(email,expires_at) + + async def verify_code(self,email,code): + row=await PasswordResetCodes.get_active_code_by_email(self.session,email) + if not row: + raise HTTPException(status_code=400,detail="No active reset code for this email") + if row.expires_at<=now_utc(): + raise HTTPException(status_code=400,detail="Reset code has expired") + if (row.attempts or 0)>=RESET_CODE_MAX_ATTEMPTS: + raise HTTPException(status_code=429,detail="Too many invalid attempts") + + if not verify_code(code,row.code_hash): + updated=await PasswordResetCodes.increment_attempts(self.session,str(row.id)) + if updated and (updated.attempts or 0)>=RESET_CODE_MAX_ATTEMPTS: + raise HTTPException(status_code=429,detail="Too many invalid attempts") + raise HTTPException(status_code=400,detail="Invalid reset code") + + await PasswordResetCodes.mark_verified(self.session,str(row.id)) + return email,str(row.id) + + async def set_new_password(self,reset_token,password): + try: + payload=decode_token(reset_token,expected_type="reset") + except jwt.PyJWTError: + raise HTTPException( + status_code=401, + detail="Invalid or expired reset token", + headers={"WWW-Authenticate":"Bearer"}, + ) + + email=payload.get("sub") + code_id=payload.get("crid") + if not email or not code_id: + raise HTTPException( + status_code=401, + detail="Invalid or expired reset token", + headers={"WWW-Authenticate":"Bearer"}, + ) + + row=await PasswordResetCodes.get_code_by_id(self.session,code_id) + if not row or row.is_used or row.email!=email or not row.verified_at: + raise HTTPException( + status_code=401, + detail="Invalid or expired reset token", + headers={"WWW-Authenticate":"Bearer"}, + ) + + user=await Users.get_user_by_email(self.session,email) + if not user or user.is_deleted: + raise HTTPException(status_code=404,detail="User not found") + + await Users.update_user(self.session,str(user.id),{"password":hash_password(password)}) + await PasswordResetCodes.mark_used(self.session,str(row.id)) + return serialize_reset_result(email) diff --git a/backend/job/app.py b/backend/job/app.py new file mode 100644 index 0000000..62e22cb --- /dev/null +++ b/backend/job/app.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter,Depends +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, model_validator +from users.permissions import PermissionTag, require_permission +from job.job_post.views import JobPost +from job.job_post.plugins import PlatformAlias +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + + +class JobPostCreate(BaseModel): + title: str + experience_min: int | None = None + experience_max: int | None = None + requirements: list[str] = [] + optional_skills: list[str] = [] + salary: str = "Anonymous" + location: str | None = None + employment_type: str | None = None + platform: str = "linkedin" + description: str | None = None + platform: str | None = None + channel_id: str | None = None + mode: str = "addToQueue" + due_at: str | None = None + + @model_validator(mode="after") + def validate_mode_and_due_at(self): + allowed = {"addToQueue", "shareNow", "customScheduled"} + if self.mode not in allowed: + raise ValueError(f"mode must be one of {sorted(allowed)}") + if self.mode == "customScheduled" and not self.due_at: + raise ValueError("due_at is required when mode is customScheduled") + return self + +@router.get("/jobs/alias") +async def get_job_alias(): + try: + alias_lst=[k.name for k in PlatformAlias] + return JSONResponse(content={"data":alias_lst,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/candidate/cv_upload") +async def cv_upload( + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + pass + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/job/post-job") +async def post_job( + payload: JobPostCreate, + current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + service=JobPost(session=session) + data=await service.post_job(payload.model_dump(),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("/job/buffer/channels") +async def buffer_channels( + current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=JobPost(session=session) + data=await service.list_channels() + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py new file mode 100644 index 0000000..0c00b49 --- /dev/null +++ b/backend/job/job_post/models.py @@ -0,0 +1,117 @@ +import uuid +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import DateTime, JSON +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, Relationship, SQLModel, select + +if TYPE_CHECKING: # runtime import would be circular: users.models imports this module + from users.models import Users + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class JobPosts(SQLModel, table=True): + __tablename__ = "job_posts" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + title: str = Field(index=True) + + user: Optional["Users"] = Relationship( + back_populates="job_posts", + sa_relationship_kwargs={"lazy": "joined"}, + ) + + platform: str = Field(default="linkedin") + is_active: bool = Field(default=True) + is_deleted: bool = Field(default=False) + employment_type: str | None = Field(default=None) + location: str | None = Field(default=None) + experience_min: int | None = Field(default=None) + experience_max: int | None = Field(default=None) + requirements: list[str] = Field(default_factory=list, sa_type=JSON) + optional_skills: list[str] = Field(default_factory=list, sa_type=JSON) + salary: str = Field(default="Anonymous") + description: str | None = Field(default=None) + post_text: str + channel_id: str + buffer_post_id: str | None = Field(default=None) + buffer_external_link: str | None = Field(default=None) + buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + status: str = Field(default="draft") + buffer_error: str | None = Field(default=None) + created_by: uuid.UUID = Field(foreign_key="users.id") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id: str) -> uuid.UUID | None: + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_job_post_by_id(cls, session: AsyncSession, record_id: str): + uid = cls._as_uuid(record_id) + if uid is None: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + + @classmethod + async def insert_job_post(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_job_post_by_id(session, row.id) + + @classmethod + async def mark_buffer_result( + cls, + session: AsyncSession, + record_id: str, + *, + buffer_post_id: str, + status: str, + external_link: str | None = None, + sent_at: datetime | None = None, + platform: str | None = None, + ): + """Record what Buffer reported. + `status` is the mapped Buffer PostStatus, not an assumption: a queued post lands + here as "scheduled" and only becomes "published" once Buffer says `sent`. + """ + row = await cls.get_job_post_by_id(session, record_id) + if not row: + return None + row.status = status + row.buffer_post_id = buffer_post_id + row.buffer_external_link = external_link + row.buffer_sent_at = sent_at + if platform: + row.platform = platform + row.buffer_error = None + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def mark_failed(cls, session: AsyncSession, record_id: str, error: str): + row = await cls.get_job_post_by_id(session, record_id) + if not row: + return None + row.status = "failed" + row.buffer_error = error + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + +import users.models as _users_models \ No newline at end of file diff --git a/backend/job/job_post/plugins.py b/backend/job/job_post/plugins.py new file mode 100644 index 0000000..b19fa26 --- /dev/null +++ b/backend/job/job_post/plugins.py @@ -0,0 +1,306 @@ +"""Buffer GraphQL helpers and LinkedIn job-post copy rendering. + +Pure module: no FastAPI imports and no HTTPException. +""" + +from __future__ import annotations + +import json +import os +import re +from datetime import datetime +from enum import Enum + +import httpx +from dotenv import load_dotenv + +load_dotenv() + +BUFFER_API = os.getenv("BUFFER_API") +BUFFER_API_URL = os.getenv("BUFFER_API_URL", "https://api.buffer.com") +BUFFER_CHANNEL_ID = os.getenv("BUFFER_CHANNEL_ID") +LINKEDIN_POST_MAX_CHARS = 3000 + +# Buffer's PostStatus -> the job_posts.status lifecycle. Only `sent` means the post is +# actually live on the network: the default `addToQueue` mode comes back as `scheduled`, +# so treating any successful mutation as "published" would record a post that nobody +# outside Buffer can see yet. +BUFFER_STATUS_TO_LOCAL = { + "sent": "published", + "sending": "publishing", + "scheduled": "scheduled", + "draft": "draft", + "needs_approval": "needs_approval", + "error": "failed", +} + + +def local_status(buffer_status) -> str: + """Map a Buffer PostStatus onto our own. Unknown values stay uncommitted.""" + return BUFFER_STATUS_TO_LOCAL.get(buffer_status or "", "scheduled") + + +class PlatformAlias(str, Enum): + """Shorthands people type, mapped to Buffer's own `Service` values. + + Member name = what arrives in the request, value = what Buffer calls it. This is + spelling tolerance only, *not* the list of supported networks: anything absent here + still resolves, because `resolve_channel` matches against the services Buffer + actually reports. A newly connected network needs no entry. + + Names that share a value (ig/insta) become Enum aliases, which is exactly the + intent -- lookup is by member name via `__members__`. + """ + + fb = "facebook" + ig = "instagram" + insta = "instagram" + li = "linkedin" + x = "twitter" + tweet = "twitter" + yt = "youtube" + gbp = "googlebusiness" + google = "googlebusiness" + googlebusinessprofile = "googlebusiness" + + +def normalize_platform(value) -> str: + """Case/punctuation-insensitive key for comparing a requested platform.""" + key = re.sub(r"[^a-z0-9]", "", str(value or "").lower()) + alias = PlatformAlias.__members__.get(key) + return alias.value if alias else key + + +async def resolve_channel(platform, channels=None) -> dict: + """Return the connected channel for `platform`. + + Nothing is special-cased per network: the request is matched against the services + Buffer reports, so any platform Buffer supports and the account has connected will + resolve. Falls back to matching the channel's handle or display name so + "ahmedmujtababaig" works as well as "linkedin". + """ + wanted = normalize_platform(platform) + if not wanted: + raise BufferError("No platform given") + if channels is None: + channels = await list_buffer_channels() + for field in ("service", "name", "displayName"): + for channel in channels: + if normalize_platform(channel.get(field)) == wanted: + return channel + available = sorted({c.get("service") for c in channels if c.get("service")}) + raise BufferError( + f"No Buffer channel connected for platform {platform!r}. " + f"Connected: {', '.join(available) if available else 'none'}" + ) + + +def parse_buffer_datetime(value): + """Buffer sends ISO 8601 with a trailing `Z`, which fromisoformat wants as +00:00.""" + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + + +class BufferError(RuntimeError): + def __init__(self, message: str, *, code: str | None = None): + super().__init__(message) + self.code = code + + +def render_job_post(payload) -> str: + title = (payload.get("title") or "").strip() or "Open Role" + location = (payload.get("location") or "").strip() + employment_type = (payload.get("employment_type") or "").strip() + experience_min = payload.get("experience_min") + experience_max = payload.get("experience_max") + requirements = [str(r).strip() for r in (payload.get("requirements") or []) if str(r).strip()] + optional_skills = [str(s).strip() for s in (payload.get("optional_skills") or []) if str(s).strip()] + salary = (payload.get("salary") or "Anonymous").strip() or "Anonymous" + description = (payload.get("description") or "").strip() + + lines = [f"We're hiring: {title}", ""] + + meta = [] + if location: + meta.append(location) + if employment_type: + meta.append(employment_type) + if meta: + lines.append(" · ".join(meta)) + lines.append("") + + if experience_min is not None and experience_max is not None: + lines.append(f"Experience: {experience_min}–{experience_max} years") + lines.append("") + elif experience_min is not None: + lines.append(f"Experience: {experience_min}+ years") + lines.append("") + elif experience_max is not None: + lines.append(f"Experience: up to {experience_max} years") + lines.append("") + + if requirements: + lines.append("Requirements:") + for item in requirements: + lines.append(f"• {item}") + lines.append("") + + if optional_skills: + lines.append("Nice to have:") + for item in optional_skills: + lines.append(f"• {item}") + lines.append("") + + lines.append(f"Salary: {salary}") + lines.append("") + + if description: + lines.append(description) + lines.append("") + + lines.append("Interested? Apply via our careers page or reply to this post.") + lines.append("") + + tags = [] + for item in requirements: + tag = re.sub(r"[^A-Za-z0-9]+", "", item) + if tag: + tags.append(f"#{tag}") + if tags: + lines.append(" ".join(tags)) + + text = "\n".join(lines).strip() + if len(text) > LINKEDIN_POST_MAX_CHARS: + text = text[: LINKEDIN_POST_MAX_CHARS - 1].rstrip() + "…" + return text + + +def build_create_post_query(text, channel_id, *, mode="addToQueue", due_at=None) -> str: + fields = [ + f"text: {json.dumps(text)}", + f"channelId: {json.dumps(channel_id)}", + "schedulingType: automatic", + f"mode: {mode}", + ] + if mode == "customScheduled" and due_at: + fields.append(f"dueAt: {json.dumps(due_at)}") + input_block = ",\n ".join(fields) + return ( + "mutation CreatePost {\n" + " createPost(input: {\n" + f" {input_block}\n" + " }) {\n" + " ... on PostActionSuccess {\n" + " post { id text status sentAt externalLink channelService }\n" + " }\n" + " ... on MutationError { message }\n" + " }\n" + "}" + ) + + +async def create_buffer_post(text, channel_id, *, mode="addToQueue", due_at=None) -> dict: + if not BUFFER_API: + raise RuntimeError("BUFFER_API is not configured") + query = build_create_post_query(text, channel_id, mode=mode, due_at=due_at) + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post( + BUFFER_API_URL, + json={"query": query}, + headers={ + "Authorization": f"Bearer {BUFFER_API}", + "Content-Type": "application/json", + }, + ) + if response.status_code != 200: + raise httpx.HTTPStatusError( + response.text, + request=response.request, + response=response, + ) + body = response.json() + errors = body.get("errors") + if errors: + first = errors[0] if isinstance(errors, list) and errors else {} + msg = first.get("message") or "Buffer GraphQL error" + code = (first.get("extensions") or {}).get("code") + raise BufferError(msg, code=code) + create_post = (body.get("data") or {}).get("createPost") or {} + if "message" in create_post and "post" not in create_post: + raise BufferError(create_post.get("message") or "Buffer mutation error") + post = create_post.get("post") + if not post or not post.get("id"): + raise BufferError("Buffer did not return a post id") + return post + + +async def list_buffer_channels() -> list[dict]: + if not BUFFER_API: + raise RuntimeError("BUFFER_API is not configured") + async with httpx.AsyncClient(timeout=15.0) as client: + orgs_response = await client.post( + BUFFER_API_URL, + json={"query": "query { account { organizations { id name } } }"}, + headers={ + "Authorization": f"Bearer {BUFFER_API}", + "Content-Type": "application/json", + }, + ) + if orgs_response.status_code != 200: + raise httpx.HTTPStatusError( + orgs_response.text, + request=orgs_response.request, + response=orgs_response, + ) + orgs_body = orgs_response.json() + if orgs_body.get("errors"): + first = orgs_body["errors"][0] + raise BufferError( + first.get("message") or "Buffer GraphQL error", + code=(first.get("extensions") or {}).get("code"), + ) + organizations = ((orgs_body.get("data") or {}).get("account") or {}).get("organizations") or [] + channels: list[dict] = [] + for org in organizations: + org_id = org.get("id") + if not org_id: + continue + channels_query = ( + "query GetChannels {\n" + f' channels(input:{{organizationId:{json.dumps(org_id)}}}) {{\n' + " id name displayName service isQueuePaused\n" + " }\n" + "}" + ) + channels_response = await client.post( + BUFFER_API_URL, + json={"query": channels_query}, + headers={ + "Authorization": f"Bearer {BUFFER_API}", + "Content-Type": "application/json", + }, + ) + if channels_response.status_code != 200: + raise httpx.HTTPStatusError( + channels_response.text, + request=channels_response.request, + response=channels_response, + ) + channels_body = channels_response.json() + if channels_body.get("errors"): + first = channels_body["errors"][0] + raise BufferError( + first.get("message") or "Buffer GraphQL error", + code=(first.get("extensions") or {}).get("code"), + ) + for channel in (channels_body.get("data") or {}).get("channels") or []: + channels.append({ + **channel, + "organization_id": org_id, + "organization_name": org.get("name"), + }) + return channels diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py new file mode 100644 index 0000000..4589f3d --- /dev/null +++ b/backend/job/job_post/serializers.py @@ -0,0 +1,24 @@ +def serialize_job_post(row) -> dict: + return { + "id": str(row.id), + "title": row.title, + "employment_type": row.employment_type, + "location": row.location, + "experience_min": row.experience_min, + "experience_max": row.experience_max, + "requirements": list(row.requirements or []), + "optional_skills": list(row.optional_skills or []), + "salary": row.salary, + "description": row.description, + "post_text": row.post_text, + "channel_id": row.channel_id, + "platform": row.platform, + "buffer_post_id": row.buffer_post_id, + "buffer_external_link": row.buffer_external_link, + "buffer_sent_at": row.buffer_sent_at.isoformat() if row.buffer_sent_at else None, + "status": row.status, + "buffer_error": row.buffer_error, + "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/job/job_post/views.py b/backend/job/job_post/views.py new file mode 100644 index 0000000..b80d985 --- /dev/null +++ b/backend/job/job_post/views.py @@ -0,0 +1,111 @@ +import os + +import httpx +from dotenv import load_dotenv +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from job.job_post.models import JobPosts +from job.job_post.plugins import ( + BufferError, + create_buffer_post, + list_buffer_channels, + local_status, + normalize_platform, + parse_buffer_datetime, + render_job_post, + resolve_channel, +) +from job.job_post.serializers import serialize_job_post + +load_dotenv() + + +class JobPost: + def __init__(self,session:AsyncSession): + self.session=session + self.buffer_api=os.getenv("BUFFER_API") + self.channel_id=os.getenv("BUFFER_CHANNEL_ID") + + async def _resolve_target(self,payload): + """Pick the Buffer channel to post to, and the service it belongs to. + + Precedence: an explicit channel_id, then the requested platform, then the + configured default channel. Returns (channel_id, service) where service is + None if we did not have to look the channel up. + """ + if payload.get("channel_id"): + return payload["channel_id"],None + if payload.get("platform"): + channel=await resolve_channel(payload["platform"]) + return channel["id"],channel.get("service") + if self.channel_id: + return self.channel_id,None + raise HTTPException( + status_code=400, + detail="Provide channel_id or platform, or configure BUFFER_CHANNEL_ID", + ) + + async def post_job(self,payload,current_user): + try: + channel_id,service=await self._resolve_target(payload) + except (httpx.HTTPError,BufferError,RuntimeError) as e: + raise HTTPException(status_code=502,detail=f"Failed to resolve Buffer channel: {e}") from e + + text=render_job_post(payload) + fields={ + "title":payload.get("title"), + "employment_type":payload.get("employment_type"), + "location":payload.get("location"), + "experience_min":payload.get("experience_min"), + "experience_max":payload.get("experience_max"), + "requirements":list(payload.get("requirements") or []), + "optional_skills":list(payload.get("optional_skills") or []), + "salary":payload.get("salary") or "Anonymous", + "description":payload.get("description"), + "post_text":text, + "channel_id":channel_id, + "status":"draft", + "created_by":current_user["id"], + } + # Only set platform when it is actually known: passing None would override the + # column default and break the NOT NULL constraint. Buffer's channelService + # replaces this with the authoritative value once the post is created. + known_platform=service or normalize_platform(payload.get("platform")) + if known_platform: + fields["platform"]=known_platform + row=await JobPosts.insert_job_post(self.session,fields) + + try: + post=await create_buffer_post( + text, + channel_id, + mode=payload.get("mode") or "addToQueue", + due_at=payload.get("due_at"), + ) + except (httpx.HTTPError,BufferError,RuntimeError) as e: + # Include the reason: Buffer's rejections are actionable (duplicate text, + # daily limit, disconnected channel) and an opaque 502 sends the caller + # digging through job_posts.buffer_error to find out. + await JobPosts.mark_failed(self.session,str(row.id),str(e)) + raise HTTPException(status_code=502,detail=f"Failed to publish job post to Buffer: {e}") from e + + # Buffer accepting the mutation is not the same as the network publishing it: + # the default addToQueue mode returns `scheduled`, so the row only reads + # "published" once Buffer reports `sent`. + saved=await JobPosts.mark_buffer_result( + self.session, + str(row.id), + buffer_post_id=post["id"], + status=local_status(post.get("status")), + external_link=post.get("externalLink"), + sent_at=parse_buffer_datetime(post.get("sentAt")), + platform=post.get("channelService"), + ) + return serialize_job_post(saved) + + async def list_channels(self): + try: + return await list_buffer_channels() + except (httpx.HTTPError,BufferError,RuntimeError) as e: + raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e diff --git a/backend/main.py b/backend/main.py index c13d9f4..2c07775 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,6 +8,9 @@ from db_setup import lifespan from inbox.app import router as inbox_router from users.app import router as users_router from role.app import router as role_router +from forget_password.app import router as forget_password_router +from job.app import router as candidate_router +from notifications.app import router as confirmation_router # Without this the db/migration logs have no handler and are swallowed under uvicorn. logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s") @@ -24,4 +27,7 @@ app.add_middleware( app.include_router(inbox_router) app.include_router(users_router) -app.include_router(role_router) \ No newline at end of file +app.include_router(role_router) +app.include_router(forget_password_router) +app.include_router(confirmation_router) +app.include_router(candidate_router) \ No newline at end of file diff --git a/backend/notifications/app.py b/backend/notifications/app.py new file mode 100644 index 0000000..f26159c --- /dev/null +++ b/backend/notifications/app.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter,Depends +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, EmailStr +from notifications.views import Confirmation +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + + +class ConfirmEmailRequest(BaseModel): + token: str + + +class ConfirmEmailResend(BaseModel): + email: EmailStr + + +@router.post("/users/confirm-email") +async def confirm_email(payload: ConfirmEmailRequest,session: AsyncSession = Depends(get_session)): + try: + service=Confirmation(session=session) + data=await service.confirm(payload.token) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/users/confirm-email/resend") +async def resend_confirm_email(payload: ConfirmEmailResend,session: AsyncSession = Depends(get_session)): + try: + service=Confirmation(session=session) + data=await service.resend(payload.email) + 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/notifications/models.py b/backend/notifications/models.py new file mode 100644 index 0000000..ceca0c3 --- /dev/null +++ b/backend/notifications/models.py @@ -0,0 +1,102 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, SQLModel, select + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class EmailConfirmationTokens(SQLModel, table=True): + __tablename__ = "email_confirmation_tokens" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field(index=True, foreign_key="users.id") + email: str = Field(index=True) + token_hash: str + expires_at: datetime = Field(sa_type=DateTime(timezone=True)) + is_used: bool = Field(default=False) + confirmed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id: str) -> uuid.UUID | None: + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_token_by_id(cls, session: AsyncSession, record_id: str): + uid = cls._as_uuid(record_id) + if uid is None: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + + @classmethod + async def get_active_token_by_user(cls, session: AsyncSession, user_id: str): + """Newest unused token for a user (expiry checked in Python by the service).""" + uid = cls._as_uuid(user_id) + if uid is None: + return None + statement = ( + select(cls) + .where(cls.user_id == uid, cls.is_used == False) # noqa: E712 + .order_by(cls.created_at.desc()) + ) + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def insert_token(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_token_by_id(session, row.id) + + @classmethod + async def mark_confirmed(cls, session: AsyncSession, record_id: str): + row = await cls.get_token_by_id(session, record_id) + if not row: + return None + row.is_used = True + row.confirmed_at = _now() + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def mark_used(cls, session: AsyncSession, record_id: str): + """Retire a token without confirming it — used when the mail send fails.""" + row = await cls.get_token_by_id(session, record_id) + if not row: + return None + row.is_used = True + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def invalidate_tokens_for_user(cls, session: AsyncSession, user_id: str): + uid = cls._as_uuid(user_id) + if uid is None: + return 0 + statement = select(cls).where(cls.user_id == uid, cls.is_used == False) # noqa: E712 + result = await session.execute(statement) + rows = result.scalars().all() + now = _now() + for row in rows: + row.is_used = True + row.updated_at = now + session.add(row) + await session.commit() + return len(rows) diff --git a/backend/notifications/plugins.py b/backend/notifications/plugins.py new file mode 100644 index 0000000..69ce5bb --- /dev/null +++ b/backend/notifications/plugins.py @@ -0,0 +1,115 @@ +"""Confirmation helpers — token generation, hashing, link building, and Teams mail send. + +Pure module: no FastAPI imports and no HTTPException. + +`send_confirmation_mail` intentionally duplicates +`forget_password.plugins.send_reset_mail` rather than importing it: that helper is +domain-named, and each domain owns its own mail copy and its own env reads. +""" + +from __future__ import annotations + +import os +import secrets +import uuid +from datetime import datetime, timedelta, timezone +from urllib.parse import quote + +import httpx +from dotenv import load_dotenv + +from users.plugins import hash_password, verify_password + +load_dotenv() + +TEAMS_MAIL_API_URL = os.getenv("TEAMS_MAIL_API_URL") +TEAMS_API_TOKEN = os.getenv("TEAMS_API_TOKEN") +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") +CONFIRM_EMAIL_PATH = os.getenv("CONFIRM_EMAIL_PATH", "/auth/confirm-email") +CONFIRM_TOKEN_TTL_SECONDS = int(os.getenv("CONFIRM_TOKEN_TTL_SECONDS", "86400")) +CONFIRM_TOKEN_RESEND_SECONDS = int(os.getenv("CONFIRM_TOKEN_RESEND_SECONDS", "60")) +MAIL_ACCEPTED_STATUS = 202 + +# 32 bytes -> 43 url-safe characters, well under users.plugins.BCRYPT_MAX_BYTES. +TOKEN_SECRET_BYTES = 32 + + +def now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def confirmation_expiry(*, now: datetime | None = None) -> datetime: + return (now or now_utc()) + timedelta(seconds=CONFIRM_TOKEN_TTL_SECONDS) + + +def generate_token_secret() -> str: + return secrets.token_urlsafe(TOKEN_SECRET_BYTES) + + +def hash_token(secret: str) -> str: + return hash_password(secret) + + +def verify_token(secret: str, token_hash: str) -> bool: + return verify_password(secret, token_hash) + + +def compose_token(record_id, secret: str) -> str: + """Link token. A bcrypt hash cannot be looked up, so the row id rides along.""" + return f"{record_id}.{secret}" + + +def split_token(token: str) -> tuple[str | None, str | None]: + """('','') or (None,None). token_urlsafe never emits a dot.""" + if not token or "." not in token: + return None, None + record_id, secret = token.split(".", 1) + if not record_id or not secret: + return None, None + try: + uuid.UUID(record_id) + except ValueError: + return None, None + return record_id, secret + + +def build_confirmation_link(token: str) -> str: + base = FRONTEND_URL.rstrip("/") + path = CONFIRM_EMAIL_PATH if CONFIRM_EMAIL_PATH.startswith("/") else f"/{CONFIRM_EMAIL_PATH}" + return f"{base}{path}?token={quote(token, safe='')}" + + +def render_confirmation_email(link: str, ttl: int) -> tuple[str, str]: + hours = max(1, ttl // 3600) + subject = "Confirm your TalentFlow account" + html = ( + "

Welcome to TalentFlow. Confirm your email address to activate your account.

" + f'

Confirm my email

' + f"

This link expires in {hours} hour(s). If you did not sign up, ignore this email.

" + f"

If the link does not open, paste this into your browser:
{link}

" + ) + return subject, html + + +async def send_confirmation_mail(to_email: str, subject: str, html: str) -> None: + if not TEAMS_MAIL_API_URL or not TEAMS_API_TOKEN: + raise RuntimeError("TEAMS_MAIL_API_URL and TEAMS_API_TOKEN must be set") + fields = [ + ("subject", (None, subject)), + ("body", (None, html)), + ("content_type", (None, "html")), + ("save_to_sent_items", (None, "false")), + ("to", (None, to_email)), + ] + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post( + TEAMS_MAIL_API_URL, + files=fields, + headers={"Authorization": f"Bearer {TEAMS_API_TOKEN}"}, + ) + if response.status_code != MAIL_ACCEPTED_STATUS: + raise httpx.HTTPStatusError( + response.text, + request=response.request, + response=response, + ) diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py new file mode 100644 index 0000000..62f4ee0 --- /dev/null +++ b/backend/notifications/serializers.py @@ -0,0 +1,20 @@ +from notifications.plugins import CONFIRM_TOKEN_RESEND_SECONDS,CONFIRM_TOKEN_TTL_SECONDS + + +def serialize_confirmation_request(email: str,expires_at) -> dict: + return { + "email": email, + "confirmation_sent": True, + "expires_at": expires_at.isoformat() if expires_at else None, + "expires_in": CONFIRM_TOKEN_TTL_SECONDS, + "resend_after": CONFIRM_TOKEN_RESEND_SECONDS, + } + + +def serialize_confirmation_result(user,*,already_confirmed: bool = False) -> dict: + """Deliberately narrow: this is an unauthenticated response, so no role or permissions.""" + return { + "email": user.email, + "is_active": user.is_active, + "already_confirmed": already_confirmed, + } diff --git a/backend/notifications/views.py b/backend/notifications/views.py new file mode 100644 index 0000000..8e6521a --- /dev/null +++ b/backend/notifications/views.py @@ -0,0 +1,94 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +import httpx + +from notifications.models import EmailConfirmationTokens +from notifications.plugins import ( + CONFIRM_TOKEN_RESEND_SECONDS, + CONFIRM_TOKEN_TTL_SECONDS, + build_confirmation_link, + compose_token, + confirmation_expiry, + generate_token_secret, + hash_token, + now_utc, + render_confirmation_email, + send_confirmation_mail, + split_token, + verify_token, +) +from notifications.serializers import serialize_confirmation_request,serialize_confirmation_result +from users.models import Users + + +class Confirmation: + def __init__(self,session:AsyncSession): + self.session=session + + async def send_confirmation(self,user): + """Issue a fresh token for an already committed Users row and mail the link.""" + await EmailConfirmationTokens.invalidate_tokens_for_user(self.session,str(user.id)) + + secret=generate_token_secret() + expires_at=confirmation_expiry() + row=await EmailConfirmationTokens.insert_token(self.session,{ + "user_id":user.id, + "email":user.email, + "token_hash":hash_token(secret), + "expires_at":expires_at, + }) + + link=build_confirmation_link(compose_token(row.id,secret)) + subject,html=render_confirmation_email(link,CONFIRM_TOKEN_TTL_SECONDS) + try: + await send_confirmation_mail(user.email,subject,html) + except (httpx.HTTPError,RuntimeError) as e: + await EmailConfirmationTokens.mark_used(self.session,str(row.id)) + raise HTTPException(status_code=502,detail="Failed to send confirmation email") from e + + return serialize_confirmation_request(user.email,expires_at) + + async def confirm(self,token): + record_id,secret=split_token(token) + if not record_id: + raise HTTPException(status_code=400,detail="Invalid confirmation link") + + row=await EmailConfirmationTokens.get_token_by_id(self.session,record_id) + if not row or not verify_token(secret,row.token_hash): + raise HTTPException(status_code=400,detail="Invalid confirmation link") + + user=await Users.get_user_by_id(self.session,str(row.user_id)) + if not user or user.is_deleted: + raise HTTPException(status_code=404,detail="User not found") + + # Mail clients, link scanners and the back button all replay this link. + if row.is_used: + if row.confirmed_at and user.is_active: + return serialize_confirmation_result(user,already_confirmed=True) + raise HTTPException(status_code=400,detail="This confirmation link is no longer valid. Request a new one.") + + if row.expires_at<=now_utc(): + raise HTTPException(status_code=400,detail="Confirmation link has expired. Request a new one.") + + if user.is_active: + await EmailConfirmationTokens.mark_confirmed(self.session,str(row.id)) + return serialize_confirmation_result(user,already_confirmed=True) + + updated=await Users.update_user(self.session,str(user.id),{"is_active":True}) + await EmailConfirmationTokens.mark_confirmed(self.session,str(row.id)) + return serialize_confirmation_result(updated) + + async def resend(self,email): + user=await Users.get_user_by_email(self.session,email) + if not user or user.is_deleted: + raise HTTPException(status_code=404,detail="No account found for this email") + if user.is_active: + raise HTTPException(status_code=400,detail="This account is already confirmed") + + active=await EmailConfirmationTokens.get_active_token_by_user(self.session,str(user.id)) + if active: + age=(now_utc()-active.created_at).total_seconds() + if agenow_utc(): + raise HTTPException(status_code=429,detail="Please wait before requesting another confirmation email") + + return await self.send_confirmation(user) diff --git a/backend/requirements.txt b/backend/requirements.txt index e3c9c13..bfb074d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -24,5 +24,5 @@ PyJWT==2.10.1 # access/refresh token encode+decode in users/plugins python-multipart==0.0.20 # required by OAuth2PasswordRequestForm in users/app.py # --- other ----------------------------------------------------------------- -httpx==0.28.1 # Graph email calls in inbox/views.py +httpx==0.28.1 # Graph email (inbox) + Teams mail send (forget_password/plugins.py) bcrypt==5.0.0 # password hashing in users/plugins.py diff --git a/backend/users/app.py b/backend/users/app.py index e6bb2e9..843c4de 100644 --- a/backend/users/app.py +++ b/backend/users/app.py @@ -22,6 +22,12 @@ class UserCreate(BaseModel): is_active: bool = True +class UserSignup(BaseModel): + name: str + email: EmailStr + password: str + + class UserUpdate(BaseModel): name: str | None = None email: EmailStr | None = None @@ -62,6 +68,19 @@ async def login(payload: UserLogin,session: AsyncSession = Depends(get_session)) raise HTTPException(status_code=500,detail=str(e)) +@router.post("/users/signup") +async def signup(payload: UserSignup,session: AsyncSession = Depends(get_session)): + try: + service=User(session=session) + user=await service.signup_user(payload.model_dump()) + tokens=serialize_token(create_access_token(user),create_refresh_token(user),user) + return JSONResponse(content={**tokens,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.post("/users/refresh") async def refresh(payload: TokenRefresh,session: AsyncSession = Depends(get_session)): try: diff --git a/backend/users/models.py b/backend/users/models.py index 6a52910..cacf22d 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select from role.models import Roles - +from job.job_post.models import JobPosts class Users(SQLModel, table=True): __tablename__ = "users" @@ -17,10 +17,17 @@ class Users(SQLModel, table=True): email: str = Field(unique=True) role_id: int | None = Field(nullable=True, foreign_key="roles.id") role: Roles | None = Relationship(back_populates="users") + # selectin, not joined: this is a one-to-many, so a joined load would repeat the + # user row once per post. Without an explicit strategy the default is a lazy load, + # which raises MissingGreenlet the moment anything touches it under asyncio. + job_posts: list[JobPosts] = Relationship( + back_populates="user", + sa_relationship_kwargs={"lazy": "selectin"}, + ) password: str created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) - is_active: bool = Field(default=True) + is_active: bool = Field(default=False) is_deleted: bool = Field(default=False) @classmethod diff --git a/backend/users/permissions.py b/backend/users/permissions.py index 7b1d7e0..a7c11b0 100644 --- a/backend/users/permissions.py +++ b/backend/users/permissions.py @@ -194,6 +194,7 @@ def require_permission(*required: PermissionTag, require_all: bool = True): async def dependency(current_user: CurrentUser) -> dict: if current_user.get("role_id") is None: raise HTTPException(status_code=403, detail="User has no role assigned") + granted = current_user.get("permissions") or [] if not has_permission(granted, *required, require_all=require_all): if require_all and len(required) == 1: diff --git a/backend/users/plugins.py b/backend/users/plugins.py index 74546df..68dc4d6 100644 --- a/backend/users/plugins.py +++ b/backend/users/plugins.py @@ -28,7 +28,9 @@ JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY") JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30")) REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7")) +RESET_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_RESET_TOKEN_EXPIRE_MINUTES", "10")) ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60 +RESET_TOKEN_EXPIRE_SECONDS = RESET_TOKEN_EXPIRE_MINUTES * 60 def _encode(raw: str) -> bytes: @@ -112,6 +114,15 @@ def create_refresh_token(user) -> str: ) +def create_reset_token(email: str, *, code_id: str) -> str: + return _create_token( + email, + token_type="reset", + expires_delta=timedelta(minutes=RESET_TOKEN_EXPIRE_MINUTES), + claims={"crid": code_id}, + ) + + def decode_token(token: str, *, expected_type: str) -> dict: payload = jwt.decode(token, _secret(), algorithms=[JWT_ALGORITHM]) if payload.get("type") != expected_type: diff --git a/backend/users/views.py b/backend/users/views.py index 101febd..bce5072 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -1,5 +1,6 @@ from fastapi import HTTPException -from role.models import Roles +from notifications.views import Confirmation +from role.models import EnumRoles,Roles from users.models import Users from users.permissions import PermissionTag,has_permission from users.serializers import serialize_user @@ -17,6 +18,8 @@ class User: async def _check_role_assignment(self,current_user,role_id,existing_role_id=None): if role_id==existing_role_id: return + + #"Take the current user's permission list. Check if rbac_users.manage is in it. If it is not → reject with 403." if not has_permission(current_user.get("permissions") or [],PermissionTag.RBAC_USERS_MANAGE): raise HTTPException(status_code=403,detail="Assigning a role requires rbac_users.manage") if role_id is None: @@ -42,6 +45,21 @@ class User: raise HTTPException(status_code=400,detail="Password is required") return await Users.insert_user(self.session,fields) + async def signup_user(self,payload): + existing=await Users.get_user_by_email(self.session,payload.get("email")) + if existing: + raise HTTPException(status_code=409,detail="Email already registered") + fields=clean_user_payload(payload) + if not fields.get("password"): + raise HTTPException(status_code=400,detail="Password is required") + role=await Roles.get_role_by_name(self.session,EnumRoles.CANDIDATE.value) + fields["role_id"]=role.id if role else 8 + user=await Users.insert_user(self.session,fields) + # Signup lands inactive; the mailed link is what flips is_active. + service=Confirmation(session=self.session) + await service.send_confirmation(user) + return user + async def get_users(self,top,skip,search=None): users=await Users.get_users(self.session,top,skip,search) return [serialize_user(u) for u in users] @@ -56,6 +74,7 @@ class User: user=await Users.get_user_by_id(self.session,record_id) if not user: raise HTTPException(status_code=404,detail="User not found") + # this is for password hashing and dehashing fields=clean_user_payload(payload,partial=True) email=fields.get("email") if email and email!=user.email: @@ -98,8 +117,10 @@ class User: detail="Incorrect email or password", headers={"WWW-Authenticate":"Bearer"}, ) - if user.is_deleted or not user.is_active: + if user.is_deleted: raise HTTPException(status_code=401,detail="User is inactive") + if not user.is_active: + raise HTTPException(status_code=401,detail="Please confirm your email address to activate your account") return await Users.get_user_by_id(self.session,user.id) async def refresh_access_token(self,refresh_token): diff --git a/devserver.py b/devserver.py deleted file mode 100644 index 14ba60b..0000000 --- a/devserver.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -"""Static dev server for the ATS dashboard. - -Identical to `python3 -m http.server` except it disables caching. The stdlib -server answers conditional requests from Last-Modified, which has one-second -granularity — so a file edited twice within the same second keeps serving the -stale copy and the browser never sees the change. -""" -import sys -from functools import partial -from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer - - -class NoCacheHandler(SimpleHTTPRequestHandler): - def end_headers(self): - self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") - self.send_header("Pragma", "no-cache") - self.send_header("Expires", "0") - super().end_headers() - - def send_header(self, keyword, value): - # Drop the validator entirely so conditional GETs can't 304. - if keyword.lower() == "last-modified": - return - super().send_header(keyword, value) - - def log_message(self, fmt, *args): - pass - - -if __name__ == "__main__": - port = int(sys.argv[1]) if len(sys.argv) > 1 else 4173 - directory = sys.argv[2] if len(sys.argv) > 2 else "." - handler = partial(NoCacheHandler, directory=directory) - print(f"Serving {directory} on http://localhost:{port} (no-cache)") - ThreadingHTTPServer(("127.0.0.1", port), handler).serve_forever() diff --git a/docs/integrations/buffer/Buffer-API.postman_collection.json b/docs/integrations/buffer/Buffer-API.postman_collection.json new file mode 100644 index 0000000..d0103c8 --- /dev/null +++ b/docs/integrations/buffer/Buffer-API.postman_collection.json @@ -0,0 +1,1976 @@ +{ + "info": { + "_postman_id": "b0ffe4a1-2c3d-4e5f-8a9b-0c1d2e3f4a5b", + "name": "Buffer API (GraphQL) — HRMS", + "description": "Buffer's public GraphQL API — every operation the HRMS job-posting integration needs, plus the full read surface.\n\n**One endpoint for everything:** `POST https://api.buffer.com`. There are no REST paths; the operation is decided by the GraphQL document in the body.\n\n---\n\n### Setup\n1. Import `Buffer-API.postman_environment.json` and paste your key from `backend/.env` (`BUFFER_API`) into `buffer_token`.\n2. Run **01 · Get Organizations** → fills `{{org_id}}`.\n3. Run **02 · Get Channels** → fills `{{channel_id}}`.\n4. Everything else now works. Create/list requests fill `{{post_id}}` for you, so **Delete Post** always targets the last post you touched.\n\n### The three answers you were after\n| Need | Request | Field |\n|---|---|---|\n| `org_id` | 01 · Get Organizations | `account.organizations[].id` |\n| `channel_id` | 02 · Get Channels | `channels[].id` |\n| create post | 04 · Create Post · … | `createPost` → `PostActionSuccess.post.id` |\n| delete post | 04 · Delete Post | `deletePost` → `DeletePostSuccess.id` |\n| list posts | 03 · Get Posts | `posts.edges[].node` |\n\n### Gotchas that cost real time\n* Errors come back as **HTTP 200**. Check `errors[]` and `__typename`, not the status code.\n* Do **not** request `totalCount` on `posts` — API keys get `FORBIDDEN`.\n* The edit mutation is `editPost`, not `updatePost`.\n* `deletePost` returns `DeletePostSuccess`, *not* `PostActionSuccess`.\n* `schedulingType` is `automatic` | `notification` only — it is **not** the queue mode. The queue mode is `mode` (`addToQueue` | `shareNext` | `shareNow` | `customScheduled`).\n* `mode: customScheduled` requires `dueAt`; `mode: shareNow` publishes instantly.\n* `metadata..linkAttachment` and a non-empty `assets` array are mutually exclusive.\n* Sorting is only by `dueAt` or `createdAt` — there is no `sentAt` sort key.\n\n### Rate limits\nFree plan: **100 requests / 15 min**, 250 / day, 3000 / 30 days. A full Collection Runner pass over this collection is ~38 calls, so back-to-back runs will trip the 15-minute window (HTTP 429, `RATE_LIMIT_EXCEEDED`, `extensions.window: \"15m\"`). Every response carries `ratelimit` / `ratelimit-policy` headers — see **08 · Rate limit headers**.\n\n### Plan-gated operations\nThese are valid GraphQL but rejected on a Free account: LinkedIn `firstComment`, `needsApproval: true` (needs a posting policy), and Insights windows older than 31 days.\n\nDocs: https://developers.buffer.com/guides · Explorer: https://developers.buffer.com/explorer.html", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{buffer_token}}", + "type": "string" + } + ] + }, + "item": [ + { + "name": "00 · Auth & Account", + "description": "Verify the API key works and inspect the authenticated account. The key is account-scoped: it can reach every organization and channel on the account.", + "item": [ + { + "name": "Ping / Whoami", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "pm.test('Token is valid', function () {", + " pm.expect(res.data.account.id).to.be.a('string');", + "});", + "console.log('Rate limit:', pm.response.headers.get('ratelimit'));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query Whoami {\\n account {\\n id\\n email\\n name\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Cheapest possible call. 200 + an account id means the token is valid.\n401 / `UNAUTHORIZED` in `errors[]` means the token is wrong or revoked." + }, + "response": [] + }, + { + "name": "Get Account (full)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetAccount {\\n account {\\n id\\n email\\n backupEmail\\n name\\n avatar\\n timezone\\n createdAt\\n organizations {\\n id\\n name\\n ownerEmail\\n channelCount\\n }\\n connectedApps {\\n clientId\\n name\\n category\\n scopes\\n createdAt\\n }\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Everything readable about the logged-in account in one call.\n\n`connectedApps[].clientId` is the OAuth **client id** — do not confuse it with `organizations[].id`." + }, + "response": [] + } + ] + }, + { + "name": "01 · Organizations → org_id", + "description": "**Run this first.** Almost every other query needs `organizationId`. The test script writes the first org id into the `org_id` collection variable automatically.", + "item": [ + { + "name": "Get Organizations (captures org_id)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const orgs = res.data.account.organizations;", + "pm.test('At least one organization', () => pm.expect(orgs).to.have.length.above(0));", + "pm.collectionVariables.set('org_id', orgs[0].id);", + "console.log('org_id =', orgs[0].id, '|', orgs[0].name);" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetOrganizations {\\n account {\\n organizations {\\n id\\n name\\n ownerEmail\\n channelCount\\n }\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "**This is the org_id endpoint.**\n\n`account.organizations[].id` is the `organizationId` every other call wants.\nThe test script stores `organizations[0].id` in `{{org_id}}`." + }, + "response": [] + }, + { + "name": "Get Organization Limits", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetOrganizationLimits {\\n account {\\n organizations {\\n id\\n name\\n channelCount\\n limits {\\n channels\\n members\\n scheduledPosts\\n ideas\\n tags\\n postTemplates\\n }\\n }\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Plan ceilings for the org (each field is the max, an `Int`) — compare `limits.channels` against `channelCount` before connecting another channel." + }, + "response": [] + } + ] + }, + { + "name": "02 · Channels → channel_id", + "description": "**Run `Get Channels` second.** `channel_id` is what `createPost` publishes to. The test script captures the first channel into `{{channel_id}}`.", + "item": [ + { + "name": "Get Channels (captures channel_id)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const chans = res.data.channels;", + "pm.test('At least one channel', () => pm.expect(chans).to.have.length.above(0));", + "pm.collectionVariables.set('channel_id', chans[0].id);", + "console.log('channel_id =', chans[0].id, '|', chans[0].service, '|', chans[0].name);", + "chans.forEach(c => console.log(` ${c.id} ${c.service.padEnd(14)} ${c.name}`));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetChannels($input: ChannelsInput!) {\\n channels(input: $input) {\\n id\\n name\\n displayName\\n service\\n type\\n serviceId\\n organizationId\\n avatar\\n externalLink\\n timezone\\n isDisconnected\\n isLocked\\n isQueuePaused\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"organizationId\": \"{{org_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "**This is the channel_id endpoint.**\n\nReturns every connected social profile in the organization. `id` → use as `channelId` in `createPost`. `service` is the network (`linkedin`, `twitter`, `instagram`, `facebook`, `tiktok`, `threads`, `youtube`, `pinterest`, `mastodon`, `bluesky`, `googlebusiness`, `startPage`).\n\nStore the id you actually want in `{{channel_id}}` — the script picks the first one." + }, + "response": [] + }, + { + "name": "Get Channels (filtered)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetFilteredChannels($input: ChannelsInput!) {\\n channels(input: $input) {\\n id\\n name\\n service\\n isLocked\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"filter\": {\n \"isLocked\": false,\n \"product\": \"publish\"\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`filter.isLocked` — true/false/omit. `filter.product` — `publish` | `analyze` | `engage` | `comments` | `startPage` | `buffer`." + }, + "response": [] + }, + { + "name": "Get Channel by ID", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetChannel($input: ChannelInput!) {\\n channel(input: $input) {\\n id\\n name\\n displayName\\n service\\n type\\n serviceId\\n organizationId\\n timezone\\n isDisconnected\\n isQueuePaused\\n allowedActions\\n scopes\\n postingSchedule {\\n day\\n times\\n paused\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{channel_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Single channel, including its weekly posting schedule (the slots `mode: addToQueue` will fill)." + }, + "response": [] + }, + { + "name": "Get Daily Posting Limits", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetDailyPostingLimits($input: DailyPostingLimitsInput!) {\\n dailyPostingLimits(input: $input) {\\n channelId\\n limit\\n scheduled\\n sent\\n isAtLimit\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelIds\": [\n \"{{channel_id}}\"\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Check before bulk-scheduling. `isAtLimit: true` means `createPost` will come back as `LimitReachedError`.\n\nOptional `input.date` (ISO 8601) checks a specific day." + }, + "response": [] + } + ] + }, + { + "name": "03 · Posts — Read", + "description": "Cursor-paginated. `first` = page size (20–50 recommended), `after` = `pageInfo.endCursor` from the previous page. Cursors are opaque — never parse them.\n\n⚠️ Do **not** add `totalCount` to the `posts` query — it returns `FORBIDDEN` on this API key.", + "item": [ + { + "name": "Get Posts (paginated, captures post_id + cursor)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const conn = res.data.posts;", + "if (conn.edges.length) {", + " pm.collectionVariables.set('post_id', conn.edges[0].node.id);", + " console.log('post_id =', conn.edges[0].node.id);", + "}", + "pm.collectionVariables.set('posts_cursor', conn.pageInfo.endCursor || '');", + "console.log('hasNextPage =', conn.pageInfo.hasNextPage);", + "conn.edges.forEach(e => console.log(` ${e.node.id} ${e.node.status.padEnd(14)} ${(e.node.text || '').slice(0, 60).replace(/\\n/g, ' ')}`));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetPosts($first: Int, $after: String, $input: PostsInput!) {\\n posts(first: $first, after: $after, input: $input) {\\n edges {\\n cursor\\n node {\\n id\\n text\\n status\\n shareMode\\n schedulingType\\n dueAt\\n sentAt\\n createdAt\\n updatedAt\\n channelId\\n channelService\\n externalLink\\n isCustomScheduled\\n via\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n startCursor\\n hasPreviousPage\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 20,\n \"input\": {\n \"organizationId\": \"{{org_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "**This is the list-posts endpoint.**\n\nStores `edges[0].node.id` in `{{post_id}}` and `pageInfo.endCursor` in `{{posts_cursor}}` so *Get Posts — Next Page* and *Delete Post* just work." + }, + "response": [] + }, + { + "name": "Get Posts — Next Page", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "pm.collectionVariables.set('posts_cursor', res.data.posts.pageInfo.endCursor || '');" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetPostsPage($first: Int, $after: String, $input: PostsInput!) {\\n posts(first: $first, after: $after, input: $input) {\\n edges {\\n cursor\\n node {\\n id\\n text\\n status\\n dueAt\\n channelId\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 20,\n \"after\": \"{{posts_cursor}}\",\n \"input\": {\n \"organizationId\": \"{{org_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Run *Get Posts* first to populate `{{posts_cursor}}`. Re-run this request repeatedly — it rolls the cursor forward each time." + }, + "response": [] + }, + { + "name": "Get Scheduled Posts (the queue)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const edges = res.data.posts.edges;", + "const queued = edges.filter(e => !e.node.isCustomScheduled);", + "if (queued.length) {", + " pm.collectionVariables.set('queued_post_id', queued[0].node.id);", + " console.log('queued_post_id =', queued[0].node.id);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetScheduledPosts($first: Int, $input: PostsInput!) {\\n posts(first: $first, input: $input) {\\n edges {\\n node {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n isCustomScheduled\\n channelId\\n channelService\\n allowedActions\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 50,\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"filter\": {\n \"status\": [\n \"scheduled\"\n ],\n \"channelIds\": [\n \"{{channel_id}}\"\n ]\n },\n \"sort\": [\n {\n \"field\": \"dueAt\",\n \"direction\": \"asc\"\n }\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Everything waiting to go out, soonest first. `allowedActions` tells you whether `deletePost` / `editPost` is permitted on each one.\n\n`sort.field` (`PostSortableKey`) is only `dueAt` or `createdAt`; `direction` is `asc` or `desc`.\n\nCaptures the first queued post into `{{queued_post_id}}` for **Move Post in Queue**." + }, + "response": [] + }, + { + "name": "Get Sent Posts", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const edges = res.data.posts.edges;", + "if (edges.length) {", + " pm.collectionVariables.set('sent_post_id', edges[0].node.id);", + " console.log('sent_post_id =', edges[0].node.id);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetSentPosts($first: Int, $input: PostsInput!) {\\n posts(first: $first, input: $input) {\\n edges {\\n node {\\n id\\n text\\n sentAt\\n externalLink\\n channelService\\n metricsUpdatedAt\\n metrics {\\n name\\n type\\n unit\\n value\\n }\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 25,\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"filter\": {\n \"status\": [\n \"sent\"\n ],\n \"channelIds\": [\n \"{{channel_id}}\"\n ]\n },\n \"sort\": [\n {\n \"field\": \"dueAt\",\n \"direction\": \"desc\"\n }\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Published posts with their live engagement metrics and the permalink (`externalLink`) on the network. `metrics` is null until the post is sent.\n\nCaptures the newest sent post into `{{sent_post_id}}` for the **06 · Analytics** folder." + }, + "response": [] + }, + { + "name": "Get Drafts", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetDrafts($first: Int, $input: PostsInput!) {\\n posts(first: $first, input: $input) {\\n edges {\\n node {\\n id\\n text\\n status\\n createdAt\\n channelId\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 25,\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"filter\": {\n \"status\": [\n \"draft\",\n \"needs_approval\"\n ]\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`PostStatus` values: `draft`, `needs_approval`, `scheduled`, `sending`, `sent`, `error`." + }, + "response": [] + }, + { + "name": "Get Failed Posts", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetFailedPosts($first: Int, $input: PostsInput!) {\\n posts(first: $first, input: $input) {\\n edges {\\n node {\\n id\\n text\\n status\\n dueAt\\n channelId\\n error {\\n message\\n }\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 25,\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"filter\": {\n \"status\": [\n \"error\"\n ]\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Posts the network rejected. `error.message` carries the reason (expired token, media rejected, duplicate content …)." + }, + "response": [] + }, + { + "name": "Get Posts by Date Range", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.collectionVariables.set('range_end', new Date().toISOString());", + "pm.collectionVariables.set('range_start', new Date(Date.now() - 30 * 864e5).toISOString());" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetPostsByDate($first: Int, $input: PostsInput!) {\\n posts(first: $first, input: $input) {\\n edges {\\n node {\\n id\\n text\\n status\\n dueAt\\n sentAt\\n createdAt\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 50,\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"filter\": {\n \"startDate\": \"{{range_start}}\",\n \"endDate\": \"{{range_end}}\"\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`startDate`/`endDate` match on `createdAt` **or** `dueAt`. The pre-request script sets a rolling 30-day window.\n\nFiner control: `dueAt` / `createdAt` accept a `DateTimeComparator` (`{ start, end }`), and `dueAtPresence` (`present` | `absent`) filters on whether a schedule exists at all. `absent` cannot be combined with a `dueAt` comparator." + }, + "response": [] + }, + { + "name": "Get Post by ID", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetPost($input: PostInput!) {\\n post(input: $input) {\\n id\\n text\\n status\\n shareMode\\n schedulingType\\n dueAt\\n sentAt\\n createdAt\\n updatedAt\\n channelId\\n channelService\\n externalLink\\n isCustomScheduled\\n sharedNow\\n via\\n allowedActions\\n assets {\\n id\\n type\\n mimeType\\n source\\n thumbnail\\n }\\n tags {\\n id\\n name\\n }\\n author {\\n id\\n name\\n }\\n error {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{post_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Full single post. `allowedActions` includes `deletePost` / `updatePost` when those mutations will be accepted." + }, + "response": [] + } + ] + }, + { + "name": "04 · Posts — Create / Edit / Delete", + "description": "Every create/edit response is a **union**. Always select `__typename` plus `... on PostActionSuccess` and `... on MutationError` — an HTTP 200 with `__typename: \"InvalidInputError\"` is still a failure.\n\n`ShareMode`: `addToQueue` · `shareNext` · `shareNow` · `customScheduled`.\n`SchedulingType`: `automatic` (Buffer publishes) · `notification` (Buffer reminds you).\n\nEach create request stores the new id in `{{post_id}}`, so **Delete Post** at the bottom of this folder cleans up whatever you just made.", + "item": [ + { + "name": "Create Post · Add to Queue", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('queued_post_id', out.post.id);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Posted from the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"addToQueue\",\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Drops the post into the next free slot of the channel's posting schedule. Buffer picks `dueAt` for you.\n\nThis is the mode the HRMS job-post flow uses by default.\n\nAlso stores the new id in `{{queued_post_id}}` so **Move Post in Queue** has a genuinely queued post to act on." + }, + "response": [] + }, + { + "name": "Create Post · Draft (safe to test with)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Draft from the Buffer API collection — not published.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"addToQueue\",\n \"saveToDraft\": true,\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`saveToDraft: true` creates the post with `status: draft`. Nothing is published and daily posting limits are not consumed.\n\n**Use this one when smoke-testing** — then run *Delete Post* to remove it." + }, + "response": [] + }, + { + "name": "Create Post · Custom Scheduled", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.collectionVariables.set('due_at', new Date(Date.now() + 864e5).toISOString());" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Scheduled from the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"customScheduled\",\n \"dueAt\": \"{{due_at}}\",\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`mode: customScheduled` **requires** `dueAt` as an ISO 8601 UTC timestamp (`2026-08-06T09:00:00.000Z`). The pre-request script sets `{{due_at}}` to 24 hours from now." + }, + "response": [] + }, + { + "name": "Create Post · Share Next (top of queue)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Jumping the queue, via the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"shareNext\",\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Takes the *next* available slot, pushing everything else down." + }, + "response": [] + }, + { + "name": "⚠️ Create Post · Share Now (PUBLISHES IMMEDIATELY)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Published immediately from the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"shareNow\",\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "**This goes live on the real social account the moment you hit Send.** There is no undo — `deletePost` removes it from Buffer but does not always retract it from the network.\n\nUse *Create Post · Draft* for testing instead." + }, + "response": [] + }, + { + "name": "Create Post · Needs Approval", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Submitted for approval from the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"addToQueue\",\n \"needsApproval\": true,\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`needsApproval: true` parks the post at `status: needs_approval` instead of scheduling it.\n\n⚠️ Only accepted when the channel's posting policy actually requires approval (Buffer → Settings → posting policy, paid plans). Otherwise you get `InvalidInputError: needsApproval is only valid when your posting policy on this channel requires approval`." + }, + "response": [] + }, + { + "name": "Create Post · With Image", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"Image post from the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"addToQueue\",\n \"saveToDraft\": true,\n \"assets\": [\n {\n \"image\": {\n \"url\": \"https://picsum.photos/1200/630.jpg\",\n \"thumbnailUrl\": \"https://picsum.photos/1200/630.jpg\"\n }\n }\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`assets` is an **ordered** list. Each entry is exactly one of `image` / `video` / `document` / `link`.\n\n* `image` → `{ url!, thumbnailUrl, metadata }`\n* `video` → `{ url!, thumbnailUrl, metadata }`\n* `document` → `{ url!, title!, thumbnailUrl! }`\n* `link` → `{ url!, title, description, thumbnailUrl }`\n\nURLs must be publicly reachable **and return the raw bytes** — Buffer fetches them server-side, so a page that redirects to a login or a CDN that blocks server-side fetches fails with `InvalidInputError: Image could not be read from its URL`. See the *Hosting Media* guide for Buffer's own upload endpoint.\n\nSet to `saveToDraft: true` here so you can run it safely." + }, + "response": [] + }, + { + "name": "Create Post · LinkedIn (first comment + link attachment)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"LinkedIn post from the Buffer API collection.\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"addToQueue\",\n \"saveToDraft\": true,\n \"assets\": [],\n \"metadata\": {\n \"linkedin\": {\n \"linkAttachment\": {\n \"url\": \"https://example.com/careers\"\n }\n }\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`metadata` is keyed by network: `linkedin`, `twitter`, `instagram`, `facebook`, `tiktok`, `threads`, `youtube`, `pinterest`, `mastodon`, `bluesky`, `google`.\n\nLinkedIn accepts `firstComment`, `linkAttachment` (`{ url }` only — no title/description override), and `annotations` (@-mentions).\n\n⚠️ `firstComment` is a **paid-plan feature** — on Free it comes back as `InvalidInputError: LinkedIn first comment requires a paid plan`. It is left out of the body below; add it back once the account is upgraded:\n```json\n\"linkedin\": { \"firstComment\": \"Full JD in the comments 👇\" }\n```\n\n⚠️ `metadata..linkAttachment` and a non-empty `assets` array are **mutually exclusive** — sending both is an `InvalidInputError`." + }, + "response": [] + }, + { + "name": "Edit Post", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "pm.test('editPost succeeded', function () {", + " pm.expect(res.data.editPost.__typename, res.data.editPost.message || '')", + " .to.eql('PostActionSuccess');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation EditPost($input: EditPostInput!) {\\n editPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n dueAt\\n updatedAt\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{post_id}}\",\n \"text\": \"Edited via the Buffer API collection.\",\n \"schedulingType\": \"automatic\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "The mutation is `editPost` (not `updatePost`). `id` and `schedulingType` are required; every other field is optional and **omitting a field preserves its current value**.\n\nChange the schedule by sending `mode: \"customScheduled\"` together with a new `dueAt`." + }, + "response": [] + }, + { + "name": "Move Post in Queue", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "pm.test('movePostInQueue succeeded', function () {", + " pm.expect(res.data.movePostInQueue.__typename,", + " res.data.movePostInQueue.message || '').to.eql('PostActionSuccess');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation MovePostInQueue($input: MovePostInQueueInput!) {\\n movePostInQueue(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n dueAt\\n shareMode\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{queued_post_id}}\",\n \"position\": \"top\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`position` is `top` or `bottom`.\n\n⚠️ Only works on posts whose `shareMode` is `addToQueue`/`shareNext`. A draft or a `customScheduled` post gives `VoidMutationError: Only queued posts can be moved within the queue` — hence the separate `{{queued_post_id}}` variable, filled by *Get Scheduled Posts* or *Create Post · Add to Queue*." + }, + "response": [] + }, + { + "name": "Delete Post", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.deletePost;", + "pm.test('deletePost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('DeletePostSuccess');", + "});", + "if (out.__typename === 'DeletePostSuccess') {", + " console.log('deleted', out.id);", + " pm.collectionVariables.set('post_id', '');", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation DeletePost($input: DeletePostInput!) {\\n deletePost(input: $input) {\\n __typename\\n ... on DeletePostSuccess {\\n id\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{post_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "**This is the delete endpoint.**\n\nTakes only the post id. The payload union is `DeletePostSuccess { id }` | `VoidMutationError { message }` — note it is *not* `PostActionSuccess`.\n\nDeleting a `sent` post removes it from Buffer; it does not necessarily retract it from the social network. Check `allowedActions` on the post for `deletePost` first." + }, + "response": [] + } + ] + }, + { + "name": "05 · Ideas", + "description": "Ideas live on the **organization**, not a channel — drafts that are not yet committed to a network.", + "item": [ + { + "name": "Get Ideas", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const edges = res.data.ideas.edges;", + "if (edges.length) pm.collectionVariables.set('idea_id', edges[0].node.id);" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetIdeas($first: Int, $after: String, $input: IdeasInput!) {\\n ideas(first: $first, after: $after, input: $input) {\\n edges {\\n cursor\\n node {\\n id\\n createdAt\\n content {\\n title\\n text\\n services\\n }\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\",\n \"variables\": {\n \"first\": 20,\n \"input\": {\n \"organizationId\": \"{{org_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Cursor-paginated like posts. Optional `groupFilter` and `tagsFilter`." + }, + "response": [] + }, + { + "name": "Create Idea", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreateIdea($input: CreateIdeaInput!) {\\n createIdea(input: $input) {\\n __typename\\n ... on IdeaResponse {\\n refreshIdeas\\n idea {\\n id\\n organizationId\\n createdAt\\n content {\\n title\\n text\\n services\\n }\\n }\\n }\\n ... on Idea {\\n id\\n content {\\n title\\n text\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"content\": {\n \"title\": \"Idea from the Buffer API collection\",\n \"text\": \"Draft copy that is not tied to a channel yet.\",\n \"services\": [\n \"linkedin\"\n ]\n }\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`content` accepts `title`, `text`, `services`, `media`, `tags`, `date`, `aiAssisted`.\n\nThe payload union is `IdeaResponse` | `Idea` | `InvalidInputError` | `UnauthorizedError` | `LimitReachedError` | `UnexpectedError` — this API returns `IdeaResponse`.\n\n⚠️ There is no `deleteIdea` mutation, so anything you create here has to be removed from the Buffer UI." + }, + "response": [] + } + ] + }, + { + "name": "06 · Analytics", + "description": "Metrics only exist for `sent` posts. On the Free plan, Insights history is capped at the **last 31 days** — a wider window returns `BAD_USER_INPUT`.", + "item": [ + { + "name": "Get Post Metrics", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetPostMetrics($input: PostInput!) {\\n post(input: $input) {\\n id\\n sentAt\\n externalLink\\n metricsUpdatedAt\\n metrics {\\n name\\n description\\n type\\n unit\\n value\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{sent_post_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Run **03 · Get Sent Posts** first — it fills `{{sent_post_id}}`. (Pointing this at `{{post_id}}` right after a delete gives `BAD_USER_INPUT: Invalid PostId format`, because the variable is empty.)\n\n`metrics` is `null` until the post is sent. `type` is one of `impressions`, `reach`, `reactions`, `likes`, `comments`, `shares`, `reposts`, `quotes`, `clicks`, `saves`, `follows`, `views`, `viewers`, `totalTimeWatched`, `engagementRate`, `postCount`. `unit` is `count` or `percentage`." + }, + "response": [] + }, + { + "name": "Get Aggregated Post Metrics (last 30 days)", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.collectionVariables.set('metrics_end', new Date().toISOString());", + "pm.collectionVariables.set('metrics_start', new Date(Date.now() - 30 * 864e5).toISOString());" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetAggregatedPostMetrics($input: AggregatedPostMetricsInput!) {\\n aggregatedPostMetrics(input: $input) {\\n metricsUpdatedAt\\n metrics {\\n name\\n type\\n unit\\n value\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"organizationId\": \"{{org_id}}\",\n \"channelIds\": [\n \"{{channel_id}}\"\n ],\n \"startDateTime\": \"{{metrics_start}}\",\n \"endDateTime\": \"{{metrics_end}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Totals across every sent post in the window. The pre-request script sets a 30-day range to stay inside the Free-plan 31-day cap." + }, + "response": [] + } + ] + }, + { + "name": "07 · HRMS job-post flow", + "description": "The exact calls `backend/job/job_post/plugins.py` makes, so you can reproduce a backend failure directly against Buffer.\n\n`.env` mapping: `BUFFER_API` → `{{buffer_token}}`, `BUFFER_API_URL` → `{{buffer_api_url}}`, `BUFFER_CHANNEL_ID` → `{{channel_id}}`.", + "item": [ + { + "name": "1. list_buffer_channels — organizations", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "pm.collectionVariables.set('org_id', res.data.account.organizations[0].id);" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query { account { organizations { id name } } }\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "First half of `list_buffer_channels()` — mirrors the literal query string in `plugins.py`." + }, + "response": [] + }, + { + "name": "2. list_buffer_channels — channels per org", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query GetChannels {\\n channels(input: { organizationId: \\\"{{org_id}}\\\" }) {\\n id\\n name\\n displayName\\n service\\n isQueuePaused\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Second half of `list_buffer_channels()`, exposed by the backend at `GET /job/buffer/channels`. Note this one inlines the org id rather than using GraphQL variables — same as the Python." + }, + "response": [] + }, + { + "name": "3. create_buffer_post — rendered job ad", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "const out = res.data.createPost;", + "pm.test('createPost succeeded', function () {", + " pm.expect(out.__typename, out.message || '').to.eql('PostActionSuccess');", + "});", + "if (out.__typename === 'PostActionSuccess') {", + " pm.collectionVariables.set('post_id', out.post.id);", + " console.log('post_id =', out.post.id, '| status =', out.post.status);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation CreatePost($input: CreatePostInput!) {\\n createPost(input: $input) {\\n __typename\\n ... on PostActionSuccess {\\n post {\\n id\\n text\\n status\\n shareMode\\n dueAt\\n createdAt\\n channelId\\n channelService\\n }\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"channelId\": \"{{channel_id}}\",\n \"text\": \"We're hiring: AI Engineer\\n\\nKarachi · Full-time\\n\\nExperience: 2–3 years\\n\\nRequirements:\\n• AWS\\n• FastAPI\\n• LangChain\\n\\nNice to have:\\n• Azure\\n\\nSalary: Anonymous\\n\\nInterested? Apply via our careers page or reply to this post.\\n\\n#AWS #FastAPI #LangChain\",\n \"schedulingType\": \"automatic\",\n \"mode\": \"addToQueue\",\n \"saveToDraft\": true,\n \"assets\": []\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "What `POST /job/post-job` ends up sending, using the output of `render_job_post()`. The backend supports `mode` of `addToQueue`, `shareNow`, or `customScheduled` (which then requires `due_at`).\n\nLinkedIn caps post text at 3000 characters — `render_job_post()` truncates to that.\n\n`saveToDraft: true` is added here so running it does not queue a real job ad; the backend does not send it." + }, + "response": [] + }, + { + "name": "4. clean up — delete the post created above", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"mutation DeletePost($input: DeletePostInput!) {\\n deletePost(input: $input) {\\n __typename\\n ... on DeletePostSuccess {\\n id\\n }\\n ... on MutationError {\\n message\\n }\\n }\\n}\",\n \"variables\": {\n \"input\": {\n \"id\": \"{{post_id}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Removes whatever step 3 created." + }, + "response": [] + } + ] + }, + { + "name": "08 · Error shapes (reference)", + "description": "Run these to see each failure mode. Buffer returns **HTTP 200** for almost everything — you must inspect the body.\n\n* Non-recoverable → top-level `errors[]` with `extensions.code`: `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `BAD_USER_INPUT`, `GRAPHQL_VALIDATION_FAILED`, `UNEXPECTED`, `RATE_LIMIT_EXCEEDED`.\n* Recoverable → `data..__typename` is a member of the error union (`InvalidInputError`, `LimitReachedError`, `NotFoundError`, `UnauthorizedError`, `RestProxyError`, `UnexpectedError`).", + "item": [ + { + "name": "FORBIDDEN — totalCount on posts", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "console.log(JSON.stringify(res.errors, null, 2));", + "pm.test('Returns a GraphQL error (expected)', function () {", + " pm.expect(res.errors).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query {\\n posts(first: 1, input: { organizationId: \\\"{{org_id}}\\\" }) {\\n totalCount\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "`totalCount` is in the schema but rejected for API-key auth. This is the most common cause of a `posts` query failing after copy-pasting from the schema reference — leave it out." + }, + "response": [] + }, + { + "name": "NOT_FOUND — bad post id", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "console.log(JSON.stringify(res.errors, null, 2));", + "pm.test('Returns a GraphQL error (expected)', function () {", + " pm.expect(res.errors).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query {\\n post(input: { id: \\\"000000000000000000000000\\\" }) {\\n id\\n }\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Expect `errors[0].extensions.code === 'NOT_FOUND'`." + }, + "response": [] + }, + { + "name": "Rate limit headers", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const res = pm.response.json();", + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('No GraphQL errors', function () {", + " pm.expect(res.errors, JSON.stringify(res.errors)).to.be.undefined;", + "});", + "console.log('ratelimit :', pm.response.headers.get('ratelimit'));", + "console.log('ratelimit-policy:', pm.response.headers.get('ratelimit-policy'));" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"query { account { id } }\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{buffer_api_url}}", + "host": [ + "{{buffer_api_url}}" + ] + }, + "description": "Every response carries three rolling windows. Free plan: 100 / 15 min, 250 / day, 3000 / 30 days. `r` = remaining, `t` = seconds to reset. Exceeding one gives HTTP 429 + `Retry-After`." + }, + "response": [] + } + ] + } + ], + "variable": [ + { + "key": "buffer_api_url", + "value": "https://api.buffer.com", + "type": "string" + }, + { + "key": "buffer_token", + "value": "", + "type": "string" + }, + { + "key": "org_id", + "value": "", + "type": "string" + }, + { + "key": "channel_id", + "value": "", + "type": "string" + }, + { + "key": "post_id", + "value": "", + "type": "string" + }, + { + "key": "sent_post_id", + "value": "", + "type": "string" + }, + { + "key": "queued_post_id", + "value": "", + "type": "string" + }, + { + "key": "idea_id", + "value": "", + "type": "string" + }, + { + "key": "posts_cursor", + "value": "", + "type": "string" + }, + { + "key": "due_at", + "value": "", + "type": "string" + }, + { + "key": "range_start", + "value": "", + "type": "string" + }, + { + "key": "range_end", + "value": "", + "type": "string" + }, + { + "key": "metrics_start", + "value": "", + "type": "string" + }, + { + "key": "metrics_end", + "value": "", + "type": "string" + } + ] +} diff --git a/docs/integrations/buffer/Buffer-API.postman_environment.json b/docs/integrations/buffer/Buffer-API.postman_environment.json new file mode 100644 index 0000000..e851cc8 --- /dev/null +++ b/docs/integrations/buffer/Buffer-API.postman_environment.json @@ -0,0 +1,61 @@ +{ + "id": "e0ffe4a1-2c3d-4e5f-8a9b-0c1d2e3f4a5b", + "name": "Buffer API (fill in your key)", + "values": [ + { + "key": "buffer_api_url", + "value": "https://api.buffer.com", + "type": "default", + "enabled": true + }, + { + "key": "buffer_token", + "value": "", + "type": "secret", + "enabled": true + }, + { + "key": "org_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "channel_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "post_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "sent_post_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "queued_post_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "idea_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "posts_cursor", + "value": "", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment" +} diff --git a/docs/integrations/buffer/README.md b/docs/integrations/buffer/README.md new file mode 100644 index 0000000..bfb552e --- /dev/null +++ b/docs/integrations/buffer/README.md @@ -0,0 +1,185 @@ +# Buffer API — working collection + +Buffer's public API is **GraphQL, one endpoint, POST only**: + +``` +POST https://api.buffer.com +Authorization: Bearer +Content-Type: application/json +``` + +There are no REST paths. The operation is decided entirely by the GraphQL document in the +body. Docs: · Explorer: + +## Files + +| File | What it is | +|---|---| +| `Buffer-API.postman_collection.json` | 38 requests in 9 folders. Import into Postman/Insomnia/Bruno. | +| `Buffer-API.postman_environment.json` | Empty environment template — safe to commit. | +| `Buffer-API.postman_environment.local.json` | Same, pre-filled with the key + ids from `backend/.env`. **Gitignored — do not commit.** | + +## Setup + +1. Import the collection **and** `Buffer-API.postman_environment.local.json`, then select + that environment. (Or import the plain template and paste `BUFFER_API` from + `backend/.env` into `buffer_token`.) +2. Run **01 · Get Organizations** → fills `{{org_id}}`. +3. Run **02 · Get Channels** → fills `{{channel_id}}`. + +Everything else works from there. Test scripts chain the ids for you: + +| Variable | Filled by | Used by | +|---|---|---| +| `org_id` | 01 · Get Organizations | almost everything | +| `channel_id` | 02 · Get Channels | all create requests | +| `post_id` | 03 · Get Posts, every create request | Get Post by ID, Edit Post, **Delete Post** | +| `sent_post_id` | 03 · Get Sent Posts | 06 · Get Post Metrics | +| `queued_post_id` | 03 · Get Scheduled Posts, 04 · Add to Queue | 04 · Move Post in Queue | +| `posts_cursor` | 03 · Get Posts | 03 · Get Posts — Next Page | + +So **Delete Post** always targets the last post you touched. + +## The endpoints you asked for + +| Need | Folder / request | Where the value is | +|---|---|---| +| **org_id** | 01 · Get Organizations | `data.account.organizations[].id` | +| **channel_id** | 02 · Get Channels | `data.channels[].id` | +| **create a post** | 04 · Create Post · … | `data.createPost` → `PostActionSuccess.post.id` | +| **delete a post** | 04 · Delete Post | `data.deletePost` → `DeletePostSuccess.id` | +| **list posts** | 03 · Get Posts | `data.posts.edges[].node` | +| **one post** | 03 · Get Post by ID | `data.post` | +| **edit a post** | 04 · Edit Post | `editPost` (not `updatePost`) | + +### org_id + +```bash +curl -s -X POST https://api.buffer.com \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $BUFFER_API" \ + -d '{"query":"query { account { id email organizations { id name channelCount } } }"}' +``` + +### channel_id + +```bash +curl -s -X POST https://api.buffer.com \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $BUFFER_API" \ + -d '{"query":"query GetChannels($input: ChannelsInput!) { channels(input: $input) { id name service type isDisconnected isQueuePaused } }", + "variables":{"input":{"organizationId":"'"$BUFFER_ORG_ID"'"}}}' +``` + +### create post + +```bash +curl -s -X POST https://api.buffer.com \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $BUFFER_API" \ + -d '{"query":"mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { __typename ... on PostActionSuccess { post { id status dueAt } } ... on MutationError { message } } }", + "variables":{"input":{"channelId":"'"$BUFFER_CHANNEL_ID"'","text":"Hello","schedulingType":"automatic","mode":"addToQueue","assets":[]}}}' +``` + +### delete post + +```bash +curl -s -X POST https://api.buffer.com \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $BUFFER_API" \ + -d '{"query":"mutation DeletePost($input: DeletePostInput!) { deletePost(input: $input) { __typename ... on DeletePostSuccess { id } ... on MutationError { message } } }", + "variables":{"input":{"id":"POST_ID"}}}' +``` + +### list posts + +```bash +curl -s -X POST https://api.buffer.com \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $BUFFER_API" \ + -d '{"query":"query GetPosts($first: Int, $after: String, $input: PostsInput!) { posts(first: $first, after: $after, input: $input) { edges { cursor node { id text status dueAt sentAt channelId externalLink } } pageInfo { hasNextPage endCursor } } }", + "variables":{"first":20,"input":{"organizationId":"'"$BUFFER_ORG_ID"'","filter":{"status":["scheduled"]}}}}' +``` + +## `.env` mapping + +| `.env` key | Collection variable | Notes | +|---|---|---| +| `BUFFER_API` | `buffer_token` | The personal access token. Buffer → Settings → API. | +| `BUFFER_API_URL` | `buffer_api_url` | `https://api.buffer.com` — correct as-is. | +| `BUFFER_CHANNEL_ID` | `channel_id` | Currently the LinkedIn profile `ahmedmujtababaig`. | +| — | `org_id` | **Not in `.env`.** `CLIENT_ID` in `backend/.env` holds this value, but it is the *organization id*, not an OAuth client id — the naming is misleading. Consider renaming it to `BUFFER_ORG_ID`. | + +`plugins.py` re-derives the org id on every `list_buffer_channels()` call, so nothing is +broken today; caching it in `BUFFER_ORG_ID` would save one round trip per request. + +## Enums worth memorising + +| Enum | Values | +|---|---| +| `ShareMode` (`mode`) | `addToQueue` · `shareNext` · `shareNow` · `customScheduled` | +| `SchedulingType` | `automatic` (Buffer publishes) · `notification` (Buffer reminds you) | +| `PostStatus` | `draft` · `needs_approval` · `scheduled` · `sending` · `sent` · `error` | +| `PostSortableKey` | `dueAt` · `createdAt` **only** | +| `SortDirection` | `asc` · `desc` | +| `QueuePosition` | `top` · `bottom` | +| `Service` | `linkedin` `twitter` `facebook` `instagram` `tiktok` `threads` `youtube` `pinterest` `mastodon` `bluesky` `googlebusiness` `startPage` | +| `PostMetricType` | `impressions` `reach` `reactions` `likes` `comments` `shares` `reposts` `quotes` `clicks` `saves` `follows` `views` `viewers` `totalTimeWatched` `engagementRate` `postCount` | + +## Gotchas that cost real time + +- **Errors come back as HTTP 200.** Check `errors[]` and `__typename`, not the status code. +- **Do not request `totalCount` on `posts`** — API-key auth gets `FORBIDDEN` and the whole + query returns `data: null`. +- The edit mutation is **`editPost`**, not `updatePost`. +- `deletePost` returns **`DeletePostSuccess`**, not `PostActionSuccess`. A blanket + `... on PostActionSuccess` fragment silently matches nothing. +- `schedulingType` is *not* the queue mode. `automatic` vs `notification` only. The queue + mode is `mode`. +- `mode: customScheduled` requires `dueAt` (ISO 8601 UTC). `mode: shareNow` publishes + immediately with no undo. +- `assets` URLs are fetched **server-side** — they must return raw bytes, not an HTML page. +- `metadata..linkAttachment` and a non-empty `assets` array are mutually exclusive. +- LinkedIn `linkAttachment` only accepts `{ url }`; there is no title/description override. +- There is **no `deleteIdea` mutation** — ideas created via the API must be removed in the UI. +- `movePostInQueue` only accepts posts whose `shareMode` is `addToQueue`/`shareNext`. Drafts + and `customScheduled` posts give + `VoidMutationError: Only queued posts can be moved within the queue`. + +## Free-plan limits hit while testing this + +- **100 requests / 15 min**, 250 / day, 3000 / 30 days. A full Collection Runner pass is + ~38 calls, so two back-to-back runs trip the 15-minute window + (HTTP 429, `RATE_LIMIT_EXCEEDED`, `extensions.window: "15m"`, plus `Retry-After`). + Every response carries `ratelimit` / `ratelimit-policy` headers. +- **Insights are capped at the last 31 days.** A wider `aggregatedPostMetrics` window + returns `BAD_USER_INPUT`. +- **LinkedIn `firstComment` is paid-only** — `InvalidInputError` on Free. +- **`needsApproval: true`** is rejected unless the channel has an approval posting policy. +- Daily posting limit on the connected channel is 50/day (`dailyPostingLimits`). + +## Error codes + +`extensions.code` on top-level `errors[]`: `UNAUTHORIZED` · `FORBIDDEN` · `NOT_FOUND` · +`BAD_USER_INPUT` · `GRAPHQL_VALIDATION_FAILED` · `RATE_LIMIT_EXCEEDED` · `UNEXPECTED`. + +Mutation union error members: `InvalidInputError` · `LimitReachedError` · `NotFoundError` · +`UnauthorizedError` · `RestProxyError` · `UnexpectedError` — all implement the +`MutationError` interface, so `... on MutationError { message }` catches every one, +including ones Buffer adds later. + +## Verification + +Every request in the collection was executed against the live API on 2026-08-05 using the +key in `backend/.env`: **38/38 pass.** + +Two of those (**Share Now**, **Create Idea**) were validated document-only — sent with a +deliberately invalid id so the server still parses and validates the GraphQL but cannot +execute it — because one publishes to the real LinkedIn account and the other creates +something the API has no mutation to delete. **Create Post · Needs Approval** returns +`InvalidInputError` on this account: the query is correct, the channel just has no approval +policy. + +Every post created during verification was deleted; the account is back to the same three +posts it had beforehand, and the pre-existing scheduled job ad still holds its original +`dueAt` slot. diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..de07be0 --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,3 @@ +# Use 127.0.0.1, not localhost. On this machine localhost prefers ::1 and hits a +# different listener (WSL/Docker on :8000) instead of the Windows uvicorn on 127.0.0.1. +VITE_API_BASE=http://127.0.0.1:8000 diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..54a8a13 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,7 @@ +# The API origin for a production build. +# +# This previously pointed at http://localhost:8000, which meant a production +# bundle called the *user's own machine*. Empty means same-origin requests, which +# works behind a reverse proxy that fronts both the bundle and the API. Set the +# real API origin here if the two are served from different hosts. +VITE_API_BASE= diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f425b9e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + + TalentFlow · Applicant Tracking System + + + + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..04a38bd --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2987 @@ +{ + "name": "hr-ats-portal", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hr-ats-portal", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query-devtools": "^5.101.4", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.5.0", + "esbuild": "^0.28.1", + "jsdom": "^30.0.1", + "vite": "^6.3.5" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.4.tgz", + "integrity": "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.4.tgz", + "integrity": "sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.101.4", + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.400", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.400.tgz", + "integrity": "sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.52", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.52.tgz", + "integrity": "sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..11b6fd5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "hr-ats-portal", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:lan": "vite --host", + "build": "vite build", + "preview": "vite preview", + "smoke": "node smoke.test.mjs", + "test:token": "node token.test.mjs", + "verify": "vite build && node smoke.test.mjs && node token.test.mjs" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query-devtools": "^5.101.4", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.5.0", + "esbuild": "^0.28.1", + "jsdom": "^30.0.1", + "vite": "^6.3.5" + } +} diff --git a/frontend/smoke.test.mjs b/frontend/smoke.test.mjs new file mode 100644 index 0000000..ffd1f32 --- /dev/null +++ b/frontend/smoke.test.mjs @@ -0,0 +1,131 @@ +/** + * Render smoke test — mounts all 27 routes (23 app + 4 auth) into jsdom and + * fails on any thrown error, console.error, or empty render. + * + * npm run smoke + * + * Bundled with esbuild (not Vite's SSR loader) because several dependencies + * ship CJS and esbuild's interop handles that cleanly. The render itself lives + * in src/__smoke__/entry.jsx so it exercises the real component tree. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +import esbuild from 'esbuild' +import { JSDOM } from 'jsdom' + +// ---------------------------------------------------------------- environment +const dom = new JSDOM('
', { + url: 'http://localhost:5173/', + pretendToBeVisual: true, +}) + +globalThis.window = dom.window +globalThis.document = dom.window.document +// Node 24 defines `navigator` as a getter-only global. +Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true }) +globalThis.HTMLElement = dom.window.HTMLElement +globalThis.Element = dom.window.Element +globalThis.Node = dom.window.Node +globalThis.getComputedStyle = dom.window.getComputedStyle +globalThis.localStorage = dom.window.localStorage +globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0) +globalThis.cancelAnimationFrame = clearTimeout +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +class RO { observe() {} unobserve() {} disconnect() {} } +class MO { observe() {} disconnect() {} takeRecords() { return [] } } +globalThis.ResizeObserver = RO +globalThis.MutationObserver = MO +dom.window.ResizeObserver = RO +dom.window.MutationObserver = MO +dom.window.matchMedia = () => ({ + matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, +}) +// jsdom has no canvas backend; the retained chart engine only needs a context object. +dom.window.HTMLCanvasElement.prototype.getContext = () => + new Proxy({}, { get: (_, k) => (k === 'canvas' ? {} : () => {}) }) + +// A signed-in session holding all 104 permissions, so no route is gated away. +const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments', + 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users'] +const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure'] +const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`)) + +dom.window.localStorage.setItem('tf-auth', JSON.stringify({ + access_token: 'test', refresh_token: 'test', expires_in: 1800, + expires_at: Date.now() + 1800_000, + data: { id: 1, name: 'Test User', email: 't@example.com', role_name: 'system_administrator', permissions }, +})) + +// The real-API screens must not hit the network here. +globalThis.fetch = async () => ({ + ok: true, status: 200, statusText: 'OK', + text: async () => JSON.stringify({ data: [], status_code: 200 }), +}) + +// ---------------------------------------------------------------- bundle +const outDir = mkdtempSync(join(tmpdir(), 'tf-smoke-')) +const outFile = join(outDir, 'entry.mjs') + +await esbuild.build({ + entryPoints: ['src/__smoke__/entry.jsx'], + outfile: outFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + jsx: 'automatic', + loader: { '.js': 'jsx', '.jsx': 'jsx' }, + logLevel: 'error', + define: { + 'process.env.NODE_ENV': '"development"', + 'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }), + }, +}) + +// ---------------------------------------------------------------- run +const errors = [] +const origError = console.error +console.error = (...args) => { + const msg = args.map((a) => (a instanceof Error ? a.stack : String(a))).join(' ') + if (msg.includes('React Router Future Flag')) return // advisory, not a defect + errors.push(msg) +} + +let failed = 0 +try { + const mod = await import(pathToFileURL(outFile).href) + mod.boot() + + for (const path of mod.ALL_ROUTES) { + errors.length = 0 + const container = dom.window.document.createElement('div') + dom.window.document.body.appendChild(container) + try { + const text = (await mod.renderRoute(path, container)).trim() + if (errors.length) { + console.log(`FAIL ${path}\n ${errors[0].split('\n').slice(0, 3).join(' | ').slice(0, 260)}`) + failed++ + } else if (text.length < 5) { + console.log(`FAIL ${path} (rendered empty)`) + failed++ + } else { + console.log(`ok ${path} (${text.length} chars)`) + } + } catch (err) { + console.log(`FAIL ${path}\n ${String(err.message).split('\n')[0].slice(0, 260)}`) + failed++ + } finally { + container.remove() + } + } +} finally { + console.error = origError + rmSync(outDir, { recursive: true, force: true }) +} + +console.log(failed ? `\n${failed}/27 routes FAILED` : `\nAll 27 routes rendered clean`) +process.exit(failed ? 1 : 0) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..bd7eb2e --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,91 @@ +import { lazy } from 'react' +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' + +import AuthProvider from './auth/AuthProvider' +import RequireAuth from './auth/RequireAuth' +import AppLayout from './app/AppLayout' +import LegacyHashRedirect from './app/LegacyHashRedirect' +import { ROUTES } from './app/routes' + +import Login from './pages/Login' +import Signup from './pages/Signup' +import ForgotPassword from './pages/ForgotPassword' +import ConfirmEmail from './pages/ConfirmEmail' + +// Route-level code splitting: putting 23 screens in one bundle would make the +// first paint pay for every screen a user never opens. +const SCREENS = { + dashboard: lazy(() => import('./screens/Dashboard')), + inbox: lazy(() => import('./screens/Inbox')), + jobs: lazy(() => import('./screens/Jobs')), + candidates: lazy(() => import('./screens/Candidates')), + talentpool: lazy(() => import('./screens/TalentPool')), + pipeline: lazy(() => import('./screens/Pipeline')), + import: lazy(() => import('./screens/CvImport')), + jobboard: lazy(() => import('./screens/JobBoard')), + recruiterhub: lazy(() => import('./screens/RecruiterHub')), + tasks: lazy(() => import('./screens/Tasks')), + aiassistant: lazy(() => import('./screens/AiAssistant')), + interviews: lazy(() => import('./screens/Interviews')), + assessments: lazy(() => import('./screens/Assessments')), + offers: lazy(() => import('./screens/Offers')), + managers: lazy(() => import('./screens/Managers')), + calendar: lazy(() => import('./screens/Calendar')), + reports: lazy(() => import('./screens/Reports')), + analytics: lazy(() => import('./screens/Analytics')), + aistudio: lazy(() => import('./screens/AiStudio')), + notifications: lazy(() => import('./screens/Notifications')), + rbac: lazy(() => import('./screens/Rbac')), + settings: lazy(() => import('./screens/Settings')), + help: lazy(() => import('./screens/Help')), +} + +export default function App() { + return ( + + + + + {/* Public. These keep the /auth prefix as ROUTE paths so the backend's + CONFIRM_EMAIL_PATH=/auth/confirm-email links resolve unchanged. */} + } /> + } /> + } /> + } /> + } /> + + {/* Protected */} + + + + } + > + {ROUTES.map((r) => { + const Screen = SCREENS[r.path] + return ( + + + + ) : ( + + ) + } + /> + ) + })} + + + } /> + } /> + + + + ) +} diff --git a/frontend/src/__smoke__/entry.jsx b/frontend/src/__smoke__/entry.jsx new file mode 100644 index 0000000..d5f4914 --- /dev/null +++ b/frontend/src/__smoke__/entry.jsx @@ -0,0 +1,113 @@ +/* Test-only entry. Kept inside src/ so every import resolves through Vite's + module graph exactly as it does in the app — one React instance, one router + instance, one query client. Not shipped: excluded from the build because + nothing in the app imports it. */ + +import React from 'react' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom' +import { QueryClientProvider } from '@tanstack/react-query' + +import { queryClient } from '../lib/queryClient' +import { initializeCache } from '../data/seedQueries' +import ThemeProvider, { initTheme } from '../theme/ThemeProvider' +import ToastProvider from '../ui/Toast' +import AuthProvider from '../auth/AuthProvider' +import AppLayout from '../app/AppLayout' +import RequireAuth from '../auth/RequireAuth' +import { ROUTES as TABLE } from '../app/routes' + +import Login from '../pages/Login' +import Signup from '../pages/Signup' +import ForgotPassword from '../pages/ForgotPassword' +import ConfirmEmail from '../pages/ConfirmEmail' + +import Dashboard from '../screens/Dashboard' +import Inbox from '../screens/Inbox' +import Jobs from '../screens/Jobs' +import Candidates from '../screens/Candidates' +import TalentPool from '../screens/TalentPool' +import Pipeline from '../screens/Pipeline' +import CvImport from '../screens/CvImport' +import JobBoard from '../screens/JobBoard' +import RecruiterHub from '../screens/RecruiterHub' +import Tasks from '../screens/Tasks' +import AiAssistant from '../screens/AiAssistant' +import Interviews from '../screens/Interviews' +import Assessments from '../screens/Assessments' +import Offers from '../screens/Offers' +import Managers from '../screens/Managers' +import Calendar from '../screens/Calendar' +import Reports from '../screens/Reports' +import Analytics from '../screens/Analytics' +import AiStudio from '../screens/AiStudio' +import Notifications from '../screens/Notifications' +import Rbac from '../screens/Rbac' +import Settings from '../screens/Settings' +import Help from '../screens/Help' + +const SCREENS = { + dashboard: Dashboard, inbox: Inbox, jobs: Jobs, candidates: Candidates, + talentpool: TalentPool, pipeline: Pipeline, import: CvImport, jobboard: JobBoard, + recruiterhub: RecruiterHub, tasks: Tasks, aiassistant: AiAssistant, + interviews: Interviews, assessments: Assessments, offers: Offers, + managers: Managers, calendar: Calendar, reports: Reports, analytics: Analytics, + aistudio: AiStudio, notifications: Notifications, rbac: Rbac, + settings: Settings, help: Help, +} + +const PAGES = { + '/auth/login': Login, + '/auth/signup': Signup, + '/auth/forgot-password': ForgotPassword, + '/auth/confirm-email': ConfirmEmail, +} + +export const ALL_ROUTES = [ + ...Object.keys(PAGES), + ...TABLE.map((r) => `/${r.path}`), +] + +export function boot() { + initTheme() + initializeCache(queryClient) +} + +/** Mount one route, wait for effects to settle, return its rendered text. */ +export async function renderRoute(path, container) { + const h = React.createElement + const isAuth = path.startsWith('/auth/') + const def = TABLE.find((r) => `/${r.path}` === path) + const Screen = isAuth ? PAGES[path] : SCREENS[def.path] + + const inner = isAuth + ? h(Route, { path, element: h(Screen) }) + : h( + Route, + { element: h(RequireAuth, null, h(AppLayout)) }, + h(Route, { path, element: h(Screen) }), + ) + + const tree = h( + QueryClientProvider, { client: queryClient }, + h(ThemeProvider, null, + h(ToastProvider, null, + h(MemoryRouter, { initialEntries: [path] }, + h(AuthProvider, null, + h(Routes, null, inner, h(Route, { path: '*', element: h(Navigate, { to: path, replace: true }) })), + ), + ), + ), + ), + ) + + const root = createRoot(container) + try { + await act(async () => { root.render(tree) }) + await act(async () => { await new Promise((r) => setTimeout(r, 40)) }) + return container.textContent || '' + } finally { + await act(async () => { root.unmount() }) + } +} diff --git a/frontend/src/__smoke__/token.entry.js b/frontend/src/__smoke__/token.entry.js new file mode 100644 index 0000000..ab56614 --- /dev/null +++ b/frontend/src/__smoke__/token.entry.js @@ -0,0 +1,7 @@ +/* Test-only entry exposing the token layer to the token test harness. */ +export { request, setSessionExpiredHandler } from '../lib/apiClient' +export { refreshSession } from '../lib/refresh' +export { + setSession, getSession, clearSession, getAccessToken, getRefreshToken, isExpiring, +} from '../lib/tokenStore' +export { ApiError, SessionExpiredError } from '../lib/errors' diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..1418c66 --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,46 @@ +/* Public auth endpoints. All are unauthenticated (`auth: false`) — attaching a + stale bearer to a login request would be harmless but misleading in the + network log, and the reset flow must never trigger a refresh. */ +import { request } from '../lib/apiClient' + +const pub = { auth: false } + +export function login(email, password) { + return request('/users/login', { ...pub, method: 'POST', body: { email, password } }) +} + +export function signup(name, email, password) { + return request('/users/signup', { ...pub, method: 'POST', body: { name, email, password } }) +} + +export function confirmEmail(token) { + return request('/users/confirm-email', { ...pub, method: 'POST', body: { token } }) +} + +export function resendConfirmEmail(email) { + return request('/users/confirm-email/resend', { ...pub, method: 'POST', body: { email } }) +} + +export function forgetPassword(email) { + return request('/users/forget-password', { ...pub, method: 'POST', body: { email } }) +} + +export function verifyForgetCode(email, code) { + return request('/users/forget-password/verify-code', { + ...pub, + method: 'POST', + body: { email, code }, + }) +} + +export function setNewPassword(password, resetToken) { + // `token` is the 10-minute reset JWT, carried in its own Authorization header + // and checked by forget_password/permissions.py's HTTPBearer. Passing `token` + // explicitly also suppresses the refresh path, which is what we want: a reset + // token is not a session. + return request('/users/forget-password/new-password', { + method: 'POST', + body: { password }, + token: resetToken, + }) +} diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js new file mode 100644 index 0000000..8ad8e13 --- /dev/null +++ b/frontend/src/api/inbox.js @@ -0,0 +1,17 @@ +import { request } from '../lib/apiClient' + +/** + * Persisted mailbox messages. + * + * NOTE: this endpoint currently has no auth dependency server-side + * (backend/inbox/app.py). We send the bearer anyway, so adding + * Depends(get_current_user) later is a zero-diff change on this side. + */ +export function listMessages() { + return request('/inbox/fetch') +} + +/** Triggers the Graph proxy to pull new mail and persist it. */ +export function syncMailbox({ token, top, skip } = {}) { + return request('/email/fetch', { params: { token, top, skip } }) +} diff --git a/frontend/src/api/roles.js b/frontend/src/api/roles.js new file mode 100644 index 0000000..e3d17db --- /dev/null +++ b/frontend/src/api/roles.js @@ -0,0 +1,31 @@ +import { request } from '../lib/apiClient' + +/** Roles with their expanded `bundles` and resolved `effective_permissions`. */ +export function listRoles() { + return request('/roles/fetch') +} +export function createRole(body) { + return request('/roles/create', { method: 'POST', body }) +} +export function updateRole(recordId, body) { + return request('/roles/update', { method: 'PUT', params: { record_id: recordId }, body }) +} +export function deleteRole(recordId) { + return request('/roles/delete', { method: 'DELETE', params: { record_id: recordId } }) +} + +/** Permission bundles (41 seeded), each resolving to a set of tag names. */ +export function listPermissions() { + return request('/permissions/fetch') +} +export function createPermission(body) { + return request('/permissions/create', { method: 'POST', body }) +} +export function updatePermission(recordId, body) { + return request('/permissions/update', { method: 'PUT', params: { record_id: recordId }, body }) +} + +/** The 104-tag catalog: 13 modules x 8 actions. */ +export function listPermissionTags() { + return request('/permission-tags/fetch') +} diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js new file mode 100644 index 0000000..54f9589 --- /dev/null +++ b/frontend/src/api/users.js @@ -0,0 +1,34 @@ +import { request } from '../lib/apiClient' + +/** The ONLY endpoint that returns `permissions`. Login and refresh do not. */ +export function me() { + return request('/users/me') +} + +export function list({ record_id, search, top, skip } = {}) { + return request('/users/fetch', { params: { record_id, search, top, skip } }) +} + +export function create(body) { + return request('/users/create', { method: 'POST', body }) +} + +export function update(recordId, body) { + return request('/users/update', { method: 'PUT', params: { record_id: recordId }, body }) +} + +export function assignRole(recordId, roleId) { + return request('/users/assign-role', { + method: 'PUT', + params: { record_id: recordId }, + body: { role_id: roleId }, + }) +} + +export function removeRole(recordId) { + return request('/users/remove-role', { method: 'PUT', params: { record_id: recordId } }) +} + +export function remove(recordId) { + return request('/users/delete', { method: 'DELETE', params: { record_id: recordId } }) +} diff --git a/frontend/src/app/AiDock.jsx b/frontend/src/app/AiDock.jsx new file mode 100644 index 0000000..76acdda --- /dev/null +++ b/frontend/src/app/AiDock.jsx @@ -0,0 +1,41 @@ +import { useNavigate } from 'react-router-dom' +import Chat from './ai/Chat' +import Icon from '../ui/icons' + +/** The slide-over dock — the same chat as the full page, in compact mode. */ +export default function AiDock({ open, onClose }) { + const navigate = useNavigate() + + return ( +
+
+ {open && ( + <> +
+
+

AI Assistant

+
+
+ + +
+
+
+ +
+ + )} +
+
+ ) +} diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx new file mode 100644 index 0000000..de62040 --- /dev/null +++ b/frontend/src/app/AppLayout.jsx @@ -0,0 +1,75 @@ +import { Suspense, useCallback, useEffect, useRef, useState } from 'react' +import { Outlet, useLocation } from 'react-router-dom' + +import Sidebar from './Sidebar' +import Topbar from './Topbar' +import AiDock from './AiDock' +import { ROUTE_BY_PATH } from './routes' +import { useBadges, useHotkeys, useNavOpen, useRouteMeta, useSidebarCollapsed } from './useShell' +import Icon from '../ui/icons' +import Spinner from '../components/Spinner' + +export default function AppLayout() { + const location = useLocation() + const contentRef = useRef(null) + + const [navOpen, setNavOpen] = useNavOpen() + const [collapsed, toggleCollapsed] = useSidebarCollapsed() + const [dockOpen, setDockOpen] = useState(false) + const badges = useBadges() + + const routeKey = location.pathname.split('/')[1] || 'dashboard' + const route = ROUTE_BY_PATH[routeKey] + useRouteMeta(route) + + const onEscape = useCallback(() => { + setNavOpen(false) + setDockOpen(false) + }, [setNavOpen]) + const searchRef = useHotkeys({ onEscape }) + + // Navigating closes the drawer and the dock, and resets scroll — the three + // things Router.render did at the end of every route change (js/app.js:44-51). + useEffect(() => { + setNavOpen(false) + setDockOpen(false) + if (contentRef.current) contentRef.current.scrollTop = 0 + }, [location.pathname, setNavOpen]) + + return ( +
+ + +
+ setNavOpen((o) => !o)} searchRef={searchRef} /> +
+
}> + + + +
+ + + + setDockOpen(false)} /> + +
setNavOpen(false)} + aria-hidden="true" + /> +
+ ) +} diff --git a/frontend/src/app/GlobalSearch.jsx b/frontend/src/app/GlobalSearch.jsx new file mode 100644 index 0000000..686a071 --- /dev/null +++ b/frontend/src/app/GlobalSearch.jsx @@ -0,0 +1,100 @@ +/* Global search — App.search from js/app.js:171-191. Same sources and the same + 4/4/3 caps. The inline onclick="App.searchGo(...)" strings become navigate() + calls, and the setTimeout(cb, 120) hack that waited for the old router to + swap innerHTML is gone: the target screen reads `state.open` instead. */ + +import { useMemo, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { seedQuery } from '../data/seedQueries' +import { Avatar, Icon } from '../ui/primitives' + +export default function GlobalSearch({ inputRef }) { + const navigate = useNavigate() + const [q, setQ] = useState('') + const [open, setOpen] = useState(false) + const boxRef = useRef(null) + + const { data: jobs = [] } = useQuery(seedQuery('jobs')) + const { data: candidates = [] } = useQuery(seedQuery('candidates')) + const { data: managers = [] } = useQuery(seedQuery('managers')) + + const results = useMemo(() => { + const term = q.trim().toLowerCase() + if (!term) return null + return { + jobs: jobs.filter((j) => (j.title + j.id + j.department).toLowerCase().includes(term)).slice(0, 4), + candidates: candidates.filter((c) => (c.name + c.email + c.jobTitle).toLowerCase().includes(term)).slice(0, 4), + managers: managers.filter((m) => m.name.toLowerCase().includes(term)).slice(0, 3), + } + }, [q, jobs, candidates, managers]) + + function go(path, state) { + setQ('') + setOpen(false) + navigate(path, { state }) + } + + const empty = results && !results.jobs.length && !results.candidates.length && !results.managers.length + + return ( +
e.stopPropagation()}> + + { + setQ(e.target.value) + setOpen(Boolean(e.target.value.trim())) + }} + onFocus={() => setOpen(Boolean(q.trim()))} + /> +
+ {results && ( + <> + {results.jobs.length > 0 &&
Jobs
} + {results.jobs.map((j) => ( +
go('/jobs', { openJob: j.id })}> + + + +
+
{j.title}
+
{j.id} · {j.department}
+
+
+ ))} + + {results.candidates.length > 0 &&
Candidates
} + {results.candidates.map((c) => ( +
go('/candidates', { openCandidate: c.id })}> + +
+
{c.name}
+
{c.jobTitle}
+
+
+ ))} + + {results.managers.length > 0 &&
Hiring Managers
} + {results.managers.map((m) => ( +
go('/managers', { openManager: m.id })}> + +
+
{m.name}
+
{m.title}
+
+
+ ))} + + {empty &&
No results for “{q}”
} + + )} +
+ ⌘K +
+ ) +} diff --git a/frontend/src/app/LegacyHashRedirect.jsx b/frontend/src/app/LegacyHashRedirect.jsx new file mode 100644 index 0000000..5f585a8 --- /dev/null +++ b/frontend/src/app/LegacyHashRedirect.jsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { ROUTE_BY_PATH } from './routes' + +/** + * The prototype addressed screens by `location.hash` (#dashboard, #candidates). + * Anyone with a bookmark — and the old post-login redirect target + * `/index.html#dashboard` — must not dead-end after the cutover. + * + * Runs once. StrictMode's double-invoke is harmless because the navigation is + * `replace` and the second run finds no hash left to act on. + */ +export default function LegacyHashRedirect() { + const navigate = useNavigate() + + useEffect(() => { + const route = (window.location.hash || '').slice(1) + if (route && ROUTE_BY_PATH[route]) { + navigate(`/${route}`, { replace: true }) + } + }, [navigate]) + + return null +} diff --git a/frontend/src/app/Sidebar.jsx b/frontend/src/app/Sidebar.jsx new file mode 100644 index 0000000..fbbcea7 --- /dev/null +++ b/frontend/src/app/Sidebar.jsx @@ -0,0 +1,83 @@ +import { NavLink } from 'react-router-dom' +import { NAV_GROUPS, ROUTES } from './routes' +import { useAuth } from '../auth/AuthContext' +import Icon from '../ui/icons' +import BrandMark from '../components/BrandMark' + +export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badges }) { + const { can } = useAuth() + + // A group heading renders only if something under it survived the permission + // filter — otherwise a low-privilege user sees orphaned section labels. + const visible = ROUTES.filter((r) => can(r.permission)) + + return ( + + ) +} diff --git a/frontend/src/app/Topbar.jsx b/frontend/src/app/Topbar.jsx new file mode 100644 index 0000000..e87f9df --- /dev/null +++ b/frontend/src/app/Topbar.jsx @@ -0,0 +1,150 @@ +import { Link } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' + +import Dropdown, { DropdownGroup } from '../ui/Dropdown' +import GlobalSearch from './GlobalSearch' +import { Avatar, Icon } from '../ui/primitives' +import { seedQuery, useSeedMutation } from '../data/seedQueries' +import { useToast } from '../ui/Toast' +import { useTheme } from '../theme/ThemeProvider' +import { useAuth } from '../auth/AuthContext' + +function initialsFromName(name) { + const parts = String(name || '').trim().split(/\s+/).filter(Boolean) + if (!parts.length) return '?' + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase() + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() +} + +export default function Topbar({ onOpenNav, searchRef }) { + const { theme, toggleTheme } = useTheme() + const { user, signOut } = useAuth() + const { toast } = useToast() + + const { data: notifications = [] } = useQuery(seedQuery('notifications')) + const { data: messages = [] } = useQuery(seedQuery('messages')) + const updateNotifications = useSeedMutation('notifications') + + // App.hydrateProfile's DOM sweep is gone — the session is read directly. + const name = user?.name || 'Guest' + const email = user?.email || '' + const role = user?.role_name || user?.role || 'Member' + + function markAllRead() { + updateNotifications((ns) => ns.map((n) => ({ ...n, unread: false }))) + toast('All notifications marked as read', 'success') + } + + return ( +
+ + + + +
+ + + + ( + + )} + > +
Messages
+
+ {messages.map((m) => ( +
+ +
+
{m.name}
+
{m.text}
+
{m.time} ago
+
+
+ ))} +
+
+ Open inbox +
+
+ + ( + + )} + > +
+ Notifications + +
+
+ {notifications.slice(0, 6).map((n) => ( +
+ +
+
{n.title}
+
{n.text}
+
{n.time}
+
+
+ ))} +
+
+ View all +
+
+ +
+ + ( + + )} + > +
+ {initialsFromName(name)} +
+
{name}
+
{email}
+
+
+
+ My Profile + Settings + Help Center +
+ + + +
+
+ ) +} diff --git a/frontend/src/app/ai/Chat.jsx b/frontend/src/app/ai/Chat.jsx new file mode 100644 index 0000000..5ffb6ad --- /dev/null +++ b/frontend/src/app/ai/Chat.jsx @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' + +import Icon from '../../ui/icons' +import { reply } from './replies' +import { seedQuery } from '../../data/seedQueries' +import { aiPrompts } from '../../data/seed' + +/** Shared by the full AI Assistant page and the slide-over dock. */ +export default function Chat({ compact = false, resetKey = 0 }) { + const [messages, setMessages] = useState([]) + const [value, setValue] = useState('') + const inputRef = useRef(null) + const scrollRef = useRef(null) + const timers = useRef([]) + + const { data: candidates = [] } = useQuery(seedQuery('candidates')) + const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) + + useEffect(() => setMessages([]), [resetKey]) + + useEffect(() => { + const list = timers.current + return () => list.forEach(clearTimeout) + }, []) + + useEffect(() => { + if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight + }, [messages]) + + const send = useCallback( + (text) => { + const body = (text ?? value).trim() + if (!body) return + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}` + setMessages((m) => [...m, { id: `u-${id}`, role: 'you', text: body }, { id: `a-${id}`, role: 'ai', typing: true }]) + setValue('') + if (inputRef.current) inputRef.current.style.height = 'auto' + + // The prototype's artificial 850-1350ms latency, kept so the typing + // indicator is visible rather than flashing. + const t = setTimeout(() => { + setMessages((m) => + m.map((msg) => + msg.id === `a-${id}` + ? { ...msg, typing: false, node: reply(body, { candidates, recruiters }) } + : msg, + ), + ) + }, 850 + Math.random() * 500) + timers.current.push(t) + }, + [value, candidates, recruiters], + ) + + const started = messages.length > 0 + + return ( +
+
+ {!started ? ( + <> +
+
+

AI Recruiter Assistant

+

Ask anything about your candidates, jobs, and pipeline

+
+
+ {aiPrompts.slice(0, compact ? 6 : 12).map((p) => ( + + ))} +
+ + ) : ( + messages.map((m) => ( +
+
+ +
+
+
{m.role === 'you' ? 'You' : 'AI Assistant'}
+ {m.typing ? ( +
+ ) : ( +
{m.text ?? m.node}
+ )} +
+
+ )) + )} +
+ +
+
+ - -
-

UI preview · responses are simulated. ${UI.icon('lock')} API-ready for backend integration.

-
-
`; -}; - -AI._bindChat = function () { - const input = document.getElementById('chatInput'); - const send = document.getElementById('chatSend'); - if (!input) return; - input.oninput = () => { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 140) + 'px'; }; - input.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); AI.send(); } }; - send.onclick = AI.send; - AI._started = false; -}; - -AI.usePrompt = function (btn, prompt) { - const input = document.getElementById('chatInput'); - input.value = prompt; - AI.send(); -}; - -AI.send = function () { - const input = document.getElementById('chatInput'); - const scroll = document.getElementById('chatScroll'); - const text = input.value.trim(); - if (!text) return; - - if (!AI._started) { scroll.innerHTML = ''; AI._started = true; } - - // user message - scroll.insertAdjacentHTML('beforeend', ` -
${UI.icon('users')}
-
You
${text.replace(/
`); - input.value = ''; input.style.height = 'auto'; - scroll.scrollTop = scroll.scrollHeight; - - // typing indicator - const typingId = 'typing_' + Math.random().toString(36).slice(2, 7); - scroll.insertAdjacentHTML('beforeend', ` -
${UI.icon('sparkles')}
-
AI Assistant
`); - scroll.scrollTop = scroll.scrollHeight; - - setTimeout(() => { - const t = document.getElementById(typingId); - if (t) t.querySelector('.chat-bubble').innerHTML = `
AI Assistant
${AI._reply(text)}
`; - scroll.scrollTop = scroll.scrollHeight; - }, 850 + Math.random() * 500); -}; - -// ---------------- Full page ---------------- -Views.aiassistant = function () { - const html = ` -
-
-

AI Assistant

Your recruiting copilot — powered by AI (interface preview)

-
- Model endpoint · Not connected - -
-
-
${AI._chatHtml(false)}
-
`; - return { html, onMount() { AI._bindChat(); } }; -}; -AI.newChat = function () { - const mount = document.getElementById('aiChatMount'); - if (mount) { mount.innerHTML = AI._chatHtml(false); AI._bindChat(); } - else { AI._dockOpen(true); } -}; - -// ---------------- Floating dock ---------------- -AI._dockOpen = function (force) { - const dock = document.getElementById('aiDock'); - const inner = document.getElementById('aiDockInner'); - const willOpen = force || !dock.classList.contains('open'); - if (willOpen) { - inner.innerHTML = ` -

${UI.icon('sparkles')} AI Assistant

-
- -
-
${AI._chatHtml(true)}
`; - dock.classList.add('open'); - AI._bindChat(); - } else { AI._dockClose(); } -}; -AI._dockClose = function () { document.getElementById('aiDock').classList.remove('open'); }; - -// ---------------- AI Studio (future modules) ---------------- -Views.aistudio = function () { - const cards = DB.aiModules.map(m => ` -
-
-
- ${UI.icon(m.icon)} - ${UI.badge(m.status, m.status === 'Beta' ? 'b-indigo' : 'b-gray')} -
-
${m.name}
-
${m.desc}
-
${m.status === 'Beta' ? 'Try it' : 'Join waitlist'} ${UI.icon('arrow-right')}
-
-
`).join(''); - - const html = ` -
-
-

AI Studio

Next-generation AI modules — designed and API-ready for backend integration

-
${DB.aiModules.filter(m => m.status === 'Beta').length} in Beta -
-
-
-
- -

Everything is API-ready

-

Each module below ships with a complete, production-grade interface. Connect your model endpoint to activate them — no UI work required.

- -
-
-
${cards}
-
`; - return { html }; -}; -AI.moduleDetail = function (name) { - const m = DB.aiModules.find(x => x.name === name); - UI.modal({ - title: m.name, subtitle: m.status + ' · AI Module', - body: `
${UI.icon(m.icon)} -
${m.name}
${m.desc}
-
-
API Contract (preview)
-
POST /api/ai/${m.name.toLowerCase().replace(/ /g, '-')} -{ - "context": { "jobId": "JOB-1001", "candidateIds": [...] }, - "options": { "model": "claude-opus", "stream": true } -} - -→ 200 OK -{ - "result": { ... }, - "usage": { "tokens": 1240 } -}
-
-

${UI.icon('lock')} This feature's UI is complete. Backend wiring is the only remaining step.

`, - footer: ` - `, - size: 'modal-lg' - }); -}; diff --git a/js/analytics.js b/js/analytics.js deleted file mode 100644 index 6af8a81..0000000 --- a/js/analytics.js +++ /dev/null @@ -1,114 +0,0 @@ -/* ============================================================ - analytics.js — Analytics dashboard (many charts) - ============================================================ */ -window.Views = window.Views || {}; - -Views.analytics = function () { - const a = DB.analytics; - - const html = ` -
-
-

Analytics

Deep-dive metrics across your recruitment funnel

-
-
WeekMonthQuarter
- -
-
- -
-
-

Hiring Trend

Hires vs applications
-
- ${Charts.legend([{ label: 'Applications', color: Charts.PALETTE[4] }, { label: 'Hires', color: Charts.PALETTE[0] }])}
-
-
-

Applications Received

Monthly volume
-
-
-
- -
-
-

Source Breakdown

-
-
-
-
-
-
-

Offer Acceptance

-
-
-
- Accepted - Pending - Declined -
-
-
-
-

Pipeline Distribution

-
-
-
- -
-
-

Applications by Department

Volume per team
-
-
-
-

Recruiter Performance

Hires by recruiter (top 8)
-
-
-
- -
-
-

Time to Hire

Days, monthly average
-
-
-
-

Time to Fill

Days, monthly average
-
-
-
-
`; - - return { - html, - onMount() { - Charts.line(document.getElementById('anTrend'), { - labels: a.hiringTrend.labels, area: true, - datasets: [ - { label: 'Applications', data: a.hiringTrend.applications, color: Charts.PALETTE[4] }, - { label: 'Hires', data: a.hiringTrend.hires, color: Charts.PALETTE[0] } - ] - }); - Charts.bar(document.getElementById('anApps'), { labels: a.hiringTrend.labels, data: a.hiringTrend.applications }); - Charts.doughnut(document.getElementById('anSource'), { - labels: a.sources.map(s => s.source), data: a.sources.map(s => s.count), - centerValue: DB.candidates.length, centerLabel: 'Total' - }); - document.getElementById('anSourceLegend').innerHTML = a.sources.map((s, i) => - `${s.source}`).join(''); - Charts.doughnut(document.getElementById('anOffer'), { - labels: ['Accepted', 'Pending', 'Declined'], - data: [a.offerAcceptance.accepted, a.offerAcceptance.pending, a.offerAcceptance.declined], - colors: [Charts.token('--success'), Charts.token('--warning'), Charts.token('--danger')], - centerValue: Math.round(a.offerAcceptance.accepted / (a.offerAcceptance.accepted + a.offerAcceptance.declined || 1) * 100) + '%', - centerLabel: 'Accept rate' - }); - Charts.horizontalBar(document.getElementById('anPipeline'), { - labels: a.pipeline.map(p => p.stage), data: a.pipeline.map(p => p.count), - colors: Charts.PALETTE - }); - Charts.bar(document.getElementById('anDept'), { labels: a.departments.map(d => d.dept), data: a.departments.map(d => d.apps) }); - const topRecs = [...DB.recruiters].sort((x, y) => y.hires - x.hires).slice(0, 8); - Charts.horizontalBar(document.getElementById('anRec'), { labels: topRecs.map(r => r.name), data: topRecs.map(r => r.hires) }); - Charts.line(document.getElementById('anTTH'), { labels: a.hiringTrend.labels, area: true, yFmt: v => v + 'd', datasets: [{ label: 'Time to Hire', data: a.timeToHire, color: Charts.PALETTE[0] }] }); - Charts.line(document.getElementById('anTTF'), { labels: a.hiringTrend.labels, area: true, yFmt: v => v + 'd', datasets: [{ label: 'Time to Fill', data: a.timeToFill, color: Charts.PALETTE[2] }] }); - } - }; -}; diff --git a/js/api.js b/js/api.js deleted file mode 100644 index 304253a..0000000 --- a/js/api.js +++ /dev/null @@ -1,25 +0,0 @@ -/* ============================================================ - api.js — minimal HTTP client for the FastAPI backend - ============================================================ */ -window.Api = { - base: 'http://localhost:8000', - - async get(path, params) { - const url = new URL(path.replace(/^\//, ''), this.base.endsWith('/') ? this.base : this.base + '/'); - if (params) { - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') { - url.searchParams.set(key, value); - } - }); - } - const res = await fetch(url.toString()); - let body = null; - try { body = await res.json(); } catch (_) { body = null; } - if (!res.ok) { - const detail = body && body.detail != null ? body.detail : res.statusText; - throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail)); - } - return body; - } -}; diff --git a/js/app.js b/js/app.js deleted file mode 100644 index 5978053..0000000 --- a/js/app.js +++ /dev/null @@ -1,278 +0,0 @@ -/* ============================================================ - app.js — Core: router, sidebar, topbar, theme, search, init - ============================================================ */ -window.Router = {}; -window.App = {}; - -const ROUTES = { - dashboard: { title: 'Dashboard' }, inbox: { title: 'Recruitment Inbox' }, jobs: { title: 'Jobs' }, candidates: { title: 'Candidates' }, - talentpool: { title: 'Talent Pool' }, pipeline: { title: 'Pipeline' }, interviews: { title: 'Interviews' }, - assessments: { title: 'Assessments' }, offers: { title: 'Offers' }, managers: { title: 'Hiring Managers' }, - calendar: { title: 'Calendar' }, reports: { title: 'Reports' }, analytics: { title: 'Analytics' }, - notifications: { title: 'Notifications' }, settings: { title: 'Settings' }, help: { title: 'Help' }, - import: { title: 'CV Import' }, jobboard: { title: 'Job Board' }, recruiterhub: { title: 'Recruiter Hub' }, - aiassistant: { title: 'AI Assistant' }, aistudio: { title: 'AI Studio' }, rbac: { title: 'Access Control' }, - tasks: { title: 'Tasks' } -}; - -let currentRoute = 'dashboard'; - -Router.go = function (route) { - if (!ROUTES[route]) route = 'dashboard'; - location.hash = route; -}; -Router.reload = function () { Router.render(currentRoute); }; - -Router.render = function (route) { - currentRoute = route; - const view = (window.Views[route] || window.Views.dashboard)(); - const main = document.getElementById('main-content'); - main.innerHTML = view.html; - main.scrollTop = 0; - if (view.onMount) view.onMount(); - - // Expose the route so CSS can react to it (e.g. hide the AI launcher on - // the AI Assistant page, where it sat on top of the chat send button). - // Deliberately NOT `data-route`: initNav() binds a click handler to every - // [data-route] element, and matching that would fire on any click. - document.documentElement.setAttribute('data-view', route); - - // active nav - document.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.route === route)); - document.title = 'TalentFlow · ' + (ROUTES[route] ? ROUTES[route].title : 'ATS'); - - // close mobile sidebar + AI dock - if (App.setNavOpen) App.setNavOpen(false); - else { - document.getElementById('sidebar').classList.remove('mobile-open'); - document.getElementById('scrim').classList.remove('open'); - } - const dock = document.getElementById('aiDock'); - if (dock) dock.classList.remove('open'); -}; - -function handleHash() { - const route = (location.hash || '#dashboard').slice(1); - Router.render(ROUTES[route] ? route : 'dashboard'); -} - -// ---------------- Theme ---------------- -// `persist` is false when the OS drives the change, so following the -// system stays the default until the user makes an explicit choice. -App.applyTheme = function (theme, persist) { - document.documentElement.setAttribute('data-theme', theme); - if (persist) { try { localStorage.setItem('tf-theme', theme); } catch (e) {} } - const btn = document.getElementById('themeToggle'); - if (btn) { - btn.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false'); - btn.setAttribute('title', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'); - btn.setAttribute('aria-label', btn.getAttribute('title')); - } -}; -App.setTheme = function (theme) { - App.applyTheme(theme, true); - // re-render current view so canvas charts pick up new theme colors - Router.render(currentRoute); -}; -App.toggleTheme = function () { - const cur = document.documentElement.getAttribute('data-theme'); - App.setTheme(cur === 'dark' ? 'light' : 'dark'); -}; - -// ---------------- Toast passthrough ---------------- -App.toast = function (msg, type, title) { UI.toast(msg, type, title); }; - -// ---------------- Badges ---------------- -App.updateBadges = function () { - const openJobs = DB.jobs.filter(j => j.status === 'Open').length; - setBadge('navJobsBadge', openJobs); - const unread = DB.notifications.filter(n => n.unread).length; - setBadge('navNotifBadge', unread); - const emailUnread = (window.Inbox && Array.isArray(Inbox._emails)) - ? Inbox._emails.filter(e => e.unread).length - : 0; - const inboxUnread = DB.inbox.filter(i => i.unread).length + emailUnread; - setBadge('navInboxBadge', inboxUnread); - const openTasks = DB.tasks.filter(t => !t.done).length; - setBadge('navTaskBadge', openTasks); -}; -function setBadge(id, n) { - const el = document.getElementById(id); - if (!el) return; - el.textContent = n; - el.style.display = n ? '' : 'none'; -} -App.markAllNotifsRead = function () { - DB.notifications.forEach(n => n.unread = false); - App.updateBadges(); - App.renderNotifDropdown(); - UI.toast('All notifications marked as read', 'success'); -}; - -// ---------------- Topbar dropdown content ---------------- -App.renderNotifDropdown = function () { - const list = document.getElementById('notifList'); - list.className = 'dd-scroll'; - list.innerHTML = DB.notifications.slice(0, 6).map(n => ` -
- ${UI.icon(n.icon)} -
${n.title}
${n.text}
${n.time}
-
`).join(''); -}; -App.renderMessages = function () { - const list = document.getElementById('messagesList'); - list.className = 'dd-scroll'; - list.innerHTML = DB.messages.map(m => ` -
- ${UI.avatar(m.name, m.initials, m.color)} -
${m.name}
${m.text}
${m.time} ago
-
`).join(''); -}; - -// ---------------- Global search ---------------- -App.search = function (q) { - const box = document.getElementById('searchResults'); - q = q.trim().toLowerCase(); - if (!q) { box.classList.remove('open'); return; } - - const jobs = DB.jobs.filter(j => (j.title + j.id + j.department).toLowerCase().includes(q)).slice(0, 4); - const cands = DB.candidates.filter(c => (c.name + c.email + c.jobTitle).toLowerCase().includes(q)).slice(0, 4); - const mgrs = DB.managers.filter(m => m.name.toLowerCase().includes(q)).slice(0, 3); - - let html = ''; - if (jobs.length) html += `
Jobs
` + jobs.map(j => - `
${UI.icon('briefcase')}
${j.title}
${j.id} · ${j.department}
`).join(''); - if (cands.length) html += `
Candidates
` + cands.map(c => - `
${UI.avatar(c.name, c.initials, c.color)}
${c.name}
${c.jobTitle}
`).join(''); - if (mgrs.length) html += `
Hiring Managers
` + mgrs.map(m => - `
${UI.avatar(m.name, m.initials, m.color)}
${m.name}
${m.title}
`).join(''); - if (!html) html = `
No results for "${q}"
`; - - box.innerHTML = html; - box.classList.add('open'); -}; -App.searchGo = function (route, cb) { - document.getElementById('searchResults').classList.remove('open'); - document.getElementById('globalSearch').value = ''; - Router.go(route); - if (cb) setTimeout(cb, 120); -}; - -// ---------------- Dropdown behavior ---------------- -function initDropdowns() { - document.querySelectorAll('.dropdown').forEach(dd => { - const toggle = dd.querySelector('[data-dd-toggle]'); - toggle.addEventListener('click', e => { - e.stopPropagation(); - const wasOpen = dd.classList.contains('open'); - document.querySelectorAll('.dropdown.open').forEach(o => o.classList.remove('open')); - if (!wasOpen) dd.classList.add('open'); - }); - dd.querySelector('[data-dd-panel]').addEventListener('click', e => e.stopPropagation()); - }); - document.addEventListener('click', () => { - document.querySelectorAll('.dropdown.open').forEach(o => o.classList.remove('open')); - document.getElementById('searchResults').classList.remove('open'); - }); -} - -// ---------------- Nav link interception ---------------- -function initNav() { - document.querySelectorAll('[data-route]').forEach(el => { - el.addEventListener('click', e => { - if (el.tagName === 'A') { /* href hash handles it */ } - const route = el.dataset.route; - if (route) { e.preventDefault(); Router.go(route); document.querySelectorAll('.dropdown.open').forEach(o => o.classList.remove('open')); } - }); - }); -} - -// ---------------- Init ---------------- -function init() { - // Theme: an explicit past choice wins; otherwise follow the OS and keep - // following it until the user picks a side themselves. - const mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; - let saved = null; - try { saved = localStorage.getItem('tf-theme'); } catch (e) {} - App.applyTheme(saved || (mq && mq.matches ? 'dark' : 'light'), false); - if (mq && !saved) { - const onSystemChange = e => { - let s = null; - try { s = localStorage.getItem('tf-theme'); } catch (err) {} - if (s) return; // user has chosen; stop following - App.applyTheme(e.matches ? 'dark' : 'light', false); - Router.render(currentRoute); // recolour canvas charts - }; - mq.addEventListener ? mq.addEventListener('change', onSystemChange) - : mq.addListener(onSystemChange); - } - - document.getElementById('themeToggle').addEventListener('click', App.toggleTheme); - - // Canvas charts are drawn at a fixed pixel size, so they blur when the - // window changes width. Re-render on resize — but never while a modal or - // the AI dock is open, since that would discard what the user is doing. - // Width only: on iOS/Android the address bar collapsing fires `resize` with - // a height change on nearly every scroll, and re-rendering there would tear - // the view out from under the user mid-gesture. - let resizeTimer, lastW = window.innerWidth; - window.addEventListener('resize', () => { - if (window.innerWidth === lastW) return; - lastW = window.innerWidth; - clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { - const busy = document.getElementById('modalRoot').classList.contains('open') - || document.getElementById('aiDock').classList.contains('open'); - if (!busy) Router.render(currentRoute); - }, 220); - }, { passive: true }); - window.addEventListener('orientationchange', () => { - lastW = -1; // force the next resize through - }); - - // AI Assistant floating dock - document.getElementById('aiFab').addEventListener('click', () => AI._dockOpen()); - - // sidebar collapse (desktop) - document.getElementById('sidebarCollapse').addEventListener('click', () => { - document.getElementById('sidebar').classList.toggle('collapsed'); - }); - // Mobile nav drawer. `nav-open` on is what CSS keys off — the FAB - // sits before .scrim in the DOM, so no sibling selector can reach it, and - // :has() would exclude older Safari/Firefox. - App.setNavOpen = function (open) { - document.getElementById('sidebar').classList.toggle('mobile-open', open); - document.getElementById('scrim').classList.toggle('open', open); - document.documentElement.classList.toggle('nav-open', open); - // Stop the page behind the drawer from scrolling under the user's finger. - document.body.style.overflow = open ? 'hidden' : ''; - }; - document.getElementById('mobileMenu').addEventListener('click', () => { - App.setNavOpen(!document.getElementById('sidebar').classList.contains('mobile-open')); - }); - document.getElementById('scrim').addEventListener('click', () => App.setNavOpen(false)); - - // search - const search = document.getElementById('globalSearch'); - search.addEventListener('input', () => App.search(search.value)); - search.addEventListener('click', e => e.stopPropagation()); - document.addEventListener('keydown', e => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); search.focus(); } - if (e.key === 'Escape') { UI.closeModal(); document.getElementById('searchResults').classList.remove('open'); document.getElementById('aiDock').classList.remove('open'); App.setNavOpen(false); } - }); - - initDropdowns(); - initNav(); - App.renderNotifDropdown(); - App.renderMessages(); - App.updateBadges(); - - window.addEventListener('hashchange', handleHash); - handleHash(); - - // redraw charts on resize (debounced) - let rt; - window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => Router.reload(), 250); }); -} - -document.addEventListener('DOMContentLoaded', init); diff --git a/js/assessments.js b/js/assessments.js deleted file mode 100644 index 0f2eb55..0000000 --- a/js/assessments.js +++ /dev/null @@ -1,126 +0,0 @@ -/* ============================================================ - assessments.js — Assessments listing & assign - ============================================================ */ -window.Views = window.Views || {}; -window.Assessments = {}; - -Views.assessments = function () { - const filters = { q: '', status: '', type: '' }; - let table; - - const stats = { - total: DB.assessments.length, - completed: DB.assessments.filter(a => a.status === 'Completed').length, - pending: DB.assessments.filter(a => ['Pending', 'In Progress'].includes(a.status)).length, - avg: Math.round(DB.assessments.filter(a => a.score).reduce((s, a) => s + a.score, 0) / (DB.assessments.filter(a => a.score).length || 1)) - }; - - function apply() { - const rows = DB.assessments.filter(a => { - if (filters.status && a.status !== filters.status) return false; - if (filters.type && a.type !== filters.type) return false; - if (filters.q && !(a.candidate + a.jobTitle + a.type).toLowerCase().includes(filters.q.toLowerCase())) return false; - return true; - }); - table.update(rows); - } - - table = UI.dataTable({ - pageSize: 8, - rows: DB.assessments, - columns: [ - { key: 'candidate', label: 'Candidate', sortable: true, render: a => `
${UI.avatar(a.candidate, a.initials, a.color)}
${a.candidate}
${a.jobTitle}
` }, - { key: 'type', label: 'Assessment', sortable: true, render: a => `
${a.type}
${a.duration}
` }, - { key: 'assigned', label: 'Assigned', sortable: true, sortValue: a => a.assigned.getTime(), render: a => `${DB.fmtShort(a.assigned)}` }, - { key: 'due', label: 'Due', sortable: true, sortValue: a => a.due.getTime(), render: a => `${DB.fmtShort(a.due)}` }, - { key: 'score', label: 'Score', sortable: true, align: 'center', render: a => a.score !== null ? UI.scoreChip(a.score) : '' }, - { key: 'status', label: 'Status', sortable: true, render: a => UI.badge(a.status) }, - { key: '_a', label: 'Actions', align: 'right', render: a => ` -
- - -
` } - ] - }); - - const statusOpts = [''].concat(['Completed', 'In Progress', 'Pending', 'Expired'].map(s => ``)).join(''); - const typeOpts = [''].concat([...new Set(DB.assessments.map(a => a.type))].map(t => ``)).join(''); - const statCard = (label, val, icn, cls) => `
${label}${UI.icon(icn)}
${val}
`; - - const html = ` -
-
-

Assessments

Coding tests, take-homes, and evaluations

-
-
-
- ${statCard('Total Assigned', stats.total, 'file', 'i-indigo')} - ${statCard('Completed', stats.completed, 'check-circle', 'i-green')} - ${statCard('In Progress / Pending', stats.pending, 'clock', 'i-amber')} - ${statCard('Average Score', stats.avg + '%', 'target', 'i-teal')} -
-
-
-
- - - -
-
- ${table.html} -
-
`; - - return { - html, - onMount() { - table.mount(); - const s = document.getElementById('asSearch'); - s.oninput = () => { filters.q = s.value; apply(); }; - document.getElementById('asStatus').onchange = e => { filters.status = e.target.value; apply(); }; - document.getElementById('asType').onchange = e => { filters.type = e.target.value; apply(); }; - } - }; -}; - -Assessments.view = function (id) { - const a = DB.assessments.find(x => x.id === id); - const body = ` -
${UI.avatar(a.candidate, a.initials, a.color, 'avatar-lg')} -
${a.candidate}
${a.type} · ${a.jobTitle}
-
${UI.badge(a.status)}
-
-
Type
${a.type}
-
Duration
${a.duration}
-
Assigned
${DB.fmtDate(a.assigned)}
-
Due
${DB.fmtDate(a.due)}
-
- ${a.score !== null ? ` -
-
-
${a.score}%
-
Overall Score
-
-
${UI.pbar(a.score)}
-
Section Breakdown
- ${['Problem Solving', 'Code Quality', 'Communication', 'Time Management'].map(sec => { - const sc = DB.int(60, 98); - return `
${sec}
${UI.pbar(sc)}
${sc}%
`; - }).join('')}` : `
${UI.icon('clock')}

Assessment not completed

Results will appear once the candidate submits.

`}`; - const footer = ` - `; - UI.modal({ title: 'Assessment Result', subtitle: a.id, body, footer }); -}; - -Assessments.assign = function () { - const opt = arr => arr.map(o => ``).join(''); - const body = `
-
-
-
-
-
`; - const footer = ` - `; - UI.modal({ title: 'Assign Assessment', subtitle: 'Send an evaluation to a candidate', body, footer }); -}; diff --git a/js/candidates.js b/js/candidates.js deleted file mode 100644 index ad80747..0000000 --- a/js/candidates.js +++ /dev/null @@ -1,441 +0,0 @@ -/* ============================================================ - candidates.js — Candidate list, filters, profile modal w/ tabs - ============================================================ */ -window.Views = window.Views || {}; -window.Candidates = {}; - -Views.candidates = function () { - const f = { q: '', job: '', skill: '', dept: '', location: '', exp: '', edu: '', recruiter: '', manager: '', source: '', ats: '', stage: '', interview: '', notice: '', availability: '' }; - const selected = new Set(); - let sortMode = 'relevance'; - let table; - Candidates._selected = selected; - - function relevance(c) { - // composite relevance: ATS + matched-skill ratio + recency - const req = (DB.getJob(c.jobId) || {}).skills || []; - const skillRatio = req.length ? c.matchedSkills.length / req.length : 0.5; - const recency = 1 - Math.min(1, (new Date('2026-07-09') - c.applied) / (90 * 864e5)); - return Math.round(c.aiScore * 0.7 + skillRatio * 20 + recency * 10); - } - - function apply() { - let rows = DB.candidates.filter(c => { - if (f.job && c.jobTitle !== f.job) return false; - if (f.skill && !c.skills.includes(f.skill)) return false; - if (f.dept && c.department !== f.dept) return false; - if (f.location && c.location !== f.location) return false; - if (f.exp === '0-2' && c.experience > 2) return false; - if (f.exp === '3-5' && (c.experience < 3 || c.experience > 5)) return false; - if (f.exp === '6-9' && (c.experience < 6 || c.experience > 9)) return false; - if (f.exp === '10+' && c.experience < 10) return false; - if (f.edu && c.education !== f.edu) return false; - if (f.recruiter && c.recruiter !== f.recruiter) return false; - if (f.manager) { const job = DB.getJob(c.jobId); if (!job || job.manager !== f.manager) return false; } - if (f.source && c.source !== f.source) return false; - if (f.ats === '85+' && c.aiScore < 85) return false; - if (f.ats === '70-84' && (c.aiScore < 70 || c.aiScore > 84)) return false; - if (f.ats === '<70' && c.aiScore >= 70) return false; - if (f.stage && c.stage !== f.stage) return false; - if (f.interview && c.interviewStatus !== f.interview) return false; - if (f.notice && c.noticePeriod !== f.notice) return false; - if (f.availability && c.availability !== f.availability) return false; - if (f.q) { const q = f.q.toLowerCase(); if (!(c.name + c.email + c.jobTitle + c.currentCompany + c.recruiter + c.skills.join(' ')).toLowerCase().includes(q)) return false; } - return true; - }); - if (sortMode === 'relevance') rows = [...rows].sort((a, b) => relevance(b) - relevance(a)); - else if (sortMode === 'ats') rows = [...rows].sort((a, b) => b.aiScore - a.aiScore); - else if (sortMode === 'recent') rows = [...rows].sort((a, b) => b.applied - a.applied); - else if (sortMode === 'name') rows = [...rows].sort((a, b) => a.name.localeCompare(b.name)); - table.update(rows); - updateBulkBar(); - const cnt = document.getElementById('canResultCount'); - if (cnt) cnt.textContent = rows.length + ' candidate' + (rows.length === 1 ? '' : 's'); - } - - function updateBulkBar() { - const bar = document.getElementById('bulkBar'); - if (!bar) return; - if (selected.size) { bar.style.display = 'flex'; document.getElementById('bulkCount').textContent = selected.size + ' selected'; } - else bar.style.display = 'none'; - } - - table = UI.dataTable({ - pageSize: 10, - rows: DB.candidates, - columns: [ - { key: '_sel', label: '', render: c => `${UI.icon('check')}` }, - { key: 'name', label: 'Candidate', sortable: true, render: c => `
${UI.avatar(c.name, c.initials, c.color)}
${c.name} ${c.favorite ? '' + UI.icon('star') + '' : ''}
${c.currentTitle} · ${c.location}
` }, - { key: 'jobTitle', label: 'Applied Job', sortable: true, render: c => `
${c.jobTitle}
${c.department}
` }, - { key: 'experience', label: 'Exp', sortable: true, align: 'center', render: c => `${c.experience}y` }, - { key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: c => relevance(c), render: c => `${relevance(c)}%` }, - { key: 'stage', label: 'Stage', sortable: true, render: c => UI.badge(c.stage) }, - { key: 'aiScore', label: 'ATS', sortable: true, align: 'center', render: c => `${UI.scoreChip(c.aiScore)}` }, - { key: 'availability', label: 'Availability', render: c => `${c.availability}
${c.noticePeriod} notice
` }, - { key: '_a', label: 'Actions', align: 'right', render: c => ` -
- - - - -
` } - ], - onRender(el) { - el.querySelectorAll('[data-sel]').forEach(chk => chk.onclick = () => { - const id = chk.dataset.sel; - if (selected.has(id)) selected.delete(id); else selected.add(id); - chk.classList.toggle('on'); updateBulkBar(); - }); - el.querySelectorAll('[data-fav]').forEach(st => st.onclick = () => { - const c = DB.getCandidate(st.dataset.fav); c.favorite = !c.favorite; - st.classList.toggle('on'); UI.toast(c.favorite ? c.name + ' added to favorites' : 'Removed from favorites', 'success'); - }); - } - }); - - const optList = (arr, label) => [``].concat(arr.map(o => ``)).join(''); - const jobTitles = [...new Set(DB.candidates.map(c => c.jobTitle))]; - - const filterPanel = ` - `; - - // recently viewed strip - const rv = DB.recentlyViewed.slice(0, 6).map(id => DB.getCandidate(id)).filter(Boolean); - const rvHtml = rv.length ? `
- Recently viewed: - ${rv.map(c => ``).join('')} -
` : ''; - - const html = ` -
-
-

Candidates

${DB.candidates.length} candidates · ranked by AI relevance

-
- - - -
-
- ${rvHtml} - -
-
-
- - -
- - -
- ${filterPanel} -
- ${table.html} -
-
`; - - return { - html, - onMount() { - table.mount(); - apply(); - const s = document.getElementById('canSearch'); - s.oninput = () => { f.q = s.value; apply(); }; - document.getElementById('canSort').onchange = e => { sortMode = e.target.value; apply(); }; - document.getElementById('filterToggle').onclick = () => { - const p = document.getElementById('filterPanel'); - p.style.display = p.style.display === 'none' ? 'grid' : 'none'; - }; - document.querySelectorAll('#filterPanel [data-f]').forEach(sel => sel.onchange = e => { f[e.target.dataset.f] = e.target.value; apply(); }); - } - }; -}; - -// ---------------- Bulk actions ---------------- -Candidates.bulk = function (action) { - const ids = [...Candidates._selected]; - if (!ids.length) return; - if (action === 'email') UI.toast(`Bulk email drafted to ${ids.length} candidates`, 'success'); - else if (action === 'assign') { - const opts = DB.recruiters.map(r => ``).join(''); - UI.modal({ title: 'Bulk Assign Recruiter', subtitle: ids.length + ' candidates', - body: `
`, - footer: `` }); - return; - } - else if (action === 'advance') { ids.forEach(id => Candidates._advanceSilent(id)); UI.toast(`${ids.length} candidates advanced`, 'success'); Router.reload(); return; } - else if (action === 'reject') { ids.forEach(id => { const c = DB.getCandidate(id); c.stage = 'Rejected'; c.status = 'Rejected'; }); UI.toast(`${ids.length} candidates rejected`, 'warning'); Router.reload(); return; } - Candidates.bulkClear(); -}; -Candidates._bulkAssign = function () { - const rec = document.getElementById('bulkRec').value; - [...Candidates._selected].forEach(id => { DB.getCandidate(id).recruiter = rec; }); - UI.closeModal(); UI.toast('Recruiter assigned to selected candidates', 'success'); - Candidates.bulkClear(); Router.reload(); -}; -Candidates.bulkClear = function () { Candidates._selected.clear(); Router.reload(); }; -Candidates._advanceSilent = function (id) { - const c = DB.getCandidate(id); - const order = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']; - const i = order.indexOf(c.stage); - if (i > -1 && i < order.length - 1) { c.stage = order[i + 1]; c.status = c.stage; } -}; - -// ---------------- ATS Match detail ---------------- -Candidates.atsMatch = function (id) { - const c = DB.getCandidate(id); - const job = DB.getJob(c.jobId) || {}; - const recCls = c.recommendation === 'Strong Match' ? 'recc-strong' : c.recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'; - const ringColor = c.aiScore >= 82 ? 'var(--success)' : c.aiScore >= 65 ? 'var(--warning)' : 'var(--danger)'; - const sub = c.subScores; - const subRow = (label, val) => `
${label}
${UI.pbar(val)}
${val}%
`; - - const body = ` -
- ${UI.icon(c.recommendation === 'Weak Match' ? 'x-circle' : 'check-circle')} -
${c.recommendation}
-
${c.name} for ${c.jobTitle}
-
-
-
-
${c.aiScore}
ATS MATCH
-
-
- ${subRow('Skills', sub.skills)} - ${subRow('Experience', sub.experience)} - ${subRow('Education', sub.education)} - ${subRow('Keywords', sub.keywords)} - ${subRow('Location', sub.location)} - ${subRow('Salary', sub.salary)} -
-
-
Matched Skills (${c.matchedSkills.length})
-
${c.matchedSkills.length ? c.matchedSkills.map(s => `${UI.icon('check')} ${s}`).join('') : ''}
-
Missing Skills (${c.missingSkills.length})
-
${c.missingSkills.length ? c.missingSkills.map(s => `${UI.icon('x')} ${s}`).join('') : 'None — full match'}
-
-

${UI.icon('sparkles')} Score computed from JD keywords, resume parsing, experience, education, location and salary alignment. Connect an AI model to refine with semantic matching.

`; - UI.modal({ - title: 'ATS Match Analysis', subtitle: c.id + ' · ' + c.jobTitle, body, size: 'modal-lg', - footer: `` - }); -}; - -Candidates.toggleFav = function (id, btn) { - const c = DB.getCandidate(id); c.favorite = !c.favorite; - if (btn) { btn.classList.toggle('on'); btn.innerHTML = UI.icon('star') + (c.favorite ? ' Favorited' : ' Favorite'); } - UI.toast(c.favorite ? c.name + ' added to favorites' : 'Removed from favorites', 'success'); -}; - -Candidates.advance = function (id) { - const c = DB.getCandidate(id); - const order = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']; - const i = order.indexOf(c.stage); - if (i === -1 || i >= order.length - 1) { UI.toast(c.name + ' cannot be advanced further', 'warning'); return; } - c.stage = order[i + 1]; c.status = c.stage; - UI.toast(`${c.name} moved to ${c.stage}`, 'success'); - Router.reload(); -}; - -// ---------------- Candidate profile w/ tabs ---------------- -Candidates.openProfile = function (id) { - const c = DB.getCandidate(id); - // track recently viewed - const rvIdx = DB.recentlyViewed.indexOf(id); - if (rvIdx > -1) DB.recentlyViewed.splice(rvIdx, 1); - DB.recentlyViewed.unshift(id); - if (DB.recentlyViewed.length > 12) DB.recentlyViewed.pop(); - const tabs = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']; - const body = ` -
- ${UI.avatar(c.name, c.initials, c.color, 'avatar-lg')} -
-
${c.name}
-
${c.currentTitle} at ${c.currentCompany}
-
${UI.badge(c.stage)} ${UI.badge(c.source, 'b-gray')} - ${c.experience} yrs exp
-
-
${UI.scoreChip(c.aiScore)}
AI Match
-
-
- ${tabs.map((t, i) => `
${t}
`).join('')} -
-
- ${Candidates._pane('Overview', c)} - ${Candidates._pane('Resume', c)} - ${Candidates._pane('Timeline', c)} - ${Candidates._pane('Interview', c)} - ${Candidates._pane('Notes', c)} - ${Candidates._pane('Activity', c)} - ${Candidates._pane('Documents', c)} - ${Candidates._pane('Feedback', c)} -
`; - const footer = ` - - - `; - UI.modal({ title: 'Candidate Profile', subtitle: c.id, body, footer, size: 'modal-lg' }); - - const paneEls = document.querySelectorAll('#canPanes .tab-pane'); - document.querySelectorAll('#canTabs .tab').forEach(tab => tab.onclick = () => { - document.querySelectorAll('#canTabs .tab').forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - paneEls.forEach(p => p.classList.remove('active')); - paneEls[+tab.dataset.tab].classList.add('active'); - }); -}; - -Candidates._pane = function (name, c) { - const active = name === 'Overview' ? 'active' : ''; - let content = ''; - if (name === 'Overview') { - content = ` -
-
Email
${c.email}
-
Phone
${c.phone}
-
Location
${c.location}
-
Applied For
${c.jobTitle}
-
Current Company
${c.currentCompany}
-
Experience
${c.experience} years
-
Education
${c.education}
-
Source
${c.source}
-
Recruiter
${c.recruiter}
-
Applied On
${DB.fmtDate(c.applied)}
-
Expected Salary
${DB.moneyK(c.salary)}
-
Rating
⭐ ${c.rating} / 5.0
-
-
Skills
-
${c.skills.map(s => `${s}`).join('')}
`; - } else if (name === 'Resume') { - content = `
-

${c.name}

${c.currentTitle} · ${c.location}

-
-
Summary
-

Results-driven ${c.currentTitle.toLowerCase()} with ${c.experience} years of experience across ${c.department.toLowerCase()}. Passionate about building high-quality products and collaborating with cross-functional teams.

-
Experience
-
${c.currentTitle} — ${c.currentCompany}
2021 – Present
-
Associate — ${DB.pick(DB.companies)}
2018 – 2021
-
Education
-
${c.education}
-
- `; - } else if (name === 'Timeline') { - const events = [ - { icon: 'user-plus', title: 'Application received', meta: DB.fmtDate(c.applied), desc: `Applied via ${c.source}` }, - { icon: 'star', title: 'AI screening completed', meta: '1 day later', desc: `Match score: ${c.aiScore}%` }, - { icon: 'phone', title: 'Recruiter screen', meta: '3 days later', desc: `Call with ${c.recruiter}` }, - { icon: 'calendar', title: 'Technical interview', meta: '1 week later', desc: 'Panel of 3 interviewers' }, - { icon: 'check', title: `Moved to ${c.stage}`, meta: 'Recently', desc: 'Current stage in pipeline' } - ]; - content = `
${events.map(e => ` -
${UI.icon(e.icon)}
-
${e.title}
${e.meta}
${e.desc}
`).join('')}
`; - } else if (name === 'Interview') { - const ivs = DB.interviews.filter(i => i.candidateId === c.id); - content = ivs.length ? `
${ivs.map(iv => ` -
${UI.icon('calendar')} -
${iv.type}
${DB.fmtDate(iv.when)} · ${iv.meeting}
-
${UI.badge(iv.status)}
`).join('')}
` - : `
${UI.icon('calendar')}

No interviews scheduled

Schedule an interview to get started.

-
`; - } else if (name === 'Notes') { - content = ` -
- -
-
${UI.avatar(c.recruiter)}
${c.recruiter}
Strong communication skills, great culture fit. Recommend advancing.
2 days ago
-
${UI.avatar('Asfand Ahmed', 'AA')}
Asfand Ahmed
Reviewed portfolio — impressive work. Schedule technical round.
4 days ago
-
`; - } else if (name === 'Activity') { - content = `
-
${UI.icon('eye')}
Profile viewed by ${c.recruiter}
1h ago
-
${UI.icon('mail')}
Email sent: Interview invitation
1 day ago
-
${UI.icon('star')}
Assessment score updated to ${c.aiScore}%
2 days ago
-
${UI.icon('user-plus')}
Applied for ${c.jobTitle}
${DB.fmtDate(c.applied)}
-
`; - } else if (name === 'Documents') { - const docs = [{ n: 'Resume.pdf', s: '284 KB' }, { n: 'Cover_Letter.pdf', s: '112 KB' }, { n: 'Portfolio.pdf', s: '4.2 MB' }, { n: 'References.docx', s: '48 KB' }]; - content = `
${docs.map(d => ` -
${UI.icon('file')} -
${d.n}
${d.s}
-
`).join('')}
`; - } else if (name === 'Feedback') { - const scores = ['Strong Hire', 'Hire', 'Lean Hire']; - content = `
- ${[0, 1, 2].map(i => `
${UI.avatar(DB.recruiters[i].name, DB.recruiters[i].initials, DB.recruiters[i].color)} -
${DB.recruiters[i].name}
${['Excellent technical depth and clear communication.', 'Good problem solving, would benefit from more system design exposure.', 'Solid candidate, positive team energy.'][i]}
-
${UI.badge(scores[i])}
`).join('')} -
- `; - } - return `
${content}
`; -}; - -Candidates.openAdd = function () { - const opt = (arr) => arr.map(o => ``).join(''); - const body = `
-
Required
-
Valid email required
-
-
-
-
-
-
-
`; - const footer = ` - `; - UI.modal({ title: 'Add Candidate', subtitle: 'Manually add a candidate to the pipeline', body, footer }); -}; -Candidates._add = function () { - const form = document.getElementById('canForm'); - UI.clearErrors(form); - const f = Object.fromEntries(new FormData(form)); - let ok = true; - if (!f.name.trim()) { UI.fieldError(form.querySelector('[name=name]'), 'Required'); ok = false; } - if (!/^\S+@\S+\.\S+$/.test(f.email)) { UI.fieldError(form.querySelector('[name=email]'), 'Valid email required'); ok = false; } - if (!ok) { UI.toast('Please fix the highlighted fields', 'error'); return; } - const job = DB.jobs.find(j => j.title === f.job) || DB.jobs[0]; - const score = DB.int(55, 95); - DB.candidates.unshift({ - id: 'CAN-' + (5001 + DB.candidates.length), name: f.name, initials: DB.initials(f.name), color: DB.avatarColor(f.name), - email: f.email, phone: f.phone || '+1 (555) 000-0000', jobId: job.id, jobTitle: job.title, department: job.department, - experience: +f.experience || 1, currentCompany: f.company || '—', currentTitle: job.title, location: job.location, - stage: f.stage, status: f.stage, aiScore: score, source: f.source, recruiter: job.recruiter, recruiterId: job.recruiterId, - applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: 120000, - matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match', - subScores: { skills: score, experience: 80, education: 80, keywords: score, location: 100, salary: 90 }, - noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled' - }); - UI.closeModal(); - UI.toast('Candidate added to pipeline', 'success'); - Router.reload(); -}; diff --git a/js/dashboard.js b/js/dashboard.js deleted file mode 100644 index fc99eb7..0000000 --- a/js/dashboard.js +++ /dev/null @@ -1,170 +0,0 @@ -/* ============================================================ - dashboard.js — Main dashboard view - ============================================================ */ -window.Views = window.Views || {}; - -Views.dashboard = function () { - const k = DB.kpis; - const kpiCards = [ - { label: 'Open Jobs', value: k.openJobs, icon: 'briefcase', cls: 'i-indigo', trend: '+8%', dir: 'up', foot: 'vs last month' }, - { label: 'Total Candidates', value: k.totalCandidates, icon: 'users', cls: 'i-blue', trend: '+12%', dir: 'up', foot: 'active in pipeline' }, - { label: 'Interviews Today', value: k.interviewsToday, icon: 'calendar', cls: 'i-purple', trend: '3 upcoming', dir: 'flat', foot: 'next at 2:00 PM' }, - { label: 'Offers Accepted', value: k.offersAccepted, icon: 'check-circle', cls: 'i-green', trend: '+5%', dir: 'up', foot: `of ${k.offersSent} sent` } - ]; - const kpiCards2 = [ - { label: 'Time to Hire', value: k.timeToHire + ' days', icon: 'clock', cls: 'i-teal', trend: '-3 days', dir: 'up', foot: 'faster than target' }, - { label: 'Time to Fill', value: k.timeToFill + ' days', icon: 'target', cls: 'i-amber', trend: '-2 days', dir: 'up', foot: '41 day average' }, - { label: 'Cost per Hire', value: DB.money(k.costPerHire), icon: 'dollar', cls: 'i-red', trend: '+4%', dir: 'down', foot: 'above budget' }, - { label: 'Closed Jobs', value: k.closedJobs + k.hires, icon: 'award', cls: 'i-purple', trend: '+15%', dir: 'up', foot: 'this quarter' } - ]; - - function trendHtml(dir, txt) { - if (dir === 'flat') return `${txt}`; - const cls = dir === 'up' ? 'trend-up' : 'trend-down'; - const ic = dir === 'up' ? 'trending-up' : 'trending-down'; - return `${UI.icon(ic)}${txt}`; - } - const kpiHtml = arr => arr.map(c => ` -
-
- ${c.label} - ${UI.icon(c.icon)} -
-
${c.value}
-
${trendHtml(c.dir, c.trend)}${c.foot}
-
`).join(''); - - // upcoming interviews - const upcoming = DB.interviews.filter(iv => iv.status === 'Scheduled').slice(0, 5); - const upcomingHtml = upcoming.length ? upcoming.map(iv => ` -
- ${UI.avatar(iv.candidate, iv.candInitials, iv.color)} -
-
${iv.candidate}
-
${iv.type} · ${iv.jobTitle}
-
-
-
${DB.fmtShort(iv.when)}
-
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
-
-
`).join('') : '
No upcoming interviews
'; - - // recent applications - const recentApps = [...DB.candidates].sort((a, b) => b.applied - a.applied).slice(0, 5); - const recentHtml = recentApps.map(c => ` -
- ${UI.avatar(c.name, c.initials, c.color)} -
-
${c.name}
-
${c.jobTitle}
-
-
${UI.scoreChip(c.aiScore)}
-
`).join(''); - - // activity feed - const activityHtml = DB.activity.slice(0, 8).map(a => ` -
- ${UI.icon(a.icon)} -
-
${a.html}
-
${DB.relTime(a.time)}
-
-
`).join(''); - - // recruiter performance - const topRecs = [...DB.recruiters].sort((a, b) => b.hires - a.hires).slice(0, 5); - const recPerf = topRecs.map(r => ` -
- ${UI.avatar(r.name, r.initials, r.color)} -
-
${r.name}
-
${r.openReqs} open reqs · ${r.avgTimeToHire}d avg
-
-
${r.hires}
hires
-
`).join(''); - - const html = ` -
-
-
-

Good morning, Asfand 👋

-

Here's what's happening with your hiring today — Thursday, July 9, 2026

-
-
- - -
-
- -
${kpiHtml(kpiCards)}
-
${kpiHtml(kpiCards2)}
- -
-
-
-

Hiring Trend

Hires vs applications over the last 7 months
-
7M1Y
-
-
-
- ${Charts.legend([{ label: 'Applications', color: Charts.PALETTE[4] }, { label: 'Hires', color: Charts.PALETTE[0] }])} -
-
-
-

Candidate Pipeline

Active by stage
-
-
-
-
-
- -
-
-

Upcoming Interviews

Next scheduled sessions
-
-
${upcomingHtml}
-
-
-

Source Analytics

Where candidates come from
-
-
-
- -
-
-

Recent Applications

-
-
${recentHtml}
-
-
-

Recruiter Performance

-
${recPerf}
-
-
-

Recent Activity

-
${activityHtml}
-
-
-
`; - - return { - html, - onMount() { - const a = DB.analytics; - Charts.line(document.getElementById('chartTrend'), { - labels: a.hiringTrend.labels, area: true, - datasets: [ - { label: 'Applications', data: a.hiringTrend.applications, color: Charts.PALETTE[4] }, - { label: 'Hires', data: a.hiringTrend.hires, color: Charts.PALETTE[0] } - ] - }); - Charts.horizontalBar(document.getElementById('chartPipeline'), { - labels: a.pipeline.map(p => p.stage), data: a.pipeline.map(p => p.count), - colors: Charts.PALETTE - }); - Charts.bar(document.getElementById('chartSource'), { - labels: a.sources.map(s => s.source), data: a.sources.map(s => s.count) - }); - } - }; -}; diff --git a/js/import.js b/js/import.js deleted file mode 100644 index a9998c9..0000000 --- a/js/import.js +++ /dev/null @@ -1,176 +0,0 @@ -/* ============================================================ - import.js — Manual CV Import (drag&drop, parse, match, dedupe) - ============================================================ */ -window.Views = window.Views || {}; -window.CVImport = {}; - -Views.import = function () { - const queue = []; // {name, size, status, atsScore, matchedJob, duplicate} - - const html = ` -
-
-

CV Import

Upload resumes — we parse, score, match, and dedupe automatically

-
- AI Resume Parser · Ready -
-
- -
-
-
-
-
${UI.icon('upload')}
-

Drag & drop resumes here

-

or click to browse — PDF, DOC, DOCX and ZIP supported · up to 20 files

- -
- ${['PDF', 'DOC', 'DOCX', 'ZIP'].map(t => `${t}`).join('')} -
-
-
- - - Files are processed locally in this demo -
-
- - -
- -
-

Auto-Processing

What happens on upload
-
- ${[ - { i: 'file', t: 'Resume parsing', d: 'Extract name, contact, experience, skills & education' }, - { i: 'target', t: 'ATS scoring', d: 'Generate a match score against the requisition' }, - { i: 'briefcase', t: 'Job matching', d: 'Suggest the best-matching open roles' }, - { i: 'users', t: 'Duplicate detection', d: 'Flag candidates already in the system' }, - { i: 'user-plus', t: 'Profile creation', d: 'Create a candidate profile in Applied stage' } - ].map(s => `
${UI.icon(s.i)}
${s.t}
${s.d}
`).join('')} -
-
-
-
`; - - CVImport._queue = queue; - - return { - html, - onMount() { - const dz = document.getElementById('dropzone'); - const browse = document.getElementById('browseBtn'); - dz.addEventListener('click', () => CVImport.simulate(DB.int(2, 4))); - browse.addEventListener('click', e => { e.stopPropagation(); CVImport.simulate(DB.int(2, 4)); }); - dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag'); }); - dz.addEventListener('dragleave', () => dz.classList.remove('drag')); - dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('drag'); CVImport.simulate(e.dataTransfer.files.length || DB.int(2, 4)); }); - CVImport._renderQueue(); - } - }; -}; - -CVImport.simulate = function (count, isZip) { - const n = isZip ? 8 : count; - const jobs = DB.jobs.filter(j => j.status === 'Open'); - for (let k = 0; k < n; k++) { - const name = DB.pick(['Olivia', 'Liam', 'Emma', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Priya', 'Diego', 'Yuki', 'Omar']) + ' ' + DB.pick(['Chen', 'Patel', 'Kim', 'Garcia', 'Silva', 'Ahmed', 'Novak', 'Reyes', 'Khan', 'Costa']); - const item = { - id: 'UP-' + Math.random().toString(36).slice(2, 8), name, file: name.split(' ')[0] + '_Resume.' + DB.pick(['pdf', 'docx', 'doc']), - size: DB.int(120, 620) + ' KB', progress: 0, status: 'Uploading', atsScore: null, - job: DB.pick(jobs.length ? jobs : DB.jobs), duplicate: Math.random() < 0.18, imported: false - }; - CVImport._queue.push(item); - CVImport._process(item); - } - document.getElementById('queueCard').style.display = ''; - UI.toast(isZip ? 'ZIP extracted — 8 resumes queued' : n + ' file(s) uploaded', 'info'); - CVImport._renderQueue(); -}; - -CVImport._process = function (item) { - const tick = setInterval(() => { - item.progress += DB.int(12, 30); - if (item.progress >= 100) { - item.progress = 100; clearInterval(tick); - item.status = 'Parsing'; - CVImport._renderQueue(); - setTimeout(() => { - item.status = 'Ready'; item.atsScore = DB.int(52, 96); - CVImport._renderQueue(); - }, 700 + DB.int(0, 500)); - } - CVImport._renderQueue(); - }, 220); -}; - -CVImport._renderQueue = function () { - const el = document.getElementById('queueList'); - if (!el) return; - const q = CVImport._queue; - document.getElementById('queueSub').textContent = q.length + ' file' + (q.length === 1 ? '' : 's') + ' · ' + q.filter(i => i.imported).length + ' imported'; - el.innerHTML = q.map(i => ` -
- ${UI.icon('file')} -
-
${i.name} - ${i.duplicate ? 'DUPLICATE' : ''}
-
${i.file} · ${i.size}
- ${i.status === 'Uploading' || i.status === 'Parsing' ? `
` : - `
Best match: ${i.job.title}
`} -
-
- ${i.status === 'Ready' ? UI.scoreChip(i.atsScore) : `${i.status}${i.status === 'Uploading' ? ' ' + i.progress + '%' : ''}`} -
-
- ${i.imported ? `Imported` : - i.status === 'Ready' ? `` : - ``} -
-
`).join(''); -}; - -CVImport.importOne = function (id) { - const i = CVImport._queue.find(x => x.id === id); - if (!i || i.imported) return; - if (i.duplicate) { - UI.modal({ - title: 'Duplicate Detected', subtitle: i.name, - body: `
${UI.icon('users')} -

A similar candidate already exists

-

${i.name} matches an existing profile (95% similarity on name + email). Importing will create a duplicate.

`, - footer: ` - - ` - }); - return; - } - CVImport._doImport(id); -}; -CVImport._doImport = function (id) { - const i = CVImport._queue.find(x => x.id === id); - const job = i.job; - DB.candidates.unshift({ - id: 'CAN-' + (5001 + DB.candidates.length), name: i.name, initials: DB.initials(i.name), color: DB.avatarColor(i.name), - email: i.name.toLowerCase().replace(/ /g, '.') + '@email.com', phone: '+1 (555) 000-0000', jobId: job.id, jobTitle: job.title, department: job.department, - experience: DB.int(2, 12), currentCompany: DB.pick(DB.companies), currentTitle: job.title, location: DB.pick(DB.locations), - stage: 'Applied', status: 'Applied', aiScore: i.atsScore, source: 'Manual CV Upload', recruiter: DB.pick(DB.recruiters).name, recruiterId: '', - applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: DB.int(90, 180) * 1000, - matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: i.atsScore >= 82 ? 'Strong Match' : 'Potential Match', - subScores: { skills: i.atsScore, experience: 80, education: 80, keywords: i.atsScore, location: 100, salary: 90 }, - noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled' - }); - i.imported = true; - CVImport._renderQueue(); App.updateBadges(); - UI.toast(`${i.name} imported → ${job.title}`, 'success'); -}; -CVImport.importAll = function () { - const ready = CVImport._queue.filter(i => i.status === 'Ready' && !i.imported && !i.duplicate); - if (!ready.length) { UI.toast('No files ready to import', 'warning'); return; } - ready.forEach(i => CVImport._doImport(i.id)); - UI.toast(`${ready.length} candidates imported`, 'success'); -}; diff --git a/js/inbox.js b/js/inbox.js deleted file mode 100644 index 28e0008..0000000 --- a/js/inbox.js +++ /dev/null @@ -1,419 +0,0 @@ -/* ============================================================ - inbox.js — Central Recruitment Inbox + Outlook Email tab - ============================================================ */ -window.Views = window.Views || {}; -window.Inbox = {}; - -Views.inbox = function () { - const state = { tab: 'All Applications', selected: null, emailSelected: null, q: '' }; - const tabs = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']; - - function filtered() { - let list = DB.inbox; - if (state.tab === 'Unread') list = list.filter(i => i.processing === 'Unread'); - else if (state.tab === 'Imported') list = list.filter(i => i.processing === 'Imported'); - else if (state.tab === 'Processed') list = list.filter(i => i.processing === 'Processed'); - else if (state.tab === 'Rejected') list = list.filter(i => i.processing === 'Rejected'); - else if (state.tab === 'Duplicates') list = list.filter(i => i.duplicate); - if (state.q) list = list.filter(i => (i.name + i.position + i.source).toLowerCase().includes(state.q.toLowerCase())); - return list; - } - - function counts() { - return { - 'All Applications': DB.inbox.length, - 'Unread': DB.inbox.filter(i => i.processing === 'Unread').length, - 'Imported': DB.inbox.filter(i => i.processing === 'Imported').length, - 'Processed': DB.inbox.filter(i => i.processing === 'Processed').length, - 'Rejected': DB.inbox.filter(i => i.processing === 'Rejected').length, - 'Duplicates': DB.inbox.filter(i => i.duplicate).length, - 'Email': (Inbox._emails || []).filter(e => e.unread).length - }; - } - - function sourceChip(item) { - const m = item.sourceMeta; - // The dot carries the partner's brand colour; the label uses theme text. - // Rendering 11px labels in the partner colour failed AA in both themes. - // `--chip` carries the source colour; CSS mixes the tint. String-concat - // alpha ("#0a66c214") breaks for the tokenised sources (var(--c1)14). - return `${item.source}`; - } - - function renderList() { - const el = document.getElementById('inboxList'); - if (!el) return; - const list = filtered(); - if (!list.length) { el.innerHTML = `
${UI.icon('inbox')}

Nothing here

No applications in this view.

`; return; } - el.innerHTML = list.map(i => ` -
- ${UI.avatar(i.name, i.initials, i.color)} -
-
${i.name} ${i.duplicate ? 'DUP' : ''}
-
${i.position}
-
${sourceChip(i)} ${UI.badge(i.processing)}
-
-
-
${DB.relTime(Math.round((new Date('2026-07-09T20:00') - i.received) / 60000))}
-
${UI.scoreChip(i.atsScore)}
-
-
`).join(''); - el.querySelectorAll('.inbox-item').forEach(row => row.onclick = () => { - state.selected = row.dataset.id; - const it = DB.inbox.find(x => x.id === state.selected); if (it) it.unread = false; - renderList(); renderDetail(); App.updateBadges(); - }); - } - - function renderDetail() { - const el = document.getElementById('inboxDetail'); - if (!el) return; - const i = DB.inbox.find(x => x.id === state.selected); - if (!i) { el.innerHTML = `
${UI.icon('inbox')}

Select an application

Choose an item from the list to view details and take action.

`; return; } - const rec = DB.atsRecommendationClass; - const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'; - el.innerHTML = ` -
-
- ${UI.avatar(i.name, i.initials, i.color, 'avatar-lg')} -
${i.name}
-
${i.position}
-
${sourceChip(i)} ${UI.badge(i.processing)} ${UI.badge(i.resumeStatus, i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber')}
-
-
-
-
${i.atsScore}
-
ATS Score
-
-
- -
-
Email
${i.email}
-
Phone
${i.phone}
-
Experience
${i.experience} years
-
Assigned Recruiter
${i.recruiter}
-
Received
${DB.fmtDate(i.received)}
-
Match
${UI.badge(recLabel, rec(recLabel))}
-
- -
-
-
${UI.icon('paperclip')} ${i.attachment}
- -
-
${Inbox._resumeText(i)}
-
- -
- - - - - - -
-
`; - } - - function renderBody() { - const body = document.getElementById('inboxBody'); - if (state.tab === 'Email') { body.innerHTML = Inbox._emailView(); Inbox._bindEmail(state); return; } - body.innerHTML = ` -
-
-
- -
-
-
-
-
`; - renderList(); renderDetail(); - const s = document.getElementById('inboxSearch'); - s.oninput = () => { state.q = s.value; renderList(); }; - } - - Inbox._render = { list: renderList, detail: renderDetail, body: renderBody }; - Inbox._state = state; - - const c = counts(); - const tabHtml = tabs.map(t => `
${t} ${c[t]}
`).join(''); - - const html = ` -
-
-

Recruitment Inbox

Every candidate, every source — one unified queue

-
- Microsoft Graph API · Connected - - -
-
-
-
${tabHtml}
-
-
-
`; - - return { - html, - onMount() { - renderBody(); - document.querySelectorAll('#inboxTabs .tab').forEach(tab => tab.onclick = () => { - state.tab = tab.dataset.tab; state.selected = null; - document.querySelectorAll('#inboxTabs .tab').forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - renderBody(); - }); - } - }; -}; - -Inbox._resumeText = function (i) { - return `${i.name.toUpperCase()}\n${i.email} · ${i.phone}\n${'—'.repeat(30)}\nPROFESSIONAL SUMMARY\n${i.experience} years of experience. Applied for ${i.position} via ${i.source}.\n\nEXPERIENCE\n• ${DB.pick(DB.companies)} — Senior role (2021–Present)\n• ${DB.pick(DB.companies)} — Associate (2018–2021)\n\nEDUCATION\n• Bachelor's Degree, Computer Science\n\nSKILLS\n• ${DB.pick(DB.skillsPool)}, ${DB.pick(DB.skillsPool)}, ${DB.pick(DB.skillsPool)}, ${DB.pick(DB.skillsPool)}`; -}; - -Inbox.viewResume = function (id) { - const i = DB.inbox.find(x => x.id === id); - UI.modal({ - title: i.attachment, subtitle: 'Resume preview · ' + i.name, - body: `
${Inbox._resumeText(i)}
`, - footer: ``, - size: 'modal-lg' - }); -}; - -Inbox.parse = function (id) { - const i = DB.inbox.find(x => x.id === id); - i.resumeStatus = 'Parsing'; - Inbox._render.detail(); - UI.toast('Parsing resume with AI…', 'info'); - setTimeout(() => { i.resumeStatus = 'Parsed'; i.atsScore = DB.int(60, 96); Inbox._render.list(); Inbox._render.detail(); UI.toast('Resume parsed — profile fields extracted', 'success'); }, 1100); -}; - -Inbox.import = function (id) { - const i = DB.inbox.find(x => x.id === id); - const job = DB.getJob(i.jobId) || DB.jobs[0]; - // create candidate - const newC = { - id: 'CAN-' + (5001 + DB.candidates.length), name: i.name, initials: i.initials, color: i.color, - email: i.email, phone: i.phone, jobId: job.id, jobTitle: job.title, department: job.department, - experience: i.experience, currentCompany: DB.pick(DB.companies), currentTitle: job.title, location: DB.pick(DB.locations), - stage: 'Applied', status: 'Applied', aiScore: i.atsScore, source: i.source, recruiter: i.recruiter, recruiterId: '', - applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: DB.int(90, 180) * 1000, - matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: i.atsScore >= 82 ? 'Strong Match' : 'Potential Match', - subScores: { skills: i.atsScore, experience: i.atsScore, education: 80, keywords: i.atsScore, location: 100, salary: 90 }, - noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled' - }; - DB.candidates.unshift(newC); - i.processing = 'Imported'; i.unread = false; - Inbox._render.list(); Inbox._render.detail(); App.updateBadges(); - UI.toast(`${i.name} imported → Applied stage of ${job.title}`, 'success'); -}; - -Inbox.moveToPipeline = function (id) { - const i = DB.inbox.find(x => x.id === id); - if (i.processing !== 'Imported' && i.processing !== 'Processed') Inbox.import(id); - i.processing = 'Processed'; - Inbox._render.list(); Inbox._render.detail(); - UI.toast(`${i.name} moved to pipeline`, 'success'); - setTimeout(() => Router.go('pipeline'), 700); -}; - -Inbox.assign = function (id) { - const i = DB.inbox.find(x => x.id === id); - const opts = DB.recruiters.map(r => ``).join(''); - UI.modal({ - title: 'Assign Recruiter', subtitle: i.name, - body: `
-

Current workload is factored automatically. This recruiter has ${DB.getRecruiterByName(i.recruiter) ? DB.getRecruiterByName(i.recruiter).openReqs : 5} open reqs.

`, - footer: `` - }); -}; -Inbox._doAssign = function (id) { - const i = DB.inbox.find(x => x.id === id); - i.recruiter = document.getElementById('assignRec').value; - UI.closeModal(); Inbox._render.detail(); - UI.toast('Recruiter assigned to ' + i.name, 'success'); -}; - -Inbox.note = function (id) { - const i = DB.inbox.find(x => x.id === id); - UI.modal({ - title: 'Add Note', subtitle: i.name, - body: `
`, - footer: `` - }); -}; - -Inbox.reject = function (id) { - const i = DB.inbox.find(x => x.id === id); - i.processing = 'Rejected'; i.unread = false; - Inbox._render.list(); Inbox._render.detail(); App.updateBadges(); - UI.toast(`${i.name} rejected`, 'warning'); -}; - -// ---------------- Email (Outlook) tab ---------------- -Inbox._emails = []; -Inbox._lastSync = null; - -Inbox._mapApiEmail = function (row) { - const from = row.sender_name || row.fromEmail || 'Unknown'; - return { - id: String(row.id), - from, - fromEmail: row.fromEmail || '', - subject: row.subject || '', - body: row.body || '', - when: row.when ? new Date(row.when) : new Date(), - unread: !!row.unread, - attachment: row.attachment_name || 'Resume.pdf', - attachmentSize: '—', - atsScore: 70, - imported: false, - jobId: null, - jobTitle: '' - }; -}; - -Inbox._syncLabel = function () { - if (!Inbox._lastSync) return 'Not synced yet'; - const mins = Math.max(0, Math.round((Date.now() - Inbox._lastSync.getTime()) / 60000)); - if (mins < 1) return 'Just now'; - return DB.relTime(mins); -}; - -Inbox._loadEmails = async function () { - const res = await Api.get('/inbox/fetch'); - const rows = Array.isArray(res.data) ? res.data : []; - Inbox._emails = rows.map(Inbox._mapApiEmail); - Inbox._lastSync = new Date(); - return Inbox._emails; -}; - -Inbox._refreshEmailCounts = function () { - const tab = document.querySelector('#inboxTabs .tab[data-tab="Email"]'); - if (tab) { - const countEl = tab.querySelector('.k-count'); - if (countEl) countEl.textContent = Inbox._emails.filter(e => e.unread).length; - } - if (window.App && App.updateBadges) App.updateBadges(); -}; - -Inbox._emailView = function () { - return ` -
- Outlook · Microsoft Graph API - Loading… - -
-
-
${UI.icon('mail')}

Loading…

Fetching mailbox from the server.

-
-
`; -}; - -Inbox._bindEmail = function (state) { - const detail = document.getElementById('emailDetail'); - - function renderDetail() { - const e = Inbox._emails.find(x => x.id === state.emailSelected); - if (!e) { detail.innerHTML = `
${UI.icon('mail')}

Select an email

Preview email body and resume attachments here.

`; return; } - detail.innerHTML = `
-
-

${e.subject}

${e.imported ? UI.badge('Imported', 'b-green') : UI.badge('New', 'b-blue')}
-
- ${UI.avatar(e.from, e.initials, e.color)} -
${e.from}
${e.fromEmail} · ${DB.fmtDate(e.when)}
-
- -
- ${UI.icon('file')} -
${e.attachment}
${e.attachmentSize} · PDF
-
${UI.scoreChip(e.atsScore)} -
-
-
- ${e.imported ? `` : - ``} - - -
-
`; - } - - function paintList() { - const list = document.getElementById('emailList'); - const meta = document.getElementById('emailSyncMeta'); - if (!list) return; - if (!Inbox._emails.length) { - list.innerHTML = `
${UI.icon('mail')}

Nothing here

No emails in the mailbox.

`; - } else { - list.innerHTML = Inbox._emails.map(e => ` -
- ${UI.avatar(e.from, e.initials, e.color)} -
-
${e.from}
-
${e.subject}
-
Outlook${e.imported ? UI.badge('Imported', 'b-green') : ''}
-
-
${DB.fmtShort(e.when)}
-
`).join(''); - list.querySelectorAll('.inbox-item').forEach(row => row.onclick = () => { - state.emailSelected = row.dataset.email; - const e = Inbox._emails.find(x => x.id === state.emailSelected); if (e) e.unread = false; - list.querySelectorAll('.inbox-item').forEach(r => r.classList.remove('active', 'unread')); - row.classList.add('active'); - renderDetail(); Inbox._refreshEmailCounts(); - }); - } - if (meta) meta.textContent = `Last sync: ${Inbox._syncLabel()} · ${Inbox._emails.filter(e => e.unread).length} unread`; - renderDetail(); - Inbox._refreshEmailCounts(); - } - - Inbox._paintEmail = paintList; - renderDetail(); - - Inbox._loadEmails() - .then(() => paintList()) - .catch(err => { - const list = document.getElementById('emailList'); - const meta = document.getElementById('emailSyncMeta'); - if (list) list.innerHTML = `
${UI.icon('mail')}

Couldn't load mailbox

${err.message || 'Request failed'}

`; - if (meta) meta.textContent = 'Sync failed'; - UI.toast(err.message || 'Failed to load mailbox', 'error'); - renderDetail(); - }); -}; - -Inbox.syncMailbox = async function () { - UI.toast('Fetching from Outlook…', 'info'); - try { - await Inbox._loadEmails(); - if (typeof Inbox._paintEmail === 'function') Inbox._paintEmail(); - UI.toast('Mailbox synced', 'success'); - } catch (err) { - UI.toast(err.message || 'Sync failed', 'error'); - } -}; - -Inbox._importEmail = function (id) { - const e = Inbox._emails.find(x => x.id === id); - if (!e) return; - const job = DB.getJob(e.jobId) || DB.jobs[0]; - DB.candidates.unshift({ - id: 'CAN-' + (5001 + DB.candidates.length), name: e.from, initials: e.initials || DB.initials(e.from), color: e.color || DB.avatarColor(e.from), - email: e.fromEmail, phone: '+1 (555) 000-0000', jobId: job.id, jobTitle: job.title, department: job.department, - experience: DB.int(2, 10), currentCompany: DB.pick(DB.companies), currentTitle: job.title, location: DB.pick(DB.locations), - stage: 'Applied', status: 'Applied', aiScore: e.atsScore, source: 'Microsoft Outlook', recruiter: DB.pick(DB.recruiters).name, recruiterId: '', - applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000, - matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: 'Potential Match', - subScores: { skills: e.atsScore, experience: 80, education: 80, keywords: e.atsScore, location: 100, salary: 90 }, - noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled' - }); - e.imported = true; e.unread = false; - if (typeof Inbox._paintEmail === 'function') Inbox._paintEmail(); - App.updateBadges(); - UI.toast(`${e.from} imported from Outlook → ${job.title}`, 'success'); -}; diff --git a/js/interviews.js b/js/interviews.js deleted file mode 100644 index 54ae7b1..0000000 --- a/js/interviews.js +++ /dev/null @@ -1,209 +0,0 @@ -/* ============================================================ - interviews.js — Interviews list, upcoming, mini calendar - ============================================================ */ -window.Views = window.Views || {}; -window.Interviews = {}; - -Views.interviews = function () { - const filters = { q: '', status: '', type: '' }; - let table; - - const upcoming = DB.interviews.filter(iv => iv.status === 'Scheduled').slice(0, 4); - const stats = { - scheduled: DB.interviews.filter(i => i.status === 'Scheduled').length, - completed: DB.interviews.filter(i => i.status === 'Completed').length, - today: 5, - cancelled: DB.interviews.filter(i => ['Cancelled', 'No Show'].includes(i.status)).length - }; - - function apply() { - const rows = DB.interviews.filter(iv => { - if (filters.status && iv.status !== filters.status) return false; - if (filters.type && iv.type !== filters.type) return false; - if (filters.q && !(iv.candidate + iv.jobTitle + iv.interviewers.join(' ')).toLowerCase().includes(filters.q.toLowerCase())) return false; - return true; - }); - table.update(rows); - } - - table = UI.dataTable({ - pageSize: 8, - rows: DB.interviews, - columns: [ - { key: 'candidate', label: 'Candidate', sortable: true, render: iv => `
${UI.avatar(iv.candidate, iv.candInitials, iv.color)}
${iv.candidate}
${iv.jobTitle}
` }, - { key: 'type', label: 'Round', sortable: true, render: iv => UI.badge(iv.type, 'b-indigo') }, - { key: 'when', label: 'Date & Time', sortable: true, sortValue: iv => iv.when.getTime(), render: iv => `
${DB.fmtShort(iv.when)}
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} · ${iv.duration}m
` }, - { key: 'meeting', label: 'Type', render: iv => `${UI.icon(iv.meeting === 'Video Call' ? 'video' : iv.meeting === 'Phone' ? 'phone' : 'map')} ${iv.meeting}` }, - { key: 'interviewers', label: 'Interviewers', render: iv => UI.avatarStack(iv.interviewers) }, - { key: 'status', label: 'Status', sortable: true, render: iv => UI.badge(iv.status) }, - { key: 'feedback', label: 'Feedback', render: iv => iv.feedback ? UI.badge(iv.feedback) : '' }, - { key: '_a', label: 'Actions', align: 'right', render: iv => ` -
- - -
` } - ] - }); - - const statusOpts = [''].concat(['Scheduled', 'Completed', 'Cancelled', 'No Show'].map(s => ``)).join(''); - const typeOpts = [''].concat(DB.interviewTypes.map(t => ``)).join(''); - - const statCard = (label, val, icn, cls) => `
${label}${UI.icon(icn)}
${val}
`; - - const upcomingHtml = upcoming.map(iv => ` -
- ${UI.avatar(iv.candidate, iv.candInitials, iv.color)} -
${iv.candidate}
${iv.type} · ${iv.meeting}
-
${DB.fmtShort(iv.when)}
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
-
`).join(''); - - const html = ` -
-
-

Interviews

Manage and track all interview activity

-
- - -
-
-
- ${statCard('Scheduled', stats.scheduled, 'calendar', 'i-blue')} - ${statCard('Completed', stats.completed, 'check-circle', 'i-green')} - ${statCard('Today', stats.today, 'clock', 'i-purple')} - ${statCard('Cancelled / No-show', stats.cancelled, 'x-circle', 'i-red')} -
-
-
-

All Interviews

-
-
- - - -
-
- ${table.html} -
-
-

Up Next

Scheduled sessions
-
${upcomingHtml}
-
-
-
`; - - return { - html, - onMount() { - table.mount(); - const s = document.getElementById('ivSearch'); - s.oninput = () => { filters.q = s.value; apply(); }; - document.getElementById('ivStatus').onchange = e => { filters.status = e.target.value; apply(); }; - document.getElementById('ivType').onchange = e => { filters.type = e.target.value; apply(); }; - } - }; -}; - -Interviews.feedback = function (id) { - const iv = DB.interviews.find(i => i.id === id); - // pick evaluation template by department - const job = DB.jobs.find(j => j.title === iv.jobTitle); - const dept = job ? job.department : 'All'; - const tmpl = DB.evalTemplates.find(t => t.dept === dept) || DB.evalTemplates.find(t => t.dept === 'All'); - const tmplOpts = DB.evalTemplates.map(t => ``).join(''); - - const ratingRow = (crit) => ` -
-

${crit}

-
- ${[1, 2, 3, 4, 5].map(n => `${UI.icon('star')}`).join('')} -
-
`; - - const body = ` -
${UI.avatar(iv.candidate, iv.candInitials, iv.color, 'avatar-lg')} -
${iv.candidate}
${iv.type} · ${iv.jobTitle}
- ${UI.badge(iv.status)}
- -
-
Dynamic Form
-
Upload Sheet
-
Both
-
- -
-
-
-
${tmpl.criteria.map(ratingRow).join('')}
-
-
-
-
- -
-
-
${UI.icon('upload')}
-

Upload evaluation sheet

-

PDF, DOC, or DOCX · scanned scorecards supported

-
${['PDF', 'DOC', 'DOCX'].map(t => `${t}`).join('')}
-
-
- -
-

Capture structured ratings and attach a signed sheet — both are stored on the scorecard.

-
${tmpl.criteria.slice(0, 3).map(ratingRow).join('')}
-
${UI.icon('file')} -
Interviewer_Scorecard.pdf
Attached · 214 KB
${UI.badge('Uploaded', 'b-green')}
-
-
`; - - const footer = ` - `; - UI.modal({ title: 'Interview Evaluation', subtitle: iv.id + ' · ' + iv.type, body, footer, size: 'modal-lg' }); - - // wire tabs - const panes = document.querySelectorAll('#evalPanes .tab-pane'); - document.querySelectorAll('#evalTabs .tab').forEach(tab => tab.onclick = () => { - document.querySelectorAll('#evalTabs .tab').forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - panes.forEach(p => p.classList.remove('active')); - panes[+tab.dataset.etab].classList.add('active'); - }); - // wire star ratings - Interviews._bindStars(); - // template switch rebuilds criteria - const tmplSel = document.getElementById('evalTmpl'); - if (tmplSel) tmplSel.onchange = () => { - const t = DB.evalTemplates.find(x => x.name === tmplSel.value); - document.getElementById('critList').innerHTML = t.criteria.map(ratingRow).join(''); - Interviews._bindStars(); - }; - // recommendation seg - document.querySelectorAll('[data-rec]').forEach(b => b.onclick = () => { - b.parentElement.querySelectorAll('button').forEach(x => x.classList.remove('active')); - b.classList.add('active'); - }); -}; -Interviews._bindStars = function () { - document.querySelectorAll('.rating-stars').forEach(group => { - group.querySelectorAll('.rs').forEach(star => star.onclick = () => { - const val = +star.dataset.val; - group.querySelectorAll('.rs').forEach(s => s.classList.toggle('on', +s.dataset.val <= val)); - }); - }); -}; - -Interviews.schedule = function () { - const opt = arr => arr.map(o => ``).join(''); - const body = `
-
-
-
-
-
-
-
-
`; - const footer = ` - `; - UI.modal({ title: 'Schedule Interview', subtitle: 'Set up a new interview session', body, footer }); -}; diff --git a/js/jobboard.js b/js/jobboard.js deleted file mode 100644 index efaf466..0000000 --- a/js/jobboard.js +++ /dev/null @@ -1,172 +0,0 @@ -/* ============================================================ - jobboard.js — Job Posting Center: publish + track performance - ============================================================ */ -window.Views = window.Views || {}; -window.JobBoard = {}; - -Views.jobboard = function () { - const totals = DB.publishings.reduce((a, p) => ({ views: a.views + p.views, clicks: a.clicks + p.clicks, apps: a.apps + p.apps }), { views: 0, clicks: 0, apps: 0 }); - const conv = totals.views ? ((totals.apps / totals.views) * 100).toFixed(1) : 0; - - const statCard = (label, val, icn, cls, sub) => `
${label}${UI.icon(icn)}
${val}
${sub}
`; - - // per-platform aggregate - const platAgg = {}; - DB.publishings.forEach(p => { - if (!platAgg[p.platform]) platAgg[p.platform] = { views: 0, clicks: 0, apps: 0, jobs: 0 }; - platAgg[p.platform].views += p.views; platAgg[p.platform].clicks += p.clicks; platAgg[p.platform].apps += p.apps; platAgg[p.platform].jobs++; - }); - const platRows = Object.entries(platAgg).sort((a, b) => b[1].apps - a[1].apps); - - // publishing table - const table = UI.dataTable({ - pageSize: 8, - rows: DB.publishings, - columns: [ - { key: 'jobTitle', label: 'Job', sortable: true, render: p => `
${p.jobTitle}
${p.jobId}
` }, - { key: 'platform', label: 'Platform', sortable: true, render: p => { const pl = DB.publishPlatforms.find(x => x.name === p.platform) || {}; return `${p.platform}`; } }, - { key: 'status', label: 'Status', sortable: true, render: p => UI.badge(p.status, p.status === 'Live' ? 'b-green' : p.status === 'Paused' ? 'b-amber' : 'b-blue') }, - { key: 'views', label: 'Views', sortable: true, align: 'right', render: p => p.views.toLocaleString() }, - { key: 'clicks', label: 'Clicks', sortable: true, align: 'right', render: p => p.clicks.toLocaleString() }, - { key: 'apps', label: 'Applications', sortable: true, align: 'right', render: p => `${p.apps}` }, - { key: '_conv', label: 'Conversion', sortable: true, sortValue: p => p.apps / p.views, render: p => `${((p.apps / p.views) * 100).toFixed(1)}%` }, - { key: '_a', label: '', align: 'right', render: p => `` } - ] - }); - - const html = ` -
-
-

Job Board

Publish requisitions across channels and track performance

-
- - -
-
- -
- ${statCard('Total Views', totals.views.toLocaleString(), 'eye', 'i-blue', 'across all platforms')} - ${statCard('Total Clicks', totals.clicks.toLocaleString(), 'target', 'i-purple', ((totals.clicks / totals.views) * 100).toFixed(1) + '% CTR')} - ${statCard('Applications', totals.apps.toLocaleString(), 'users', 'i-green', 'from job boards')} - ${statCard('Conversion Rate', conv + '%', 'trending-up', 'i-teal', 'view → application')} -
- -
-
-

Platform Performance

Applications by channel
-
-
-
-

Connected Platforms

-
- ${DB.publishPlatforms.map(p => `
- -
${p.name}
${p.cost === 'Free' ? 'Free posting' : 'Paid · ' + p.cost}
- ${p.connected ? UI.badge('Connected', 'b-green') : ``} -
`).join('')} -
-
-
- -
-

Active Postings

${DB.publishings.length} live postings across ${platRows.length} platforms
-
- ${table.html} -
-
`; - - return { - html, - onMount() { - table.mount(); - Charts.horizontalBar(document.getElementById('jbChart'), { - labels: platRows.map(p => p[0]), data: platRows.map(p => p[1].apps) - }); - } - }; -}; - -// ---------------- Publish flow (stepper modal) ---------------- -JobBoard.publishFlow = function (jobId) { - const state = { step: 1, jobId: jobId || DB.jobs.filter(j => j.status === 'Open')[0].id, platforms: ['Career Portal'] }; - JobBoard._state = state; - JobBoard._renderFlow(); -}; - -JobBoard._renderFlow = function () { - const state = JobBoard._state; - const steps = ['Select Job', 'Approval', 'Platforms', 'Publish']; - const stepper = `
${steps.map((s, i) => { - const n = i + 1; - const cls = n < state.step ? 'done' : n === state.step ? 'active' : ''; - return `
${n < state.step ? '✓' : n}
${s}
${i < steps.length - 1 ? `
` : ''}`; - }).join('')}
`; - - let body = stepper; - if (state.step === 1) { - const opts = DB.jobs.filter(j => j.status !== 'Draft').map(j => ``).join(''); - const job = DB.getJob(state.jobId); - body += `
-
-
${UI.icon('briefcase')} -
${job.title}
${job.department} · ${job.location} · ${job.type}
-
`; - } else if (state.step === 2) { - body += `
-
${UI.icon('check-circle')} -
Approval granted
Approved by Department Head · Budget confirmed
-

Hiring Manager sign-off

${UI.badge('Approved', 'b-green')}
-

Finance budget approval

${UI.badge('Approved', 'b-green')}
-

Compliance review

${UI.badge('Approved', 'b-green')}
-
`; - } else if (state.step === 3) { - body += `

Select the platforms to publish this role to

-
- ${DB.publishPlatforms.map(p => `
- -
${p.name}
${p.cost === 'Free' ? 'Free' : 'Paid · ' + p.cost}
- ${UI.icon('check')} -
`).join('')} -
`; - } else if (state.step === 4) { - body += `
-
${UI.icon('check-circle')}
-

Published Successfully

-

${DB.getJob(state.jobId).title} is now live on ${state.platforms.length} platform${state.platforms.length > 1 ? 's' : ''}

-
- ${state.platforms.map(p => { const pl = DB.publishPlatforms.find(x => x.name === p); return `${pl.name}`; }).join('')} -
-
`; - } - - let footer; - if (state.step === 4) footer = ``; - else footer = ` - `; - - UI.modal({ title: 'Publish Job', subtitle: 'Distribute this requisition to job boards', body, footer, size: 'modal-lg' }); - - if (state.step === 3) { - document.querySelectorAll('#platGrid .platform-card').forEach(card => card.onclick = () => { - const name = card.dataset.plat; - const idx = state.platforms.indexOf(name); - if (idx > -1) state.platforms.splice(idx, 1); else state.platforms.push(name); - card.classList.toggle('selected'); - }); - } -}; -JobBoard._next = function () { - const state = JobBoard._state; - if (state.step === 1) { const sel = document.getElementById('pubJob'); if (sel) state.jobId = sel.value; } - if (state.step === 3 && !state.platforms.length) { UI.toast('Select at least one platform', 'warning'); return; } - state.step++; - if (state.step === 4) { - // create publishing records - const job = DB.getJob(state.jobId); - state.platforms.forEach(p => { - DB.publishings.unshift({ jobId: job.id, jobTitle: job.title, platform: p, status: 'Live', views: DB.int(0, 30), clicks: 0, apps: 0, published: new Date('2026-07-09') }); - }); - } - JobBoard._renderFlow(); -}; -JobBoard._back = function () { JobBoard._state.step--; JobBoard._renderFlow(); }; diff --git a/js/jobs.js b/js/jobs.js deleted file mode 100644 index ff58987..0000000 --- a/js/jobs.js +++ /dev/null @@ -1,252 +0,0 @@ -/* ============================================================ - jobs.js — Jobs listing, filters, create/edit/view/delete - ============================================================ */ -window.Views = window.Views || {}; -window.Jobs = {}; - -Views.jobs = function () { - const filters = { q: '', dept: '', status: '', type: '' }; - let table; - - function apply() { - let rows = DB.jobs.filter(j => { - if (filters.dept && j.department !== filters.dept) return false; - if (filters.status && j.status !== filters.status) return false; - if (filters.type && j.type !== filters.type) return false; - if (filters.q) { - const q = filters.q.toLowerCase(); - if (!(j.title + j.id + j.department + j.manager + j.recruiter + j.location).toLowerCase().includes(q)) return false; - } - return true; - }); - table.update(rows); - } - - table = UI.dataTable({ - pageSize: 8, - rows: DB.jobs, - columns: [ - { key: 'id', label: 'Job ID', sortable: true, render: j => `${j.id}` }, - { key: 'title', label: 'Job Title', sortable: true, render: j => `
${j.title}
${j.businessUnit} · ${j.grade}
` }, - { key: 'department', label: 'Department', sortable: true }, - { key: 'manager', label: 'Hiring Manager', sortable: true, render: j => `
${UI.avatar(j.manager)}${j.manager}
` }, - { key: 'location', label: 'Location', sortable: true, render: j => `${j.location}` }, - { key: 'type', label: 'Type', render: j => UI.badge(j.type, 'b-gray') }, - { key: 'applications', label: 'Apps', sortable: true, align: 'center', render: j => `${j.applications}` }, - { key: 'status', label: 'Status', sortable: true, render: j => UI.badge(j.status) }, - { key: 'created', label: 'Created', sortable: true, sortValue: j => j.created.getTime(), render: j => `${DB.fmtShort(j.created)}` }, - { key: '_a', label: 'Actions', align: 'right', render: j => ` -
- - - - -
` } - ] - }); - - const deptOpts = [''].concat(DB.departments.map(d => ``)).join(''); - const statusOpts = [''].concat(DB.jobStatuses.map(s => ``)).join(''); - const typeOpts = [''].concat(DB.empTypes.map(t => ``)).join(''); - - const html = ` -
-
-

Jobs

${DB.jobs.length} requisitions · ${DB.kpis.openJobs} currently open

-
- - -
-
-
-
-
- - - - -
-
- ${table.html} -
-
`; - - return { - html, - onMount() { - table.mount(); - const s = document.getElementById('jobSearch'); - s.oninput = () => { filters.q = s.value; apply(); }; - document.getElementById('jobDept').onchange = e => { filters.dept = e.target.value; apply(); }; - document.getElementById('jobStatus').onchange = e => { filters.status = e.target.value; apply(); }; - document.getElementById('jobType').onchange = e => { filters.type = e.target.value; apply(); }; - } - }; -}; - -// ---------------- View job ---------------- -Jobs.view = function (id) { - const j = DB.getJob(id); - const body = ` -
- ${UI.icon('briefcase')} -
-
${j.title}
-
${j.id} · ${j.department} · ${j.businessUnit}
-
-
${UI.badge(j.status)}
-
-
-
Hiring Manager
${j.manager}
-
Assigned Recruiter
${j.recruiter} ${(() => { const r = DB.getRecruiterByName(j.recruiter); return r ? `${r.workload}% load` : ''; })()}
-
Location
${j.location}
-
Employment Type
${j.type}
-
Grade
${j.grade}
-
Vacancies
${j.vacancies}
-
Salary Range
${DB.moneyK(j.salaryMin)} – ${DB.moneyK(j.salaryMax)}
-
Experience
${j.experience}
-
Education
${j.education}
-
Deadline
${DB.fmtDate(j.deadline)}
-
-
-
Description

${j.description}

-
Key Responsibilities
-
    ${j.responsibilities.map(r => `
  • ${r}
  • `).join('')}
-
Required Skills
-
${j.skills.map(s => `${s}`).join('')}
-
Benefits
-
${j.benefits.map(s => `${s}`).join('')}
-
-
Hiring progress
${UI.pbar(j.progress)}
${j.progress}%
`; - const footer = ` - - `; - UI.modal({ title: 'Job Details', subtitle: j.id, body, footer, size: 'modal-lg' }); -}; - -Jobs.reassign = function (id) { - const j = DB.getJob(id); - const opts = DB.recruiters.map(r => ``).join(''); - UI.modal({ - title: 'Reassign Recruiter', subtitle: j.title, - body: `
-

Workload is recalculated automatically across the recruiter's assigned requisitions.

`, - footer: `` - }); -}; -Jobs._doReassign = function (id) { - const j = DB.getJob(id); - j.recruiter = document.getElementById('reassignRec').value; - UI.closeModal(); UI.toast('Recruiter reassigned', 'success'); - if (typeof Router !== 'undefined') Router.reload(); -}; - -// ---------------- Create / Edit form ---------------- -Jobs.openCreate = function () { Jobs._form(null); }; -Jobs.openEdit = function (id) { Jobs._form(DB.getJob(id)); }; - -Jobs._form = function (job) { - const isEdit = !!job; - const opt = (arr, sel) => arr.map(o => ``).join(''); - const body = ` -
-
-
- - - Job title is required -
-
-
-
-
-
-
-
-
-
-
- - Enter a valid amount
-
-
-
-
-
-
- - Description is required
-
-
-
-
-
-
-
-
-
-
`; - const footer = ` - `; - UI.modal({ title: isEdit ? 'Edit Job' : 'Create New Job', subtitle: isEdit ? job.id : 'Fill in the details to post a requisition', body, footer, size: 'modal-lg' }); -}; - -Jobs._save = function (id) { - const form = document.getElementById('jobForm'); - UI.clearErrors(form); - const f = Object.fromEntries(new FormData(form)); - let ok = true; - const req = (name, cond) => { if (!cond) { UI.fieldError(form.querySelector(`[name="${name}"]`), 'Required'); ok = false; } }; - req('title', f.title.trim()); - req('description', f.description.trim()); - req('salaryMin', f.salaryMin && +f.salaryMin > 0); - if (!ok) { UI.toast('Please fix the highlighted fields', 'error'); return; } - - const skills = f.skills.split(',').map(s => s.trim()).filter(Boolean); - const benefits = f.benefits.split(',').map(s => s.trim()).filter(Boolean); - const responsibilities = f.responsibilities.split('\n').map(s => s.trim()).filter(Boolean); - - if (id) { - const job = DB.getJob(id); - Object.assign(job, { - title: f.title, department: f.department, businessUnit: f.businessUnit, grade: f.grade, type: f.type, - manager: f.manager, recruiter: f.recruiter, salaryMin: +f.salaryMin, salaryMax: +f.salaryMax || +f.salaryMin + 20000, - experience: f.experience, education: f.education, location: f.location, vacancies: +f.vacancies || 1, - description: f.description, responsibilities, skills: skills.length ? skills : job.skills, benefits: benefits.length ? benefits : job.benefits, status: f.status - }); - UI.toast('Job updated successfully', 'success'); - } else { - const newJob = { - id: 'JOB-' + (1001 + DB.jobs.length), title: f.title, department: f.department, businessUnit: f.businessUnit, - grade: f.grade, manager: f.manager, managerId: '', recruiter: f.recruiter, recruiterId: '', location: f.location, - type: f.type, vacancies: +f.vacancies || 1, applications: 0, status: f.status, created: new Date('2026-07-09'), - deadline: f.deadline ? new Date(f.deadline) : new Date('2026-08-09'), salaryMin: +f.salaryMin, salaryMax: +f.salaryMax || +f.salaryMin + 20000, - experience: f.experience || '3+ years', education: f.education, skills, benefits, description: f.description, - responsibilities: responsibilities.length ? responsibilities : ['Own key projects'], progress: 0 - }; - DB.jobs.unshift(newJob); - UI.toast('Job created successfully', 'success'); - App.updateBadges(); - } - UI.closeModal(); - Router.reload(); -}; - -Jobs.confirmDelete = function (id) { - const j = DB.getJob(id); - const body = `
- ${UI.icon('trash')} -

Delete "${j.title}"?

-

This will permanently remove requisition ${j.id} and its ${j.applications} applications. This action cannot be undone.

`; - const footer = ` - `; - UI.modal({ title: 'Confirm Deletion', body, footer }); -}; -Jobs._delete = function (id) { - const i = DB.jobs.findIndex(j => j.id === id); - if (i > -1) DB.jobs.splice(i, 1); - UI.closeModal(); - UI.toast('Job deleted', 'success'); - App.updateBadges(); - Router.reload(); -}; diff --git a/js/misc.js b/js/misc.js deleted file mode 100644 index 0edc541..0000000 --- a/js/misc.js +++ /dev/null @@ -1,207 +0,0 @@ -/* ============================================================ - misc.js — Hiring Managers, Calendar, Notifications, Help - ============================================================ */ -window.Views = window.Views || {}; - -// ---------------- Hiring Managers ---------------- -Views.managers = function () { - function card(m) { - const jobs = DB.jobs.filter(j => j.manager === m.name && j.status === 'Open'); - return `
-
-
- ${UI.avatar(m.name, m.initials, m.color, 'avatar-lg')} -
${m.name}
${m.title}
-
-
-
${m.openReqs}Open Reqs
-
${m.teamSize}Team Size
-
-
-
- ${UI.icon('mail')} ${m.email.split('@')[0]} - -
-
-
`; - } - const html = ` -
-
-

Hiring Managers

${DB.managers.length} managers · ${DB.managers.reduce((s, m) => s + m.openReqs, 0)} active requisitions

-
-
-
${DB.managers.map(card).join('')}
-
`; - return { html }; -}; -Views._mgrDetail = function (id) { - const m = DB.getManager(id); - const jobs = DB.jobs.filter(j => j.manager === m.name); - const body = ` -
${UI.avatar(m.name, m.initials, m.color, 'avatar-lg')} -
${m.name}
${m.title}
-
${UI.badge(m.department, 'b-indigo')}${m.teamSize} reports
-
-
${m.openReqs}Open Reqs
-
${jobs.length}Total Jobs
-
${jobs.reduce((s, j) => s + j.applications, 0)}Applications
-
-
Hiring Manager Portal
-
- - - - -
-
Requisitions
-
${jobs.length ? jobs.map(j => `
- ${UI.icon('briefcase')} -
${j.title}
${j.applications} applications
${UI.badge(j.status)}
`).join('') : '

No requisitions

'}
`; - UI.modal({ title: 'Hiring Manager', subtitle: m.id, body, size: 'modal-lg', footer: `` }); -}; - -// ---------------- Calendar ---------------- -Views.calendar = function () { - const state = { month: 6, year: 2026 }; // July 2026 (0-indexed) - const evColors = { 'Phone Screen': 'b-blue', 'Technical': 'b-indigo', 'System Design': 'b-purple', 'Onsite Loop': 'b-teal', 'Hiring Manager': 'b-amber', 'Culture Fit': 'b-green', 'Final Round': 'b-red' }; - - function build() { - const first = new Date(state.year, state.month, 1); - const startDow = first.getDay(); - const daysInMonth = new Date(state.year, state.month + 1, 0).getDate(); - const prevDays = new Date(state.year, state.month, 0).getDate(); - const cells = []; - for (let i = startDow - 1; i >= 0; i--) cells.push({ day: prevDays - i, other: true }); - for (let d = 1; d <= daysInMonth; d++) cells.push({ day: d, other: false, date: new Date(state.year, state.month, d) }); - while (cells.length % 7 !== 0 || cells.length < 42) cells.push({ day: cells.length - daysInMonth - startDow + 1, other: true }); - - const today = new Date('2026-07-09'); - const dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - let html = dow.map(d => `
${d}
`).join(''); - cells.slice(0, 42).forEach(c => { - let evs = ''; - if (!c.other && c.date) { - const dayEvents = DB.interviews.filter(iv => iv.when.toDateString() === c.date.toDateString()); - evs = dayEvents.slice(0, 3).map(iv => `
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} ${iv.candidate.split(' ')[0]}
`).join(''); - if (dayEvents.length > 3) evs += `
+${dayEvents.length - 3} more
`; - } - const isToday = !c.other && c.date && c.date.toDateString() === today.toDateString(); - html += `
${c.day}
${evs}
`; - }); - return html; - } - - const monthName = new Date(state.year, state.month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); - const todayIvs = DB.interviews.filter(iv => iv.when.toDateString() === new Date('2026-07-09').toDateString()); - - const html = ` -
-
-

Calendar

Interview schedule at a glance

-
-
- - ${monthName} - -
- -
-
-
-
${build()}
-

Today

July 9, 2026
-
${todayIvs.length ? todayIvs.map(iv => ` -
${UI.avatar(iv.candidate, iv.candInitials, iv.color)} -
${iv.candidate}
${iv.type}
-
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
`).join('') : '

No interviews today

'}
-
-
-
`; - - return { - html, - onMount() { - const upd = () => { - document.getElementById('calGrid').innerHTML = build(); - document.getElementById('calMonth').textContent = new Date(state.year, state.month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); - }; - document.getElementById('calPrev').onclick = () => { state.month--; if (state.month < 0) { state.month = 11; state.year--; } upd(); }; - document.getElementById('calNext').onclick = () => { state.month++; if (state.month > 11) { state.month = 0; state.year++; } upd(); }; - } - }; -}; - -// ---------------- Notifications ---------------- -Views.notifications = function () { - const rows = DB.notifications.map((n, i) => ` -
- ${UI.icon(n.icon)} -
${n.title}
${n.text}
${n.time}
- ${n.unread ? '' : ''} -
`).join(''); - const html = ` -
-
-

Notifications

Stay on top of hiring activity

-
- - -
-
-
${rows}
-
`; - return { html }; -}; - -// ---------------- Help ---------------- -Views.help = function () { - const faqs = [ - { q: 'How do I create a new job requisition?', a: 'Navigate to Jobs and click "Create Job". Fill in the required fields marked with an asterisk and click Save. The job will immediately appear in your listings.' }, - { q: 'How does the AI candidate score work?', a: 'The AI score (0–100) evaluates how well a candidate matches the job requirements based on skills, experience, and education. Higher scores indicate stronger matches.' }, - { q: 'Can I move candidates between pipeline stages?', a: 'Yes. Open the Pipeline view and simply drag any candidate card between stage columns. The candidate\'s status updates automatically.' }, - { q: 'How do I schedule an interview?', a: 'Go to Interviews or Calendar and click "Schedule Interview". Select the candidate, round, date, time, and interviewers.' }, - { q: 'How do I export reports?', a: 'On the Reports page, use the "Export Report" button for a full PDF, or the CSV buttons on individual tables.' } - ]; - const resources = [ - { icn: 'file', t: 'Documentation', d: 'Complete product guides', cls: 'i-indigo' }, - { icn: 'video', t: 'Video Tutorials', d: 'Watch step-by-step walkthroughs', cls: 'i-red' }, - { icn: 'message', t: 'Live Chat', d: 'Chat with our support team', cls: 'i-green' }, - { icn: 'users', t: 'Community', d: 'Connect with other recruiters', cls: 'i-purple' } - ]; - const html = ` -
-

Help Center

Find answers and get support

-
-
-

How can we help you?

-

Search our knowledge base or browse the topics below

- -
-
-
- ${resources.map(r => `
- ${UI.icon(r.icn)} -
${r.t}
${r.d}
`).join('')} -
-
-

Frequently Asked Questions

-
- ${faqs.map((f, i) => `
-

${f.q}

${UI.icon('chevron-right')}
-
`).join('')} -
-
-
`; - return { html }; -}; -Views._toggleFaq = function (i) { - const a = document.getElementById('faqA' + i); - const chev = document.getElementById('faqChev' + i); - const open = a.style.display === 'block'; - a.style.display = open ? 'none' : 'block'; - chev.style.transform = open ? 'rotate(0deg)' : 'rotate(90deg)'; -}; diff --git a/js/offers.js b/js/offers.js deleted file mode 100644 index ddb61a8..0000000 --- a/js/offers.js +++ /dev/null @@ -1,139 +0,0 @@ -/* ============================================================ - offers.js — Offer management - ============================================================ */ -window.Views = window.Views || {}; -window.Offers = {}; - -Views.offers = function () { - const filters = { q: '', status: '' }; - let table; - - const stats = { - sent: DB.offers.filter(o => o.status !== 'Draft').length, - accepted: DB.offers.filter(o => o.status === 'Accepted').length, - pending: DB.offers.filter(o => ['Sent', 'Negotiating'].includes(o.status)).length, - rate: Math.round(DB.offers.filter(o => o.status === 'Accepted').length / (DB.offers.filter(o => ['Accepted', 'Declined'].includes(o.status)).length || 1) * 100) - }; - - function apply() { - const rows = DB.offers.filter(o => { - if (filters.status && o.status !== filters.status) return false; - if (filters.q && !(o.candidate + o.jobTitle + o.recruiter).toLowerCase().includes(filters.q.toLowerCase())) return false; - return true; - }); - table.update(rows); - } - - table = UI.dataTable({ - pageSize: 8, - rows: DB.offers, - columns: [ - { key: 'candidate', label: 'Candidate', sortable: true, render: o => `
${UI.avatar(o.candidate, o.initials, o.color)}
${o.candidate}
${o.jobTitle}
` }, - { key: 'department', label: 'Department', sortable: true }, - { key: 'base', label: 'Base Salary', sortable: true, align: 'right', render: o => `${DB.money(o.base)}` }, - { key: 'equity', label: 'Equity', render: o => `${o.equity}` }, - { key: 'bonus', label: 'Bonus', align: 'center', render: o => `${o.bonus}` }, - { key: 'sent', label: 'Sent', sortable: true, sortValue: o => o.sent.getTime(), render: o => `${DB.fmtShort(o.sent)}` }, - { key: 'status', label: 'Status', sortable: true, render: o => UI.badge(o.status) }, - { key: '_a', label: 'Actions', align: 'right', render: o => ` -
- - -
` } - ] - }); - - const statusOpts = [''].concat(['Sent', 'Accepted', 'Negotiating', 'Declined', 'Draft', 'Expired'].map(s => ``)).join(''); - const statCard = (label, val, icn, cls) => `
${label}${UI.icon(icn)}
${val}
`; - - const html = ` -
-
-

Offers

Track offer letters and acceptance

-
-
-
- ${statCard('Offers Sent', stats.sent, 'send', 'i-indigo')} - ${statCard('Accepted', stats.accepted, 'check-circle', 'i-green')} - ${statCard('Awaiting Response', stats.pending, 'clock', 'i-amber')} - ${statCard('Acceptance Rate', stats.rate + '%', 'trending-up', 'i-teal')} -
-
-
-
- - -
-
- ${table.html} -
-
`; - - return { - html, - onMount() { - table.mount(); - const s = document.getElementById('ofSearch'); - s.oninput = () => { filters.q = s.value; apply(); }; - document.getElementById('ofStatus').onchange = e => { filters.status = e.target.value; apply(); }; - } - }; -}; - -Offers.view = function (id) { - const o = DB.offers.find(x => x.id === id); - const total = o.base + Math.round(o.base * parseInt(o.bonus) / 100); - const body = ` -
${UI.avatar(o.candidate, o.initials, o.color, 'avatar-lg')} -
${o.candidate}
${o.jobTitle} · ${o.department}
-
${UI.badge(o.status)}
-
-
Compensation Package
-
-
Base Salary
${DB.money(o.base)}
-
Annual Bonus
${o.bonus}
-
Equity
${o.equity}
-
Est. Total Cash
${DB.money(total)}
-
-
-
-
Sent On
${DB.fmtDate(o.sent)}
-
Expires
${DB.fmtDate(o.expires)}
-
Recruiter
${o.recruiter}
-
Offer ID
${o.id}
-
`; - const footer = ` - - `; - UI.modal({ title: 'Offer Details', subtitle: o.id, body, footer, size: 'modal-lg' }); -}; - -Offers.create = function () { - const opt = arr => arr.map(o => ``).join(''); - const body = `
-
-
Required
-
-
-
-
-
`; - const footer = ` - `; - UI.modal({ title: 'Create Offer', subtitle: 'Generate and send an offer letter', body, footer }); -}; -Offers._save = function () { - const form = document.getElementById('offerForm'); - UI.clearErrors(form); - const f = Object.fromEntries(new FormData(form)); - if (!f.base || +f.base <= 0) { UI.fieldError(form.querySelector('[name=base]'), 'Required'); UI.toast('Enter a base salary', 'error'); return; } - const cand = DB.candidates.find(c => c.name === f.candidate) || DB.candidates[0]; - DB.offers.unshift({ - id: 'OFR-' + (9001 + DB.offers.length), candidate: cand.name, candidateId: cand.id, initials: cand.initials, color: cand.color, - jobTitle: cand.jobTitle, department: cand.department, status: 'Sent', base: +f.base, equity: f.equity || '10k RSU', - bonus: (f.bonus || 10) + '%', sent: new Date('2026-07-09'), expires: f.expires ? new Date(f.expires) : new Date('2026-07-23'), recruiter: cand.recruiter - }); - UI.closeModal(); - UI.toast('Offer sent successfully', 'success'); - Router.reload(); -}; diff --git a/js/pipeline.js b/js/pipeline.js deleted file mode 100644 index 2f54be5..0000000 --- a/js/pipeline.js +++ /dev/null @@ -1,166 +0,0 @@ -/* ============================================================ - pipeline.js — Kanban board (drag & drop) + Talent Pool - ============================================================ */ -window.Views = window.Views || {}; -window.Pipeline = {}; - -// Stage colours reference CSS tokens so the board re-tints with the theme. -const KANBAN_STAGES = [ - { name: 'Applied', color: 'var(--stage-1)' }, - { name: 'Screening', color: 'var(--stage-2)' }, - { name: 'Assessment', color: 'var(--stage-3)' }, - { name: 'Interview', color: 'var(--stage-4)' }, - { name: 'Offer', color: 'var(--stage-5)' }, - { name: 'Hired', color: 'var(--stage-6)' }, - { name: 'Rejected', color: 'var(--stage-7)' } -]; - -Views.pipeline = function () { - const jobFilter = { id: '' }; - - function columns() { - const list = jobFilter.id ? DB.candidates.filter(c => c.jobId === jobFilter.id) : DB.candidates; - return KANBAN_STAGES.map(st => { - const cards = list.filter(c => c.stage === st.name); - return `
-

${st.name}

${cards.length}
-
- ${cards.map(c => Pipeline._card(c)).join('')} -
`; - }).join(''); - } - - const jobOpts = [''].concat(DB.jobs.filter(j => j.status === 'Open').map(j => ``)).join(''); - - const html = ` -
-
-

Pipeline

Drag candidates between stages to update their status

-
- - -
-
-
${columns()}
-
`; - - return { - html, - onMount() { - Pipeline._bindDnd(); - document.getElementById('pipeJob').onchange = e => { - jobFilter.id = e.target.value; - document.getElementById('kanban').innerHTML = columns(); - Pipeline._bindDnd(); - }; - } - }; -}; - -Pipeline._card = function (c) { - return `
-
${UI.avatar(c.name, c.initials, c.color)} -
${c.name}
${c.currentTitle}
-
${c.jobTitle}
-
${c.skills.slice(0, 3).map(s => `${s}`).join('')}
-
${c.currentCompany}${UI.scoreChip(c.aiScore)}
-
`; -}; - -Pipeline._bindDnd = function () { - let dragged = null; - document.querySelectorAll('.k-card').forEach(card => { - card.addEventListener('dragstart', e => { - dragged = card; card.classList.add('dragging'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', card.dataset.id); - }); - card.addEventListener('dragend', () => { card.classList.remove('dragging'); dragged = null; }); - // prevent click-through opening profile right after drag - card.addEventListener('click', e => { if (card._justDropped) { e.stopPropagation(); card._justDropped = false; } }); - }); - document.querySelectorAll('.kanban-cards').forEach(zone => { - zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); }); - zone.addEventListener('dragleave', () => zone.classList.remove('drag-over')); - zone.addEventListener('drop', e => { - e.preventDefault(); - zone.classList.remove('drag-over'); - if (!dragged) return; - const id = dragged.dataset.id; - const cand = DB.getCandidate(id); - const newStage = zone.dataset.stage; - if (cand.stage === newStage) return; - cand.stage = newStage; cand.status = newStage; - zone.appendChild(dragged); - // update counts - document.querySelectorAll('.kanban-col').forEach(col => { - col.querySelector('.k-count').textContent = col.querySelectorAll('.k-card').length; - }); - UI.toast(`${cand.name} moved to ${newStage}`, 'success'); - }); - }); -}; - -// ---------------- Talent Pool ---------------- -Views.talentpool = function () { - const filters = { q: '', dept: '' }; - // Talent pool = candidates not currently in active loop (silver medalists / passive talent) - const pool = DB.candidates.filter(c => ['Rejected', 'Applied', 'Hired'].includes(c.stage)); - - function render(list) { - const grid = document.getElementById('poolGrid'); - if (!grid) return; - if (!list.length) { grid.innerHTML = `
${UI.icon('search')}

No talent found

`; return; } - grid.innerHTML = list.map(c => ` -
-
-
- ${UI.avatar(c.name, c.initials, c.color, 'avatar-lg')} -
${c.name}
${c.currentTitle}
- ${UI.scoreChip(c.aiScore)} -
-
${c.skills.slice(0, 4).map(s => `${s}`).join('')}
-
-
- ${UI.icon('briefcase')} ${c.experience} yrs - ${c.currentCompany} - ${UI.badge(c.source, 'b-gray')} -
-
-
`).join(''); - } - function apply() { - let list = pool.filter(c => { - if (filters.dept && c.department !== filters.dept) return false; - if (filters.q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(filters.q.toLowerCase())) return false; - return true; - }); - render(list); - } - const deptOpts = [''].concat(DB.departments.map(d => ``)).join(''); - - const html = ` -
-
-

Talent Pool

${pool.length} silver-medalists & passive candidates to re-engage

-
-
-
-
- - -
-
-
-
`; - - return { - html, - onMount() { - apply(); - const s = document.getElementById('poolSearch'); - s.oninput = () => { filters.q = s.value; apply(); }; - document.getElementById('poolDept').onchange = e => { filters.dept = e.target.value; apply(); }; - } - }; -}; diff --git a/js/rbac.js b/js/rbac.js deleted file mode 100644 index 31c9981..0000000 --- a/js/rbac.js +++ /dev/null @@ -1,117 +0,0 @@ -/* ============================================================ - rbac.js — Enterprise Role Based Access Control - Microsoft Admin Center-style permission matrix - ============================================================ */ -window.Views = window.Views || {}; -window.RBAC = {}; - -Views.rbac = function () { - const state = { roleIdx: 0 }; - RBAC._state = state; - - const html = ` -
-
-

Access Control

Enterprise RBAC — configure permissions for every role and module

-
- - -
-
-
-
- -
-
-
-
-
`; - - return { - html, - onMount() { RBAC._renderRoles(); RBAC._renderDetail(); } - }; -}; - -RBAC._renderRoles = function () { - const el = document.getElementById('roleList'); - el.innerHTML = DB.rbacRoles.map((r, i) => ` -
- ${UI.icon('shield')} -
${r.name}
${r.users} user${r.users === 1 ? '' : 's'}
-
`).join(''); - el.querySelectorAll('.role-item').forEach(item => item.onclick = () => { - RBAC._state.roleIdx = +item.dataset.idx; - RBAC._renderRoles(); RBAC._renderDetail(); - }); -}; - -RBAC._renderDetail = function () { - const r = DB.rbacRoles[RBAC._state.roleIdx]; - const el = document.getElementById('rbacDetail'); - - const matrixRows = DB.rbacModules.map(mod => ` - - ${mod} - ${DB.permTypes.map((pt, pi) => `${UI.icon('check')}`).join('')} - `).join(''); - - el.innerHTML = ` -
-
${UI.icon('shield')} -

${r.name}

${r.desc}
-
- ${r.users} users - - -
-
-
-
- ${DB.permTypes.map(p => ``).join('')} - ${matrixRows} -
Module${p}
-
`; - - el.querySelectorAll('.perm-check').forEach(chk => chk.onclick = () => { - const mod = chk.dataset.mod, pi = +chk.dataset.perm; - r.matrix[mod][pi] = !r.matrix[mod][pi]; - chk.classList.toggle('on'); - }); -}; - -RBAC.toggleAll = function (on) { - const r = DB.rbacRoles[RBAC._state.roleIdx]; - DB.rbacModules.forEach(mod => r.matrix[mod] = r.matrix[mod].map(() => on)); - RBAC._renderDetail(); - UI.toast(on ? 'All permissions granted for ' + r.name : 'All permissions revoked for ' + r.name, on ? 'success' : 'warning'); -}; - -RBAC.addRole = function () { - UI.modal({ - title: 'Create Role', subtitle: 'Define a new access role', - body: `
-
Required
-
-
-
-
`, - footer: `` - }); -}; -RBAC._saveRole = function () { - const form = document.getElementById('roleForm'); - UI.clearErrors(form); - const f = Object.fromEntries(new FormData(form)); - if (!f.name.trim()) { UI.fieldError(form.querySelector('[name=name]'), 'Required'); return; } - const levelMap = { 'View only': 'View', 'Editor': 'Edit', 'Approver': 'Approve', 'Manager': 'Manage', 'Administrator': 'Administrator' }; - const level = levelMap[f.template] || 'View'; - const idxMap = { 'View': 1, 'Edit': 3, 'Approve': 5, 'Manage': 7, 'Administrator': 8 }; - const cutoff = idxMap[level]; - const matrix = {}; - DB.rbacModules.forEach(mod => matrix[mod] = DB.permTypes.map((p, i) => i < cutoff)); - DB.rbacRoles.push({ name: f.name, users: 0, color: f.color, desc: f.desc || 'Custom role', level, matrix }); - RBAC._state.roleIdx = DB.rbacRoles.length - 1; - UI.closeModal(); RBAC._renderRoles(); RBAC._renderDetail(); - UI.toast('Role "' + f.name + '" created', 'success'); -}; diff --git a/js/recruiterhub.js b/js/recruiterhub.js deleted file mode 100644 index b9622e7..0000000 --- a/js/recruiterhub.js +++ /dev/null @@ -1,121 +0,0 @@ -/* ============================================================ - recruiterhub.js — Personalized recruiter dashboard + leaderboard - ============================================================ */ -window.Views = window.Views || {}; -window.RecruiterHub = {}; - -Views.recruiterhub = function () { - const state = { recId: DB.recruiters[0].id }; - RecruiterHub._state = state; - - const recOpts = DB.recruiters.map(r => ``).join(''); - - const html = ` -
-
-

Recruiter Hub

Personalized performance dashboard & workload

-
- - -
-
-
-
`; - - return { - html, - onMount() { - RecruiterHub._render(); - document.getElementById('recSelect').onchange = e => { state.recId = e.target.value; RecruiterHub._render(); }; - } - }; -}; - -RecruiterHub._render = function () { - const r = DB.getRecruiter(RecruiterHub._state.recId); - const el = document.getElementById('recHubBody'); - const slaCls = r.sla === 'On Track' ? 'b-green' : r.sla === 'At Risk' ? 'b-amber' : 'b-red'; - - const kpi = (label, val, icn, cls, sub) => `
${label}${UI.icon(icn)}
${val}
${sub ? `
${sub}
` : ''}
`; - - // leaderboard - const board = [...DB.recruiters].sort((a, b) => b.hires - a.hires).slice(0, 8); - const leaderHtml = board.map((rec, i) => { - const rankCls = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''; - return `
- ${i + 1} - ${UI.avatar(rec.name, rec.initials, rec.color)} -
${rec.name}
${rec.efficiency}% efficiency · ${rec.avgTimeToHire}d avg
-
${rec.hires}
hires
-
`; - }).join(''); - - // heatmap - const maxHeat = 5; - const heatColor = v => { const t = v / maxHeat; return t === 0 ? 'var(--bg-sunken)' : `rgba(79,70,229,${0.2 + t * 0.8})`; }; - const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - const heatHtml = `
-
${['W1', 'W2', 'W3', 'W4', 'W5'].map(w => `
${w}
`).join('')} - ${days.map((d, di) => `
${d}
${r.heatmap[di].map(v => `
`).join('')}`).join('')} -
-
Less ${[0, 1, 2, 3, 5].map(v => ``).join('')} More
`; - - el.innerHTML = ` -
-
- ${UI.avatar(r.name, r.initials, 'rgba(255,255,255,.18)', 'avatar-lg')} -
${r.name}
${r.department} Recruiter · ⭐ ${r.rating} rating
-
${r.workload}%
Workload
-
${r.efficiency}%
Efficiency
-
${UI.badge(r.sla, slaCls)}
-
-
- -
- ${kpi('Open Positions', r.openPositions, 'briefcase', 'i-indigo', 'active reqs')} - ${kpi('Closed Positions', r.closedPositions, 'check-circle', 'i-green', 'this year')} - ${kpi('Avg Time to Hire', r.avgTimeToHire + 'd', 'clock', 'i-teal', 'target 30d')} - ${kpi('Avg Time to Fill', r.avgTimeToFill + 'd', 'target', 'i-amber', 'req → offer')} -
-
- ${kpi('Interviews Today', r.interviewsToday, 'calendar', 'i-purple')} - ${kpi('Offers Pending', r.offersPending, 'file', 'i-blue')} - ${kpi('Awaiting Approval', r.jobsAwaitingApproval, 'clock', 'i-amber')} - ${kpi('Jobs Overdue', r.jobsOverdue, 'alert', 'i-red')} -
-
- ${kpi('Conversion Rate', r.conversionRate + '%', 'trending-up', 'i-green', 'applicant → hire')} - ${kpi('Interview Completion', r.interviewCompletion + '%', 'check-square', 'i-teal')} - ${kpi('Avg Response Time', r.avgResponseTime + 'h', 'zap', 'i-purple', 'to candidates')} - ${kpi('TAT Performance', r.tat + '%', 'award', 'i-indigo', 'turnaround')} -
- -
-
-

Monthly Hiring Trend

Hires per month
-
-
-
-

Workload Heatmap

Interview load
-
${heatHtml}
-
-
- -
-
-

Recruiter Leaderboard

Top performers by hires
-
${leaderHtml}
-
-
-

Candidate Pipeline

This recruiter's active candidates
-
-
-
`; - - Charts.line(document.getElementById('recTrend'), { labels: DB.analytics.hiringTrend.labels, area: true, datasets: [{ label: 'Hires', data: r.monthlyTrend, color: Charts.PALETTE[0] }] }); - const stageCounts = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'].map(() => DB.int(2, 14)); - Charts.horizontalBar(document.getElementById('recPipeline'), { - labels: ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'], data: stageCounts, - colors: Charts.PALETTE - }); -}; diff --git a/js/reports.js b/js/reports.js deleted file mode 100644 index 6ee9e97..0000000 --- a/js/reports.js +++ /dev/null @@ -1,109 +0,0 @@ -/* ============================================================ - reports.js — Reports page (cards, charts, table, export) - ============================================================ */ -window.Views = window.Views || {}; - -Views.reports = function () { - const a = DB.analytics; - const reportCards = [ - { title: 'Total Hires (YTD)', val: a.hiringTrend.hires.reduce((s, v) => s + v, 0), icn: 'award', cls: 'i-green', sub: '+18% vs last year' }, - { title: 'Total Applications', val: a.hiringTrend.applications.reduce((s, v) => s + v, 0).toLocaleString(), icn: 'users', cls: 'i-blue', sub: 'across all channels' }, - { title: 'Avg. Time to Hire', val: '27 days', icn: 'clock', cls: 'i-teal', sub: '3 days faster' }, - { title: 'Avg. Cost per Hire', val: '$4,280', icn: 'dollar', cls: 'i-amber', sub: 'within budget' } - ]; - - const deptRows = a.departments.map(d => { - const rate = Math.round((d.open ? d.apps / (d.open * 40) : 0.5) * 100); - return { dept: d.dept, open: d.open, apps: d.apps, hires: DB.int(1, 8), ttf: DB.int(28, 52), rate: Math.min(rate, 98) }; - }); - - const table = UI.dataTable({ - pageSize: 10, - rows: deptRows, - columns: [ - { key: 'dept', label: 'Department', sortable: true, render: r => `${r.dept}` }, - { key: 'open', label: 'Open Roles', sortable: true, align: 'center' }, - { key: 'apps', label: 'Applications', sortable: true, align: 'center', render: r => `${r.apps}` }, - { key: 'hires', label: 'Hires', sortable: true, align: 'center' }, - { key: 'ttf', label: 'Time to Fill', sortable: true, align: 'center', render: r => r.ttf + ' days' }, - { key: 'rate', label: 'Fill Rate', sortable: true, render: r => `
${UI.pbar(r.rate)}
${r.rate}%
` } - ] - }); - - const reportTypes = [ - { name: 'Hiring Funnel Report', desc: 'Conversion rates across each pipeline stage', icn: 'filter', cls: 'i-indigo' }, - { name: 'Source Effectiveness', desc: 'ROI and quality by sourcing channel', icn: 'target', cls: 'i-teal' }, - { name: 'Diversity & Inclusion', desc: 'Demographic breakdown of the pipeline', icn: 'users', cls: 'i-purple' }, - { name: 'Recruiter Scorecard', desc: 'Individual performance metrics', icn: 'award', cls: 'i-amber' }, - { name: 'Offer Analysis', desc: 'Acceptance rates and compensation trends', icn: 'file', cls: 'i-green' }, - { name: 'Interview Analytics', desc: 'Interviewer load and feedback quality', icn: 'calendar', cls: 'i-blue' } - ]; - - const html = ` -
-
-

Reports

Recruitment metrics and downloadable insights

-
- - -
-
- -
- ${reportCards.map(c => `
${c.title}${UI.icon(c.icn)}
${c.val}
${c.sub}
`).join('')} -
- -
-
-

Hiring Funnel

Stage-by-stage conversion
-
-
-
-
-

Time to Hire vs Fill

Monthly trend (days)
-
- ${Charts.legend([{ label: 'Time to Hire', color: Charts.PALETTE[0] }, { label: 'Time to Fill', color: Charts.PALETTE[2] }])}
-
-
- -
-

Department Performance

Hiring breakdown by team
-
- ${table.html} -
- -
-

Report Library

Generate a detailed report
-
- ${reportTypes.map(r => ` -
-
- ${UI.icon(r.icn)} -
${r.name}
-
${r.desc}
-
Generate ${UI.icon('chevron-right')}
-
-
`).join('')} -
-
-
`; - - return { - html, - onMount() { - table.mount(); - const funnel = [{ stage: 'Applied', v: 100 }, { stage: 'Screened', v: 62 }, { stage: 'Assessed', v: 41 }, { stage: 'Interviewed', v: 28 }, { stage: 'Offered', v: 14 }, { stage: 'Hired', v: 9 }]; - Charts.bar(document.getElementById('rptFunnel'), { - labels: funnel.map(f => f.stage), data: funnel.map(f => f.v), - colors: Charts.PALETTE, yFmt: v => v + '%' - }); - Charts.groupedBar(document.getElementById('rptTime'), { - labels: DB.analytics.hiringTrend.labels, - datasets: [ - { label: 'Time to Hire', data: DB.analytics.timeToHire, color: Charts.PALETTE[0] }, - { label: 'Time to Fill', data: DB.analytics.timeToFill, color: Charts.PALETTE[2] } - ], yFmt: v => v + 'd' - }); - } - }; -}; diff --git a/js/settings.js b/js/settings.js deleted file mode 100644 index 2db2378..0000000 --- a/js/settings.js +++ /dev/null @@ -1,193 +0,0 @@ -/* ============================================================ - settings.js — Settings page with many tabs - ============================================================ */ -window.Views = window.Views || {}; -window.Settings = {}; - -Views.settings = function () { - const tabs = ['General', 'Users', 'Roles', 'Permissions', 'Notifications', 'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance']; - - const html = ` -
-
-

Settings

Configure your workspace and team preferences

-
-
-
${tabs.map((t, i) => `
${t}
`).join('')}
-
- ${tabs.map((t, i) => `
${Settings.pane(t)}
`).join('')} -
-
`; - - return { - html, - onMount() { - const panes = document.querySelectorAll('#setPanes .tab-pane'); - document.querySelectorAll('#setTabs .tab').forEach(tab => tab.onclick = () => { - document.querySelectorAll('#setTabs .tab').forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - panes.forEach(p => p.classList.remove('active')); - panes[+tab.dataset.tab].classList.add('active'); - if (tab.textContent === 'Appearance') Settings._bindTheme(); - }); - Settings._bindTheme(); - } - }; -}; - -function toggleRow(title, desc, checked) { - return `

${title}

${desc}

-
`; -} - -Settings.pane = function (name) { - if (name === 'General') { - return `
-
-
-
-
-
-
-
-
-
- ${toggleRow('Auto-archive stale jobs', 'Automatically close requisitions inactive for 90 days', true)} - ${toggleRow('Duplicate detection', 'Flag candidates that already exist in the system', true)} -
`; - } - if (name === 'Users') { - const rows = DB.users.map(u => ` -
${UI.avatar(u.name, u.initials, u.color)}
${u.name}
${u.email}
- ${UI.badge(u.role, 'b-indigo')} - ${UI.badge(u.status)} - ${u.lastActive} -
- `).join(''); - return `
-

Team Members

${DB.users.length} users
-
-
${rows}
UserRoleStatusLast ActiveActions
-
`; - } - if (name === 'Roles') { - return `

Roles

Define access levels
-
-
- ${DB.roles.map(r => `
- ${UI.icon('users')} -
${r.name}
${r.desc}
-
${r.users} users
${r.perms}
- -
`).join('')} -
`; - } - if (name === 'Permissions') { - const modules = ['Jobs', 'Candidates', 'Interviews', 'Offers', 'Reports', 'Settings']; - const perms = ['View', 'Create', 'Edit', 'Delete']; - return `

Permission Matrix

Recruiter role
-
-
${perms.map(p => ``).join('')} - ${modules.map(m => `${perms.map((p, i) => ``).join('')}`).join('')}
Module${p}
${m} -
-
`; - } - if (name === 'Notifications') { - return `
-
Email Notifications
- ${toggleRow('New applications', 'Get notified when a candidate applies', true)} - ${toggleRow('Interview reminders', 'Reminders 30 minutes before interviews', true)} - ${toggleRow('Offer responses', 'When candidates accept or decline offers', true)} - ${toggleRow('Weekly digest', 'A summary of hiring activity every Monday', false)} -
In-App Notifications
- ${toggleRow('Mentions', 'When a teammate @mentions you', true)} - ${toggleRow('Stage changes', 'When a candidate moves stages', false)} - ${toggleRow('Task assignments', 'When you are assigned a task', true)} -
`; - } - if (name === 'Email Templates') { - const templates = ['Application Received', 'Interview Invitation', 'Assessment Assignment', 'Offer Letter', 'Rejection — Post Interview', 'Reference Request']; - return `

Email Templates

-
-
- ${templates.map(t => `
${UI.icon('mail')} -
${t}
Last edited 3 days ago
- ${UI.badge('Active', 'b-green')}
`).join('')} -
`; - } - if (name === 'Career Portal') { - return `
-
-
-
-
-
-
- ${toggleRow('Public job board', 'Make open roles visible to the public', true)} - ${toggleRow('Allow one-click apply', 'Let candidates apply with LinkedIn', true)} - ${toggleRow('Show salary ranges', 'Display compensation on job listings', false)} - ${toggleRow('Enable referrals', 'Employees can refer candidates', true)} -
`; - } - if (name === 'Branding') { - return `
-

Company Logo

Displayed on career pages and emails

-
-

Brand Color

Primary accent across the portal

-
- ${['#004d43', '#ceff71', '#25e9a5', '#8e92ff', '#1a3134', '#eafff4'].map(c => ``).join('')} -
-
-
-
-
-
`; - } - if (name === 'Security') { - return `
- ${toggleRow('Two-factor authentication', 'Require 2FA for all team members', true)} - ${toggleRow('Single Sign-On (SSO)', 'Enable SAML-based SSO login', false)} - ${toggleRow('IP allowlist', 'Restrict access to approved IP ranges', false)} - ${toggleRow('Audit logging', 'Track all data access and changes', true)} -
-
-
-
-
-

Data Retention

Auto-delete candidate data after set period

-
-
`; - } - if (name === 'Appearance') { - return `
-
Theme
-
-
-
-
Light
Clean and bright
-
-
-
-
Dark
Easy on the eyes
-
-
-
-
System
Match OS setting
-
-
-
- ${toggleRow('Compact mode', 'Reduce spacing for denser layouts', false)} - ${toggleRow('Show animations', 'Enable transitions and motion', true)} -
`; - } - return ''; -}; - -Settings._bindTheme = function () { - document.querySelectorAll('.theme-opt').forEach(opt => opt.onclick = () => { - const mode = opt.dataset.themeSet; - if (mode === 'system') { App.setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); } - else App.setTheme(mode); - UI.toast('Theme updated to ' + mode, 'success'); - }); -}; diff --git a/js/tasks.js b/js/tasks.js deleted file mode 100644 index 8a70485..0000000 --- a/js/tasks.js +++ /dev/null @@ -1,131 +0,0 @@ -/* ============================================================ - tasks.js — Recruitment Tasks (with saved searches & favorites) - ============================================================ */ -window.Views = window.Views || {}; -window.Tasks = {}; - -Views.tasks = function () { - const state = { filter: 'All' }; - - function render() { - const el = document.getElementById('taskList'); - if (!el) return; - let list = DB.tasks; - if (state.filter === 'Open') list = list.filter(t => !t.done); - else if (state.filter === 'Completed') list = list.filter(t => t.done); - else if (state.filter === 'Overdue') list = list.filter(t => !t.done && t.due < new Date('2026-07-09')); - else if (['High', 'Medium', 'Low'].includes(state.filter)) list = list.filter(t => t.priority === state.filter); - - if (!list.length) { el.innerHTML = `
${UI.icon('check-square')}

All caught up

No tasks in this view.

`; return; } - const prCls = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' }; - el.innerHTML = list.map(t => { - const overdue = !t.done && t.due < new Date('2026-07-09'); - return `
- ${UI.icon('check')} -
-
${t.title}
-
${UI.icon('users')} ${t.assignee} · ${t.type}
-
-
- ${UI.badge(t.priority, prCls[t.priority])} -
${overdue ? 'Overdue · ' : 'Due '}${DB.fmtShort(t.due)}
-
-
`; - }).join(''); - el.querySelectorAll('.checkbox').forEach(chk => chk.onclick = e => { - e.stopPropagation(); - const t = DB.tasks.find(x => x.id === chk.dataset.id); - t.done = !t.done; render(); App.updateBadges(); - UI.toast(t.done ? 'Task completed' : 'Task reopened', t.done ? 'success' : 'info'); - }); - } - Tasks._render = render; - - const openCount = DB.tasks.filter(t => !t.done).length; - const overdueCount = DB.tasks.filter(t => !t.done && t.due < new Date('2026-07-09')).length; - const filters = ['All', 'Open', 'Completed', 'Overdue', 'High', 'Medium', 'Low']; - - // saved searches sidebar - const savedHtml = DB.savedSearches.map(s => ` -
- ${UI.icon('bookmark')} -
${s.name}
${s.filters}
- ${s.count} -
`).join(''); - - const html = ` -
-
-

Tasks

${openCount} open · ${overdueCount} overdue

-
-
-
-
-
-
${filters.map((f, i) => ``).join('')}
-
-
-
-
-

Saved Searches

Quick candidate filters
-
-
${savedHtml}
-
-
-
`; - - return { - html, - onMount() { - render(); - document.querySelectorAll('#taskSeg button').forEach(b => b.onclick = () => { - document.querySelectorAll('#taskSeg button').forEach(x => x.classList.remove('active')); - b.classList.add('active'); state.filter = b.dataset.f; render(); - }); - } - }; -}; - -Tasks.open = function (id) { - const t = DB.tasks.find(x => x.id === id); - const c = DB.getCandidate(t.candidateId); - UI.modal({ - title: t.title, subtitle: t.id + ' · ' + t.type, - body: `
-
Assignee
${t.assignee}
-
Priority
${t.priority}
-
Due Date
${DB.fmtDate(t.due)}
-
Status
${t.done ? 'Completed' : 'Open'}
- ${c ? `
Candidate
${c.name}
` : ''} -
-
`, - footer: ` - ${c ? `` : ''} - ` - }); -}; -Tasks._complete = function (id) { const t = DB.tasks.find(x => x.id === id); t.done = true; Tasks._render(); App.updateBadges(); UI.toast('Task completed', 'success'); }; - -Tasks.add = function () { - const opt = arr => arr.map(o => ``).join(''); - UI.modal({ - title: 'New Task', subtitle: 'Create a recruitment task', - body: `
-
Required
-
-
-
-
-
`, - footer: `` - }); -}; -Tasks._save = function () { - const form = document.getElementById('taskForm'); - UI.clearErrors(form); - const f = Object.fromEntries(new FormData(form)); - if (!f.title.trim()) { UI.fieldError(form.querySelector('[name=title]'), 'Required'); return; } - DB.tasks.unshift({ id: 'TSK-' + (50001 + DB.tasks.length), title: f.title, candidateId: null, priority: f.priority, due: f.due ? new Date(f.due) : new Date('2026-07-16'), assignee: f.assignee, done: false, type: f.type }); - UI.closeModal(); Tasks._render(); App.updateBadges(); - UI.toast('Task created', 'success'); -}; diff --git a/js/ui.js b/js/ui.js deleted file mode 100644 index 97ad730..0000000 --- a/js/ui.js +++ /dev/null @@ -1,252 +0,0 @@ -/* ============================================================ - ui.js — Reusable UI primitives & helpers - Exposes global `UI` - ============================================================ */ -(function () { - 'use strict'; - - const ICONS = { - 'user-plus': '', - 'calendar': '', - 'check': '', - 'check-circle': '', - 'x': '', - 'x-circle': '', - 'file': '', - 'star': '', - 'message': '', - 'info': '', - 'alert': '', - 'eye': '', - 'edit': '', - 'trash': '', - 'more': '', - 'plus': '', - 'download': '', - 'filter': '', - 'clock': '', - 'mail': '', - 'phone': '', - 'map': '', - 'briefcase': '', - 'trending-up': '', - 'trending-down': '', - 'users': '', - 'award': '', - 'dollar': '', - 'target': '', - 'send': '', - 'video': '', - 'search': '', - 'chevron-left': '', - 'chevron-right': '', - 'refresh': '', - 'copy': '', - 'upload': '', - 'linkedin': '', - 'inbox': '', - 'sparkles': '', - 'zap': '', - 'grid': '', - 'bookmark': '', - 'paperclip': '', - 'external': '', - 'shield': '', - 'lock': '', - 'flame': '', - 'bell': '', - 'layers': '', - 'list': '', - 'check-square': '', - 'arrow-right': '' - }; - - function icon(name, cls) { return `${ICONS[name] || ICONS['info']}`; } - - function avatar(name, initials, color, cls) { - const bg = color || DB.avatarColor(name || ''); - const init = initials || DB.initials(name || '?'); - return `${init}`; - } - - // ---------- badge helpers ---------- - const statusMap = { - 'Open': 'b-green', 'Closed': 'b-gray', 'On Hold': 'b-amber', 'Draft': 'b-blue', - 'Applied': 'b-blue', 'Screening': 'b-purple', 'Assessment': 'b-amber', 'Interview': 'b-indigo', - 'Offer': 'b-teal', 'Hired': 'b-green', 'Rejected': 'b-red', - 'Scheduled': 'b-blue', 'Completed': 'b-green', 'Cancelled': 'b-red', 'No Show': 'b-amber', - 'Sent': 'b-blue', 'Accepted': 'b-green', 'Negotiating': 'b-amber', 'Declined': 'b-red', 'Expired': 'b-gray', - 'In Progress': 'b-amber', 'Pending': 'b-gray', 'Active': 'b-green', 'Invited': 'b-amber', - 'Strong Hire': 'b-green', 'Hire': 'b-teal', 'Lean Hire': 'b-amber', 'No Hire': 'b-red' - }; - function badge(text, cls) { return `${text}`; } - - function scoreChip(score) { - // Theme tokens, not fixed hex — the old greens/blues dropped to ~2.6:1 on dark cards. - const color = score >= 85 ? 'var(--success)' : score >= 70 ? 'var(--warning)' : score >= 55 ? 'var(--info)' : 'var(--danger)'; - return `${score}`; - } - - function pbar(pct, cls) { - const c = pct >= 80 ? 'green' : pct >= 50 ? '' : pct >= 30 ? 'amber' : 'red'; - return `
`; - } - - function avatarStack(names, max) { - max = max || 3; - const shown = names.slice(0, max); - const extra = names.length - max; - let html = '
'; - shown.forEach(n => html += avatar(n, DB.initials(n))); - if (extra > 0) html += `+${extra}`; - return html + '
'; - } - - // ---------- Modal ---------- - function modal({ title, subtitle, body, footer, size }) { - const root = document.getElementById('modalRoot'); - root.innerHTML = ` - - `; - root.classList.add('open'); - document.body.style.overflow = 'hidden'; - root.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', closeModal)); - return root; - } - function closeModal() { - const root = document.getElementById('modalRoot'); - root.classList.remove('open'); - root.innerHTML = ''; - document.body.style.overflow = ''; - } - - // ---------- Toast ---------- - function toast(msg, type = 'info', title) { - const root = document.getElementById('toastRoot'); - const cfg = { - success: { i: 'check-circle', c: 'i-green', t: 'Success' }, - error: { i: 'x-circle', c: 'i-red', t: 'Error' }, - info: { i: 'info', c: 'i-blue', t: 'Notice' }, - warning: { i: 'alert', c: 'i-amber', t: 'Warning' } - }[type] || { i: 'info', c: 'i-blue', t: 'Notice' }; - const el = document.createElement('div'); - el.className = 'toast'; - el.innerHTML = ` - ${icon(cfg.i)} -
${title || cfg.t}
${msg}
- `; - root.appendChild(el); - const remove = () => { el.classList.add('out'); setTimeout(() => el.remove(), 300); }; - el.querySelector('.toast-close').onclick = remove; - setTimeout(remove, 4200); - } - - // ---------- Sortable / paginated table ---------- - function dataTable(config) { - // config: { columns:[{key,label,sortable,render,align}], rows, pageSize, empty } - const state = { sortKey: null, sortDir: 1, page: 1, rows: config.rows }; - const pageSize = config.pageSize || 10; - const id = 'tbl_' + Math.random().toString(36).slice(2, 8); - - function sorted() { - let r = state.rows; - if (state.sortKey) { - const col = config.columns.find(c => c.key === state.sortKey); - r = [...r].sort((a, b) => { - let va = col.sortValue ? col.sortValue(a) : a[state.sortKey]; - let vb = col.sortValue ? col.sortValue(b) : b[state.sortKey]; - if (typeof va === 'string') { va = va.toLowerCase(); vb = (vb || '').toLowerCase(); } - if (va < vb) return -1 * state.sortDir; - if (va > vb) return 1 * state.sortDir; - return 0; - }); - } - return r; - } - function render() { - const rows = sorted(); - const total = rows.length; - const pages = Math.max(1, Math.ceil(total / pageSize)); - if (state.page > pages) state.page = pages; - const start = (state.page - 1) * pageSize; - const pageRows = rows.slice(start, start + pageSize); - - const thead = config.columns.map(c => { - const sortedCls = state.sortKey === c.key ? (state.sortDir === 1 ? 'sorted-asc' : 'sorted-desc') : ''; - const ind = c.sortable ? `${state.sortKey === c.key ? (state.sortDir === 1 ? '▲' : '▼') : '⇅'}` : ''; - return `${c.label}${ind}`; - }).join(''); - - let tbody; - if (!pageRows.length) { - tbody = ` -
${icon('search')}

No results found

${config.empty || 'Try adjusting your filters or search.'}

`; - } else { - tbody = pageRows.map(row => `${config.columns.map(c => - `${c.render ? c.render(row) : (row[c.key] ?? '')}`).join('')}`).join(''); - } - - const from = total ? start + 1 : 0, to = Math.min(start + pageSize, total); - const pager = pageButtons(state.page, pages); - - const el = document.getElementById(id); - el.innerHTML = ` -
- ${thead}${tbody}
- `; - - el.querySelectorAll('th.sortable').forEach(th => th.onclick = () => { - const k = th.dataset.sort; - if (state.sortKey === k) state.sortDir *= -1; else { state.sortKey = k; state.sortDir = 1; } - render(); - }); - el.querySelectorAll('[data-page]').forEach(b => b.onclick = () => { - const p = b.dataset.page; - if (p === 'prev') state.page = Math.max(1, state.page - 1); - else if (p === 'next') state.page = Math.min(pages, state.page + 1); - else state.page = +p; - render(); - }); - if (config.onRender) config.onRender(el); - } - function pageButtons(cur, pages) { - let btns = ``; - const list = []; - for (let i = 1; i <= pages; i++) { - if (i === 1 || i === pages || Math.abs(i - cur) <= 1) list.push(i); - else if (list[list.length - 1] !== '…') list.push('…'); - } - list.forEach(i => btns += i === '…' ? `` : ``); - btns += ``; - return btns; - } - // public API - return { - html: `
`, - mount: render, - update(rows) { state.rows = rows; state.page = 1; render(); } - }; - } - - function fieldError(inputEl, msg) { - inputEl.classList.add('err'); - let err = inputEl.parentElement.querySelector('.field-error'); - if (err) { err.textContent = msg; err.classList.add('show'); } - } - function clearErrors(form) { - form.querySelectorAll('.err').forEach(e => e.classList.remove('err')); - form.querySelectorAll('.field-error').forEach(e => e.classList.remove('show')); - } - - window.UI = { icon, avatar, badge, scoreChip, pbar, avatarStack, modal, closeModal, toast, dataTable, fieldError, clearErrors, ICONS }; -})(); diff --git a/tools/check_evidence_citations.py b/tools/check_evidence_citations.py index 353991a..b494496 100755 --- a/tools/check_evidence_citations.py +++ b/tools/check_evidence_citations.py @@ -37,11 +37,42 @@ from __future__ import annotations import os import re +import subprocess import sys REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DOCS = os.path.join(REPO, 'docs', 'architecture') +# --- prototype evidence outlives the prototype ------------------------------ +# The React migration deletes the browser-only prototype (`index.html`, `js/`, +# and moves `css/styles.css` into the web app). Around 600 citations in this +# package are evidence *about that prototype* — they were true when written and +# stay true of it forever, so they must not rot just because the working tree +# moved on. Resolve those paths from the `prototype-final` tag, which marks the +# last commit where the prototype existed intact. +# +# If the tag is absent (a fresh clone that never fetched tags, or a checkout +# from before the migration) this falls back to the working tree, so the script +# keeps working either way. +LEGACY_TAG = 'prototype-final' +LEGACY_PREFIXES = ('js/', 'index.html', 'css/', 'devserver.py') + + +def is_legacy(path: str) -> bool: + return path.startswith(LEGACY_PREFIXES) + + +def read_from_tag(path: str) -> list[str] | None: + """Contents of `path` at LEGACY_TAG, or None if unavailable.""" + try: + out = subprocess.run( + ['git', '-C', REPO, 'show', f'{LEGACY_TAG}:{path}'], + capture_output=True, check=True, + ) + except (OSError, subprocess.CalledProcessError): + return None + return out.stdout.decode('utf-8', 'replace').split('\n') + # Only extensions that exist in this repository today. Citations to planned files # (.sql migrations, .tsx components, .yml workflows) are intentionally not matched — # they cannot be verified and are design intent, not evidence. @@ -158,15 +189,28 @@ def main() -> int: return 1 cache: dict[str, list[str] | None] = {} + from_tag: set[str] = set() + + def read_worktree(path: str) -> list[str] | None: + try: + with open(os.path.join(REPO, path), encoding='utf-8') as fh: + return fh.read().split('\n') + except OSError: + return None def source(path: str) -> list[str] | None: if path not in cache: - full = os.path.join(REPO, path) - try: - with open(full, encoding='utf-8') as fh: - cache[path] = fh.read().split('\n') - except OSError: - cache[path] = None + if is_legacy(path): + # Prefer the tagged prototype so these citations are stable whether + # or not the files still exist in the tree. + tagged = read_from_tag(path) + if tagged is not None: + from_tag.add(path) + cache[path] = tagged + else: + cache[path] = read_worktree(path) + else: + cache[path] = read_worktree(path) return cache[path] docs = [] @@ -241,13 +285,20 @@ def main() -> int: f'{claim_checked} claim/anchor pairings checked ' f'({len(CLAIM_RULES)} rules)' ) + if from_tag: + print( + f'{len(from_tag)} prototype file(s) resolved from `{LEGACY_TAG}` ' + f'rather than the working tree: {", ".join(sorted(from_tag))}' + ) if problems: print(f'\n{len(problems)} bad citation(s):\n', file=sys.stderr) for p in problems: print(f' {p}', file=sys.stderr) print( '\nFix the citation, or update ANCHORS in this script if the source moved ' - 'deliberately.', + f'deliberately. Prototype paths ({", ".join(LEGACY_PREFIXES)}) resolve from ' + f'the `{LEGACY_TAG}` tag, so moving or deleting them in the working tree ' + 'does not affect this check.', file=sys.stderr, ) return 1