From 3c1183a9bebc22da20845a04f827e2d3fc0f77e9 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 10 Jun 2026 01:36:35 +0800 Subject: [PATCH 01/14] Add Brevo newsletter sync for mailbox provisioning --- .env.example | 2 + ENVIRONMENT.md | 3 + apps/discord_bot/README.md | 1 + .../src/five08/discord_bot/cogs/crm.py | 80 ++++++++++++++++- .../src/five08/discord_bot/cogs/migadu.py | 62 +++++++++++++ packages/shared/src/five08/agent/tools.py | 86 ++++++++++++++++++- packages/shared/src/five08/clients/brevo.py | 80 +++++++++++++++++ packages/shared/src/five08/settings.py | 4 + tests/unit/test_agent_gateway.py | 51 +++++++++++ tests/unit/test_brevo_client.py | 63 ++++++++++++++ tests/unit/test_crm_create_sso_user.py | 9 ++ tests/unit/test_migadu_create_mailbox.py | 8 ++ tests/unit/test_shared_settings.py | 4 + 13 files changed, 448 insertions(+), 5 deletions(-) create mode 100644 packages/shared/src/five08/clients/brevo.py create mode 100644 tests/unit/test_brevo_client.py diff --git a/.env.example b/.env.example index 72190a3a..a3bcc4f4 100644 --- a/.env.example +++ b/.env.example @@ -235,6 +235,8 @@ EMAIL_REQUIRE_SENDER_AUTH_HEADERS=true MIGADU_API_USER=your_migadu_api_user MIGADU_API_KEY=your_migadu_api_key MIGADU_MAILBOX_DOMAIN=508.dev +BREVO_API_KEY= +BREVO_NEWSLETTER_LIST_ID= # EspoCRM (required for worker integration) ESPO_API_KEY=your_key_here diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 03f08096..e65f3004 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -161,6 +161,9 @@ current precedence rules. - `Required for /create-mailbox and /create-user-accounts`: `MIGADU_API_USER`, `MIGADU_API_KEY` - `Optional`: `MIGADU_MAILBOX_DOMAIN` (default: `508.dev`) +- `Optional for Brevo newsletter sync`: `BREVO_API_KEY` +- `Optional for Brevo newsletter sync`: `BREVO_NEWSLETTER_LIST_ID` (set to the production Brevo newsletter list ID) +- Note: mailbox and backup email subscription to Brevo is best effort. Failures are reported as warnings and do not block mailbox or account creation. ## Authentik SSO Provisioning diff --git a/apps/discord_bot/README.md b/apps/discord_bot/README.md index 0e1284ec..2457f49d 100644 --- a/apps/discord_bot/README.md +++ b/apps/discord_bot/README.md @@ -150,6 +150,7 @@ Relevant configuration: - `/create-mailbox` - Description: Create a Migadu mailbox for a 508 user, optionally link it to a CRM contact, and sync `c508Email`. - Prerequisites: `MIGADU_API_USER` and `MIGADU_API_KEY` must be configured (configured in env; command will fail if missing). + - Brevo newsletter sync: if `BREVO_API_KEY` and `BREVO_NEWSLETTER_LIST_ID` are configured, the new 508 mailbox and backup email are added to that Brevo list. Brevo failures are shown as warnings and do not block mailbox creation. - Required role: Admin - Args: - `mailbox_username` (required): 508 mailbox username or address. If the domain is omitted, `@508.dev` is added automatically. diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 7e5605e8..8878fd5c 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -22,6 +22,7 @@ from five08.discord_bot.config import settings from five08.clients.authentik import AuthentikAPIError, AuthentikClient +from five08.clients.brevo import BrevoAPIError, BrevoClient from five08.clients import espo from five08.clients.docuseal import ( DocusealAPIError, @@ -133,10 +134,12 @@ def __init__( *, mailbox_email: str, partial_success: str, + newsletter_error: str | None = None, ) -> None: super().__init__(message) self.mailbox_email = mailbox_email self.partial_success = partial_success + self.newsletter_error = newsletter_error @dataclass(frozen=True, slots=True) @@ -147,6 +150,7 @@ class MailboxProvisioningResult: created: bool crm_updated: bool backup_email: str + newsletter_error: str | None = None @dataclass(frozen=True, slots=True) @@ -3816,6 +3820,47 @@ def _migadu_client(self) -> MigaduClient: domain=self._migadu_mailbox_domain(), ) + def _brevo_client(self) -> BrevoClient | None: + """Build a Brevo client when newsletter sync is configured.""" + api_key = self._contact_text_value(settings.brevo_api_key) + if not api_key: + return None + return BrevoClient( + api_key=api_key, + base_url=settings.brevo_api_base_url, + timeout_seconds=settings.brevo_api_timeout_seconds, + ) + + async def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: + """Best-effort subscribe mailbox and backup addresses to Brevo.""" + client = self._brevo_client() + if client is None: + return "BREVO_API_KEY is not configured." + if settings.brevo_newsletter_list_id is None: + return "BREVO_NEWSLETTER_LIST_ID is not configured." + + errors: list[str] = [] + seen: set[str] = set() + for email in emails: + normalized_email = email.strip().lower() + if not normalized_email or normalized_email in seen: + continue + seen.add(normalized_email) + try: + await asyncio.to_thread( + client.add_contact_to_list, + email=normalized_email, + list_id=settings.brevo_newsletter_list_id, + ) + except (BrevoAPIError, ValueError) as exc: + logger.warning( + "Failed to add %s to Brevo newsletter list: %s", + normalized_email, + exc, + ) + errors.append(f"{normalized_email}: {exc}") + return "; ".join(errors) if errors else None + def _normalize_mailbox_request(self, mailbox_username: str) -> tuple[str, str]: """Normalize a bare or full 508 mailbox request to email and local-part.""" normalized = mailbox_username.strip().lower() @@ -8284,6 +8329,12 @@ async def _create_migadu_mailbox_for_contact( created=False, crm_updated=False, backup_email="", + newsletter_error=await self._add_emails_to_newsletter( + [ + existing_email, + self._contact_text_value(contact.get("emailAddress")) or "", + ] + ), ) backup_email = self._normalize_full_email( @@ -8307,6 +8358,10 @@ async def _create_migadu_mailbox_for_contact( partial_success="mailbox_created_address_mismatch", ) + newsletter_error = await self._add_emails_to_newsletter( + [target_email, backup_email] + ) + try: await asyncio.to_thread( self.espo_api.request, @@ -8319,6 +8374,7 @@ async def _create_migadu_mailbox_for_contact( str(exc), mailbox_email=target_email, partial_success="mailbox_created_crm_update_failed", + newsletter_error=newsletter_error, ) from exc contact["c508Email"] = target_email @@ -8327,6 +8383,7 @@ async def _create_migadu_mailbox_for_contact( created=True, crm_updated=True, backup_email=backup_email, + newsletter_error=newsletter_error, ) async def _invite_outline_user_for_contact( @@ -8564,22 +8621,30 @@ async def _create_user_accounts_for_contact( "mailbox_username": mailbox_username, "mailbox_email": exc.mailbox_email, "partial_success": exc.partial_success, + "newsletter_error": exc.newsletter_error, "error": message, }, ) + newsletter_line = ( + f"\nNewsletter: Brevo subscription failed: `{exc.newsletter_error}`" + if exc.newsletter_error + else "\nNewsletter: added mailbox and backup email to Brevo." + ) if exc.partial_success == "mailbox_created_crm_update_failed": await interaction.followup.send( "⚠️ Created the mailbox, but failed to update CRM `c508Email`.\n" f"Email: `{exc.mailbox_email}`\n" f"Error: `{message}`\n" - "SSO provisioning and Outline invite were not started.", + "SSO provisioning and Outline invite were not started." + f"{newsletter_line}", ephemeral=True, ) else: await interaction.followup.send( f"⚠️ {message}\n" f"Created mailbox: `{exc.mailbox_email}`\n" - "SSO provisioning and Outline invite were not started.", + "SSO provisioning and Outline invite were not started." + f"{newsletter_line}", ephemeral=True, ) except SSOProvisioningPartialError as exc: @@ -8704,6 +8769,8 @@ async def _create_user_accounts_for_contact( "sso_created": result.sso.created, "sso_crm_updated": result.sso.crm_updated, "outline_invited": result.outline_invited, + "newsletter_subscribed": result.mailbox.newsletter_error is None, + "newsletter_error": result.mailbox.newsletter_error, "recovery_email_error": result.sso.recovery_email_error, }, resource_type="crm_contact", @@ -8720,6 +8787,15 @@ async def _create_user_accounts_for_contact( f"(user ID `{result.sso.user_id}`).", "Outline invite: sent.", ] + if result.mailbox.newsletter_error is None: + message_lines.append( + "Newsletter: added mailbox and backup email to Brevo." + ) + else: + message_lines.append( + "Newsletter: Brevo subscription failed: " + f"`{result.mailbox.newsletter_error}`" + ) if result.sso.created: if result.sso.recovery_email_error is None: message_lines.append("SSO recovery email: sent.") diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py b/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py index e98402e4..a9e10fec 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py @@ -17,6 +17,7 @@ from discord.ext import commands from five08.clients.espo import EspoAPIError, EspoClient +from five08.clients.brevo import BrevoAPIError, BrevoClient from five08.clients.migadu import ( MigaduAPIError, MigaduClient, @@ -55,6 +56,7 @@ class MailboxCreationOutcome: mailbox_name: str crm_contact: dict[str, Any] | None sync_error: str | None = None + newsletter_error: str | None = None def _truncate_discord_text(value: str, *, limit: int) -> str: @@ -208,6 +210,47 @@ def _migadu_client(self) -> MigaduClient: domain=self._migadu_mailbox_domain(), ) + def _brevo_client(self) -> BrevoClient | None: + """Build a Brevo client when newsletter sync is configured.""" + api_key = (settings.brevo_api_key or "").strip() + if not api_key: + return None + return BrevoClient( + api_key=api_key, + base_url=settings.brevo_api_base_url, + timeout_seconds=settings.brevo_api_timeout_seconds, + ) + + async def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: + """Best-effort subscribe mailbox and backup addresses to Brevo.""" + client = self._brevo_client() + if client is None: + return "BREVO_API_KEY is not configured." + if settings.brevo_newsletter_list_id is None: + return "BREVO_NEWSLETTER_LIST_ID is not configured." + + errors: list[str] = [] + seen: set[str] = set() + for email in emails: + normalized_email = email.strip().lower() + if not normalized_email or normalized_email in seen: + continue + seen.add(normalized_email) + try: + await asyncio.to_thread( + client.add_contact_to_list, + email=normalized_email, + list_id=settings.brevo_newsletter_list_id, + ) + except (BrevoAPIError, ValueError) as exc: + logger.warning( + "Failed to add %s to Brevo newsletter list: %s", + normalized_email, + exc, + ) + errors.append(f"{normalized_email}: {exc}") + return "; ".join(errors) if errors else None + def _normalize_mailbox_request(self, mailbox_username: str) -> tuple[str, str]: """ Normalize user input and derive: @@ -580,6 +623,9 @@ async def _execute_mailbox_creation( name=mailbox_name, ) created_address = str(mailbox.get("address") or context.mailbox_email) + newsletter_error = await self._add_emails_to_newsletter( + [created_address, backup_email] + ) contact_to_update = pre_resolved_contact sync_error: str | None = None @@ -617,6 +663,7 @@ async def _execute_mailbox_creation( mailbox_name=mailbox_name, crm_contact=contact_to_update, sync_error=sync_error, + newsletter_error=newsletter_error, ) def _build_mailbox_embed( @@ -650,6 +697,18 @@ def _build_mailbox_embed( value=outcome.sync_error, inline=False, ) + if outcome.newsletter_error: + embed.add_field( + name="Newsletter", + value=f"Brevo subscription failed: {outcome.newsletter_error}", + inline=False, + ) + else: + embed.add_field( + name="Newsletter", + value="Added mailbox and backup email to Brevo.", + inline=False, + ) return embed @@ -754,6 +813,7 @@ async def _handle_mailbox_creation( else None ), "crm_sync_error": outcome.sync_error, + "newsletter_error": outcome.newsletter_error, "mailbox_created": True, }, resource_type="discord_command", @@ -789,6 +849,8 @@ async def _handle_mailbox_creation( else None ), "forwarded_to": outcome.backup_email, + "newsletter_subscribed": outcome.newsletter_error is None, + "newsletter_error": outcome.newsletter_error, }, resource_type="discord_command", ) diff --git a/packages/shared/src/five08/agent/tools.py b/packages/shared/src/five08/agent/tools.py index 0dc3d2e9..de918ea2 100644 --- a/packages/shared/src/five08/agent/tools.py +++ b/packages/shared/src/five08/agent/tools.py @@ -17,6 +17,7 @@ RiskLevel, ) from five08.clients.authentik import AuthentikAPIError, AuthentikClient +from five08.clients.brevo import BrevoAPIError, BrevoClient from five08.clients.docuseal import create_member_agreement_submission from five08.clients.espo import EspoAPIError, EspoClient from five08.clients.github import GitHubClient @@ -75,6 +76,10 @@ class ToolRuntimeConfig: outline_base_url: str = "https://app.getoutline.com" outline_api_key: str | None = None outline_api_timeout_seconds: float = 20.0 + brevo_api_key: str | None = None + brevo_api_base_url: str = "https://api.brevo.com/v3" + brevo_api_timeout_seconds: float = 20.0 + brevo_newsletter_list_id: int | None = None @classmethod def from_settings(cls, settings: Any) -> "ToolRuntimeConfig": @@ -127,6 +132,20 @@ def from_settings(cls, settings: Any) -> "ToolRuntimeConfig": "outline_api_timeout_seconds", 20.0, ), + brevo_api_key=getattr(settings, "brevo_api_key", None), + brevo_api_base_url=getattr( + settings, + "brevo_api_base_url", + "https://api.brevo.com/v3", + ), + brevo_api_timeout_seconds=getattr( + settings, + "brevo_api_timeout_seconds", + 20.0, + ), + brevo_newsletter_list_id=getattr( + settings, "brevo_newsletter_list_id", None + ), ) @@ -1389,7 +1408,45 @@ def _migadu_client(self) -> MigaduClient: ), ) - def _create_migadu_mailbox(self, arguments: dict[str, Any]) -> dict[str, Any]: + def _brevo_client(self) -> BrevoClient | None: + api_key = _optional_str(self.runtime_config.brevo_api_key) + if api_key is None: + return None + return BrevoClient( + api_key=api_key, + base_url=self.runtime_config.brevo_api_base_url, + timeout_seconds=self.runtime_config.brevo_api_timeout_seconds, + ) + + def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: + client = self._brevo_client() + if client is None: + return "BREVO_API_KEY is not configured." + if self.runtime_config.brevo_newsletter_list_id is None: + return "BREVO_NEWSLETTER_LIST_ID is not configured." + + errors: list[str] = [] + seen: set[str] = set() + for email in emails: + normalized_email = email.strip().lower() + if not normalized_email or normalized_email in seen: + continue + seen.add(normalized_email) + try: + client.add_contact_to_list( + email=normalized_email, + list_id=self.runtime_config.brevo_newsletter_list_id, + ) + except (BrevoAPIError, ValueError) as exc: + errors.append(f"{normalized_email}: {_short_error(exc)}") + return "; ".join(errors) if errors else None + + def _create_migadu_mailbox( + self, + arguments: dict[str, Any], + *, + subscribe_newsletter: bool = True, + ) -> dict[str, Any]: local_part = str(arguments.get("local_part") or "").strip().lower() backup_email = _normalize_full_email( arguments.get("backup_email"), @@ -1398,13 +1455,25 @@ def _create_migadu_mailbox(self, arguments: dict[str, Any]) -> dict[str, Any]: name = str(arguments.get("name") or "").strip() if not local_part or not backup_email or not name: raise ValueError("Mailbox local_part, backup_email, and name are required") - return self._migadu_client().create_mailbox( + mailbox = self._migadu_client().create_mailbox( MigaduMailboxCreateRequest( local_part=local_part, backup_email=backup_email, name=name, ) ) + if subscribe_newsletter: + mailbox_email = str(mailbox.get("address") or "").strip().lower() + if not mailbox_email: + domain = normalize_migadu_mailbox_domain( + self.runtime_config.migadu_mailbox_domain + ) + mailbox_email = f"{local_part}@{domain}" + mailbox["newsletter_error"] = self._add_emails_to_newsletter( + [mailbox_email, backup_email] + ) + mailbox["newsletter_subscribed"] = mailbox["newsletter_error"] is None + return mailbox def _create_migadu_mailbox_for_contact( self, @@ -1427,6 +1496,9 @@ def _create_migadu_mailbox_for_contact( created=False, crm_updated=False, backup_email="", + newsletter_error=self._add_emails_to_newsletter( + [existing_email, _optional_str(contact.get("emailAddress")) or ""] + ), ) backup_email = _normalize_full_email( @@ -1439,7 +1511,8 @@ def _create_migadu_mailbox_for_contact( "local_part": local_part, "backup_email": backup_email, "name": contact_name, - } + }, + subscribe_newsletter=False, ) created_address = str(mailbox.get("address") or target_email).strip().lower() if created_address != target_email: @@ -1457,6 +1530,8 @@ def _create_migadu_mailbox_for_contact( ) raise ToolPartialSuccessError(message, result) + newsletter_error = self._add_emails_to_newsletter([target_email, backup_email]) + try: self._espo_client().update_contact(contact_id, {"c508Email": target_email}) except EspoAPIError as exc: @@ -1467,6 +1542,7 @@ def _create_migadu_mailbox_for_contact( backup_email=backup_email, partial_success="mailbox_created_crm_update_failed", error=_short_error(exc), + newsletter_error=newsletter_error, ) raise ToolPartialSuccessError( "Mailbox was created, but updating CRM c508Email failed.", @@ -1478,6 +1554,7 @@ def _create_migadu_mailbox_for_contact( created=True, crm_updated=True, backup_email=backup_email, + newsletter_error=newsletter_error, ) @staticmethod @@ -1489,12 +1566,15 @@ def _mailbox_result( backup_email: str, partial_success: str | None = None, error: str | None = None, + newsletter_error: str | None = None, ) -> dict[str, Any]: result: dict[str, Any] = { "email": email, "created": created, "crm_updated": crm_updated, "backup_email": backup_email, + "newsletter_subscribed": newsletter_error is None, + "newsletter_error": newsletter_error, } if partial_success is not None: result["partial_success"] = partial_success diff --git a/packages/shared/src/five08/clients/brevo.py b/packages/shared/src/five08/clients/brevo.py new file mode 100644 index 00000000..533b7e63 --- /dev/null +++ b/packages/shared/src/five08/clients/brevo.py @@ -0,0 +1,80 @@ +"""Brevo API client helpers shared across services.""" + +from __future__ import annotations + +from typing import Any + +import requests + +BREVO_API_BASE_URL = "https://api.brevo.com/v3" + + +class BrevoAPIError(RuntimeError): + """Raised when the Brevo API request fails or returns invalid data.""" + + +class BrevoClient: + """Small Brevo API wrapper for newsletter contact subscriptions.""" + + def __init__( + self, + *, + api_key: str, + base_url: str = BREVO_API_BASE_URL, + timeout_seconds: float = 20.0, + ) -> None: + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.timeout_seconds = timeout_seconds + + def add_contact_to_list( + self, + *, + email: str, + list_id: int, + ) -> dict[str, Any]: + """Create or update one Brevo contact and add it to a list.""" + normalized_email = email.strip().lower() + if not normalized_email or normalized_email.count("@") != 1: + raise ValueError("Brevo contact email must be a full email address.") + if list_id <= 0: + raise ValueError("Brevo list ID must be a positive integer.") + + payload = { + "email": normalized_email, + "listIds": [list_id], + "updateEnabled": True, + } + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "api-key": self.api_key, + } + + try: + response = requests.post( + f"{self.base_url}/contacts", + headers=headers, + json=payload, + timeout=self.timeout_seconds, + ) + except requests.RequestException as exc: + raise BrevoAPIError(f"Brevo API request failed: {exc}") from exc + + if response.status_code not in {200, 201, 204}: + raise BrevoAPIError( + "Brevo contact subscription failed: " + f"status={response.status_code}, body={response.text}" + ) + + if not response.content: + return {} + + try: + data = response.json() + except ValueError as exc: + raise BrevoAPIError("Brevo response payload must be valid JSON.") from exc + + if not isinstance(data, dict): + raise BrevoAPIError("Brevo response payload must be a JSON object.") + return data diff --git a/packages/shared/src/five08/settings.py b/packages/shared/src/five08/settings.py index 25d24c88..4e960fb6 100644 --- a/packages/shared/src/five08/settings.py +++ b/packages/shared/src/five08/settings.py @@ -79,6 +79,10 @@ class SharedSettings(BaseSettings): outline_base_url: str = "https://app.getoutline.com" outline_api_key: str | None = None outline_api_timeout_seconds: float = 20.0 + brevo_api_key: str | None = None + brevo_api_base_url: str = "https://api.brevo.com/v3" + brevo_api_timeout_seconds: float = 20.0 + brevo_newsletter_list_id: int | None = Field(default=None, ge=1) model_config = SettingsConfigDict( env_file=".env", diff --git a/tests/unit/test_agent_gateway.py b/tests/unit/test_agent_gateway.py index b666e609..de13603c 100644 --- a/tests/unit/test_agent_gateway.py +++ b/tests/unit/test_agent_gateway.py @@ -1879,6 +1879,7 @@ def test_user_accounts_create_is_admin_only() -> None: def _account_runtime_config( *, outline_api_key: str | None = "outline-key", + brevo_api_key: str | None = None, ) -> ToolRuntimeConfig: return ToolRuntimeConfig( espo_base_url="https://crm.example", @@ -1889,6 +1890,8 @@ def _account_runtime_config( authentik_api_token="authentik-token", authentik_recovery_email_stage_id="stage-1", outline_api_key=outline_api_key, + brevo_api_key=brevo_api_key, + brevo_newsletter_list_id=4, ) @@ -2081,15 +2084,35 @@ def invite_user( self.invites.append(invite) return invite + class FakeBrevoClient: + subscriptions: list[dict[str, Any]] = [] + + def __init__( + self, + *, + api_key: str, + base_url: str = "https://api.brevo.com/v3", + timeout_seconds: float = 20.0, + ) -> None: + self.api_key = api_key + self.base_url = base_url + self.timeout_seconds = timeout_seconds + + def add_contact_to_list(self, *, email: str, list_id: int) -> dict[str, Any]: + self.subscriptions.append({"email": email, "list_id": list_id}) + return {"id": len(self.subscriptions)} + monkeypatch.setattr("five08.agent.tools.EspoClient", FakeEspoClient) monkeypatch.setattr("five08.agent.tools.AuthentikClient", FakeAuthentikClient) monkeypatch.setattr("five08.agent.tools.MigaduClient", FakeMigaduClient) monkeypatch.setattr("five08.agent.tools.OutlineClient", FakeOutlineClient) + monkeypatch.setattr("five08.agent.tools.BrevoClient", FakeBrevoClient) return SimpleNamespace( espo=FakeEspoClient, authentik=FakeAuthentikClient, migadu=FakeMigaduClient, outline=FakeOutlineClient, + brevo=FakeBrevoClient, events=events, ) @@ -2464,6 +2487,34 @@ def test_user_accounts_tool_executes_all_steps( assert ("contact-1", {"cSsoID": "42"}) in fakes.espo.updates +def test_user_accounts_tool_subscribes_mailbox_and_backup_email_to_brevo( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fakes = _install_account_tool_fakes(monkeypatch) + registry = ToolRegistry(runtime_config=_account_runtime_config(brevo_api_key="key")) + + result = registry.execute( + "account_write.create_user_accounts", + {"contact_id": "contact-1", "mailbox_username": "jane@508.dev"}, + organization_id="org-1", + actor_id="123", + actor_scopes={ + "mailbox:create", + "user:manage", + "integration:manage", + "crm:contact:read", + "crm:contact:update", + }, + ) + + assert result["mailbox"]["newsletter_subscribed"] is True + assert result["mailbox"]["newsletter_error"] is None + assert fakes.brevo.subscriptions == [ + {"email": "jane@508.dev", "list_id": 4}, + {"email": "jane@example.com", "list_id": 4}, + ] + + def test_user_accounts_tool_preflights_before_mailbox_creation( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit/test_brevo_client.py b/tests/unit/test_brevo_client.py new file mode 100644 index 00000000..24aaea54 --- /dev/null +++ b/tests/unit/test_brevo_client.py @@ -0,0 +1,63 @@ +"""Unit tests for the shared Brevo API client.""" + +from unittest.mock import Mock, patch + +import pytest +import requests + +from five08.clients.brevo import BrevoAPIError, BrevoClient + + +def test_add_contact_to_list_posts_create_or_update_payload() -> None: + response = Mock() + response.status_code = 201 + response.content = b'{"id": 21}' + response.json.return_value = {"id": 21} + + with patch("five08.clients.brevo.requests.post", return_value=response) as post: + result = BrevoClient( + api_key="brevo-key", + timeout_seconds=7.0, + ).add_contact_to_list(email="Jane@Example.com", list_id=4) + + post.assert_called_once_with( + "https://api.brevo.com/v3/contacts", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "api-key": "brevo-key", + }, + json={ + "email": "jane@example.com", + "listIds": [4], + "updateEnabled": True, + }, + timeout=7.0, + ) + assert result == {"id": 21} + + +def test_add_contact_to_list_accepts_empty_success_body() -> None: + response = Mock() + response.status_code = 204 + response.content = b"" + + with patch("five08.clients.brevo.requests.post", return_value=response): + result = BrevoClient(api_key="brevo-key").add_contact_to_list( + email="jane@example.com", + list_id=4, + ) + + assert result == {} + + +def test_add_contact_to_list_raises_on_request_error() -> None: + with patch( + "five08.clients.brevo.requests.post", + side_effect=requests.Timeout("timed out"), + ): + with pytest.raises(BrevoAPIError, match="request failed"): + BrevoClient(api_key="brevo-key").add_contact_to_list( + email="jane@example.com", + list_id=4, + ) diff --git a/tests/unit/test_crm_create_sso_user.py b/tests/unit/test_crm_create_sso_user.py index 7923f676..6a10bc06 100644 --- a/tests/unit/test_crm_create_sso_user.py +++ b/tests/unit/test_crm_create_sso_user.py @@ -521,6 +521,11 @@ async def test_create_user_accounts_creates_mailbox_sso_and_outline_invite( patch.object(cog, "_migadu_client", return_value=migadu_client), patch.object(cog, "_authentik_client", return_value=authentik_client), patch.object(cog, "_outline_client", return_value=outline_client), + patch.object( + cog, + "_add_emails_to_newsletter", + new=AsyncMock(return_value=None), + ) as mock_newsletter, patch.object(cog, "_audit_command_safe") as mock_audit, ): mock_espo_api.request.return_value = {"id": "crm-123"} @@ -546,6 +551,9 @@ async def test_create_user_accounts_creates_mailbox_sso_and_outline_invite( name="Jane Doe", role="member", ) + mock_newsletter.assert_awaited_once_with( + ["jane@508.dev", "jane.personal@example.com"] + ) assert mock_espo_api.request.call_args_list[0].args == ( "PUT", "Contact/crm-123", @@ -560,6 +568,7 @@ async def test_create_user_accounts_creates_mailbox_sso_and_outline_invite( assert "User accounts are ready" in message assert "Email: `jane@508.dev`" in message assert "Outline invite: sent." in message + assert "Newsletter: added mailbox and backup email to Brevo." in message assert mock_interaction.followup.send.call_args.kwargs["ephemeral"] is True assert mock_audit.call_args.kwargs["metadata"]["outline_invited"] is True diff --git a/tests/unit/test_migadu_create_mailbox.py b/tests/unit/test_migadu_create_mailbox.py index c84c4b1d..e5e8dda9 100644 --- a/tests/unit/test_migadu_create_mailbox.py +++ b/tests/unit/test_migadu_create_mailbox.py @@ -32,6 +32,10 @@ def migadu_cog(mock_bot: Mock) -> MigaduCog: mock_settings.migadu_api_user = "migadu-user" mock_settings.migadu_api_key = "migadu-key" mock_settings.migadu_mailbox_domain = "508.dev" + mock_settings.brevo_api_key = None + mock_settings.brevo_api_base_url = "https://api.brevo.com/v3" + mock_settings.brevo_api_timeout_seconds = 20.0 + mock_settings.brevo_newsletter_list_id = 4 cog = MigaduCog(mock_bot) cog.espo_api = Mock() return cog @@ -90,6 +94,7 @@ async def test_create_mailbox_command_success_with_crm_defaults_and_sync( migadu_cog._create_migadu_mailbox = AsyncMock( return_value={"address": "alice@508.dev"} ) + migadu_cog._add_emails_to_newsletter = AsyncMock(return_value=None) await migadu_cog.create_mailbox.callback( migadu_cog, @@ -107,6 +112,9 @@ async def test_create_mailbox_command_success_with_crm_defaults_and_sync( "contact-1", {"c508Email": "alice@508.dev"}, ) + migadu_cog._add_emails_to_newsletter.assert_awaited_once_with( + ["alice@508.dev", "alice@gmail.com"] + ) _args, kwargs = mock_interaction.followup.send.call_args assert kwargs["embed"].title == "✅ Mailbox Created" diff --git a/tests/unit/test_shared_settings.py b/tests/unit/test_shared_settings.py index db5f974c..d90f321d 100644 --- a/tests/unit/test_shared_settings.py +++ b/tests/unit/test_shared_settings.py @@ -99,6 +99,8 @@ def test_shared_settings_expose_agent_external_tool_credentials() -> None: migadu_api_user="migadu-user", migadu_api_key="migadu-key", migadu_mailbox_domain="mail.example.com", + brevo_api_key="brevo-key", + brevo_newsletter_list_id=4, ) runtime_config = ToolRuntimeConfig.from_settings(settings) @@ -107,6 +109,8 @@ def test_shared_settings_expose_agent_external_tool_credentials() -> None: assert runtime_config.migadu_api_user == "migadu-user" assert runtime_config.migadu_api_key == "migadu-key" assert runtime_config.migadu_mailbox_domain == "mail.example.com" + assert runtime_config.brevo_api_key == "brevo-key" + assert runtime_config.brevo_newsletter_list_id == 4 def test_shared_settings_docuseal_template_id_accepts_numeric_string() -> None: From 6780a019e4a58658176f9d296d7a94f8459a8799 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 10 Jun 2026 01:43:38 +0800 Subject: [PATCH 02/14] Resolve Brevo members list by name --- .env.example | 3 +- ENVIRONMENT.md | 3 +- apps/discord_bot/README.md | 2 +- .../src/five08/discord_bot/cogs/crm.py | 19 +++++- .../src/five08/discord_bot/cogs/migadu.py | 19 +++++- packages/shared/src/five08/agent/tools.py | 28 ++++++-- packages/shared/src/five08/clients/brevo.py | 67 +++++++++++++++++++ packages/shared/src/five08/settings.py | 3 +- tests/unit/test_agent_gateway.py | 5 +- tests/unit/test_brevo_client.py | 34 ++++++++++ tests/unit/test_migadu_create_mailbox.py | 3 +- tests/unit/test_shared_settings.py | 6 +- 12 files changed, 172 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index a3bcc4f4..2e505b50 100644 --- a/.env.example +++ b/.env.example @@ -236,7 +236,8 @@ MIGADU_API_USER=your_migadu_api_user MIGADU_API_KEY=your_migadu_api_key MIGADU_MAILBOX_DOMAIN=508.dev BREVO_API_KEY= -BREVO_NEWSLETTER_LIST_ID= +BREVO_508_MEMBERS_NEWSLETTER_LIST_ID= +BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME=508 members # EspoCRM (required for worker integration) ESPO_API_KEY=your_key_here diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index e65f3004..c1409fcb 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -162,7 +162,8 @@ current precedence rules. - `Required for /create-mailbox and /create-user-accounts`: `MIGADU_API_USER`, `MIGADU_API_KEY` - `Optional`: `MIGADU_MAILBOX_DOMAIN` (default: `508.dev`) - `Optional for Brevo newsletter sync`: `BREVO_API_KEY` -- `Optional for Brevo newsletter sync`: `BREVO_NEWSLETTER_LIST_ID` (set to the production Brevo newsletter list ID) +- `Optional for Brevo newsletter sync`: `BREVO_508_MEMBERS_NEWSLETTER_LIST_ID` (explicit production Brevo list ID override) +- `Optional`: `BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME` (default: `508 members`; used to look up the list ID when the explicit ID is unset) - Note: mailbox and backup email subscription to Brevo is best effort. Failures are reported as warnings and do not block mailbox or account creation. ## Authentik SSO Provisioning diff --git a/apps/discord_bot/README.md b/apps/discord_bot/README.md index 2457f49d..0a538516 100644 --- a/apps/discord_bot/README.md +++ b/apps/discord_bot/README.md @@ -150,7 +150,7 @@ Relevant configuration: - `/create-mailbox` - Description: Create a Migadu mailbox for a 508 user, optionally link it to a CRM contact, and sync `c508Email`. - Prerequisites: `MIGADU_API_USER` and `MIGADU_API_KEY` must be configured (configured in env; command will fail if missing). - - Brevo newsletter sync: if `BREVO_API_KEY` and `BREVO_NEWSLETTER_LIST_ID` are configured, the new 508 mailbox and backup email are added to that Brevo list. Brevo failures are shown as warnings and do not block mailbox creation. + - Brevo newsletter sync: if `BREVO_API_KEY` is configured, the new 508 mailbox and backup email are added to `BREVO_508_MEMBERS_NEWSLETTER_LIST_ID`, or to the Brevo list named by `BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME` (default `508 members`) when the explicit ID is unset. Brevo failures are shown as warnings and do not block mailbox creation. - Required role: Admin - Args: - `mailbox_username` (required): 508 mailbox username or address. If the domain is omitted, `@508.dev` is added automatically. diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 8878fd5c..84ae80bb 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -3836,8 +3836,21 @@ async def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: client = self._brevo_client() if client is None: return "BREVO_API_KEY is not configured." - if settings.brevo_newsletter_list_id is None: - return "BREVO_NEWSLETTER_LIST_ID is not configured." + + list_id = settings.brevo_508_members_newsletter_list_id + if list_id is None: + try: + list_id = await asyncio.to_thread( + client.find_list_id_by_name, + settings.brevo_508_members_newsletter_list_name, + ) + except (BrevoAPIError, ValueError) as exc: + return f"Brevo list lookup failed: {exc}" + if list_id is None: + return ( + "Brevo list not found: " + f"{settings.brevo_508_members_newsletter_list_name}" + ) errors: list[str] = [] seen: set[str] = set() @@ -3850,7 +3863,7 @@ async def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: await asyncio.to_thread( client.add_contact_to_list, email=normalized_email, - list_id=settings.brevo_newsletter_list_id, + list_id=list_id, ) except (BrevoAPIError, ValueError) as exc: logger.warning( diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py b/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py index a9e10fec..45faff60 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py @@ -226,8 +226,21 @@ async def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: client = self._brevo_client() if client is None: return "BREVO_API_KEY is not configured." - if settings.brevo_newsletter_list_id is None: - return "BREVO_NEWSLETTER_LIST_ID is not configured." + + list_id = settings.brevo_508_members_newsletter_list_id + if list_id is None: + try: + list_id = await asyncio.to_thread( + client.find_list_id_by_name, + settings.brevo_508_members_newsletter_list_name, + ) + except (BrevoAPIError, ValueError) as exc: + return f"Brevo list lookup failed: {exc}" + if list_id is None: + return ( + "Brevo list not found: " + f"{settings.brevo_508_members_newsletter_list_name}" + ) errors: list[str] = [] seen: set[str] = set() @@ -240,7 +253,7 @@ async def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: await asyncio.to_thread( client.add_contact_to_list, email=normalized_email, - list_id=settings.brevo_newsletter_list_id, + list_id=list_id, ) except (BrevoAPIError, ValueError) as exc: logger.warning( diff --git a/packages/shared/src/five08/agent/tools.py b/packages/shared/src/five08/agent/tools.py index de918ea2..20a32154 100644 --- a/packages/shared/src/five08/agent/tools.py +++ b/packages/shared/src/five08/agent/tools.py @@ -79,7 +79,8 @@ class ToolRuntimeConfig: brevo_api_key: str | None = None brevo_api_base_url: str = "https://api.brevo.com/v3" brevo_api_timeout_seconds: float = 20.0 - brevo_newsletter_list_id: int | None = None + brevo_508_members_newsletter_list_id: int | None = None + brevo_508_members_newsletter_list_name: str = "508 members" @classmethod def from_settings(cls, settings: Any) -> "ToolRuntimeConfig": @@ -143,8 +144,11 @@ def from_settings(cls, settings: Any) -> "ToolRuntimeConfig": "brevo_api_timeout_seconds", 20.0, ), - brevo_newsletter_list_id=getattr( - settings, "brevo_newsletter_list_id", None + brevo_508_members_newsletter_list_id=getattr( + settings, "brevo_508_members_newsletter_list_id", None + ), + brevo_508_members_newsletter_list_name=getattr( + settings, "brevo_508_members_newsletter_list_name", "508 members" ), ) @@ -1422,8 +1426,20 @@ def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: client = self._brevo_client() if client is None: return "BREVO_API_KEY is not configured." - if self.runtime_config.brevo_newsletter_list_id is None: - return "BREVO_NEWSLETTER_LIST_ID is not configured." + + list_id = self.runtime_config.brevo_508_members_newsletter_list_id + if list_id is None: + try: + list_id = client.find_list_id_by_name( + self.runtime_config.brevo_508_members_newsletter_list_name + ) + except (BrevoAPIError, ValueError) as exc: + return f"Brevo list lookup failed: {_short_error(exc)}" + if list_id is None: + return ( + "Brevo list not found: " + f"{self.runtime_config.brevo_508_members_newsletter_list_name}" + ) errors: list[str] = [] seen: set[str] = set() @@ -1435,7 +1451,7 @@ def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: try: client.add_contact_to_list( email=normalized_email, - list_id=self.runtime_config.brevo_newsletter_list_id, + list_id=list_id, ) except (BrevoAPIError, ValueError) as exc: errors.append(f"{normalized_email}: {_short_error(exc)}") diff --git a/packages/shared/src/five08/clients/brevo.py b/packages/shared/src/five08/clients/brevo.py index 533b7e63..060adf41 100644 --- a/packages/shared/src/five08/clients/brevo.py +++ b/packages/shared/src/five08/clients/brevo.py @@ -78,3 +78,70 @@ def add_contact_to_list( if not isinstance(data, dict): raise BrevoAPIError("Brevo response payload must be a JSON object.") return data + + def find_list_id_by_name(self, name: str) -> int | None: + """Find a Brevo contact list ID by exact case-insensitive name.""" + normalized_name = name.strip().casefold() + if not normalized_name: + raise ValueError("Brevo list name must be non-empty.") + + limit = 50 + offset = 0 + while True: + payload = self._get_lists_page(limit=limit, offset=offset) + lists = payload.get("lists", []) + if not isinstance(lists, list): + raise BrevoAPIError("Brevo lists payload must include a list array.") + + for item in lists: + if not isinstance(item, dict): + continue + item_name = str(item.get("name") or "").strip().casefold() + if item_name != normalized_name: + continue + list_id = item.get("id") + if not isinstance(list_id, int): + raise BrevoAPIError("Brevo list ID must be an integer.") + return list_id + + count = payload.get("count") + offset += limit + if isinstance(count, int) and offset >= count: + return None + if len(lists) < limit: + return None + + def _get_lists_page(self, *, limit: int, offset: int) -> dict[str, Any]: + headers = { + "Accept": "application/json", + "api-key": self.api_key, + } + params: dict[str, str | int] = { + "limit": limit, + "offset": offset, + "sort": "asc", + } + try: + response = requests.get( + f"{self.base_url}/contacts/lists", + headers=headers, + params=params, + timeout=self.timeout_seconds, + ) + except requests.RequestException as exc: + raise BrevoAPIError(f"Brevo API request failed: {exc}") from exc + + if response.status_code != 200: + raise BrevoAPIError( + "Brevo list lookup failed: " + f"status={response.status_code}, body={response.text}" + ) + + try: + data = response.json() + except ValueError as exc: + raise BrevoAPIError("Brevo response payload must be valid JSON.") from exc + + if not isinstance(data, dict): + raise BrevoAPIError("Brevo response payload must be a JSON object.") + return data diff --git a/packages/shared/src/five08/settings.py b/packages/shared/src/five08/settings.py index 4e960fb6..1a3cb3a4 100644 --- a/packages/shared/src/five08/settings.py +++ b/packages/shared/src/five08/settings.py @@ -82,7 +82,8 @@ class SharedSettings(BaseSettings): brevo_api_key: str | None = None brevo_api_base_url: str = "https://api.brevo.com/v3" brevo_api_timeout_seconds: float = 20.0 - brevo_newsletter_list_id: int | None = Field(default=None, ge=1) + brevo_508_members_newsletter_list_id: int | None = Field(default=None, ge=1) + brevo_508_members_newsletter_list_name: str = "508 members" model_config = SettingsConfigDict( env_file=".env", diff --git a/tests/unit/test_agent_gateway.py b/tests/unit/test_agent_gateway.py index de13603c..c1fc580f 100644 --- a/tests/unit/test_agent_gateway.py +++ b/tests/unit/test_agent_gateway.py @@ -1891,7 +1891,7 @@ def _account_runtime_config( authentik_recovery_email_stage_id="stage-1", outline_api_key=outline_api_key, brevo_api_key=brevo_api_key, - brevo_newsletter_list_id=4, + brevo_508_members_newsletter_list_id=4, ) @@ -2102,6 +2102,9 @@ def add_contact_to_list(self, *, email: str, list_id: int) -> dict[str, Any]: self.subscriptions.append({"email": email, "list_id": list_id}) return {"id": len(self.subscriptions)} + def find_list_id_by_name(self, name: str) -> int | None: + return 4 if name == "508 members" else None + monkeypatch.setattr("five08.agent.tools.EspoClient", FakeEspoClient) monkeypatch.setattr("five08.agent.tools.AuthentikClient", FakeAuthentikClient) monkeypatch.setattr("five08.agent.tools.MigaduClient", FakeMigaduClient) diff --git a/tests/unit/test_brevo_client.py b/tests/unit/test_brevo_client.py index 24aaea54..e4373318 100644 --- a/tests/unit/test_brevo_client.py +++ b/tests/unit/test_brevo_client.py @@ -61,3 +61,37 @@ def test_add_contact_to_list_raises_on_request_error() -> None: email="jane@example.com", list_id=4, ) + + +def test_find_list_id_by_name_gets_matching_list() -> None: + response = Mock() + response.status_code = 200 + response.json.return_value = { + "count": 2, + "lists": [ + {"id": 3, "name": "Other"}, + {"id": 4, "name": "508 members"}, + ], + } + + with patch("five08.clients.brevo.requests.get", return_value=response) as get: + list_id = BrevoClient(api_key="brevo-key").find_list_id_by_name("508 Members") + + get.assert_called_once_with( + "https://api.brevo.com/v3/contacts/lists", + headers={"Accept": "application/json", "api-key": "brevo-key"}, + params={"limit": 50, "offset": 0, "sort": "asc"}, + timeout=20.0, + ) + assert list_id == 4 + + +def test_find_list_id_by_name_returns_none_when_missing() -> None: + response = Mock() + response.status_code = 200 + response.json.return_value = {"count": 1, "lists": [{"id": 3, "name": "Other"}]} + + with patch("five08.clients.brevo.requests.get", return_value=response): + list_id = BrevoClient(api_key="brevo-key").find_list_id_by_name("508 members") + + assert list_id is None diff --git a/tests/unit/test_migadu_create_mailbox.py b/tests/unit/test_migadu_create_mailbox.py index e5e8dda9..b70925de 100644 --- a/tests/unit/test_migadu_create_mailbox.py +++ b/tests/unit/test_migadu_create_mailbox.py @@ -35,7 +35,8 @@ def migadu_cog(mock_bot: Mock) -> MigaduCog: mock_settings.brevo_api_key = None mock_settings.brevo_api_base_url = "https://api.brevo.com/v3" mock_settings.brevo_api_timeout_seconds = 20.0 - mock_settings.brevo_newsletter_list_id = 4 + mock_settings.brevo_508_members_newsletter_list_id = None + mock_settings.brevo_508_members_newsletter_list_name = "508 members" cog = MigaduCog(mock_bot) cog.espo_api = Mock() return cog diff --git a/tests/unit/test_shared_settings.py b/tests/unit/test_shared_settings.py index d90f321d..d2e73bdd 100644 --- a/tests/unit/test_shared_settings.py +++ b/tests/unit/test_shared_settings.py @@ -100,7 +100,8 @@ def test_shared_settings_expose_agent_external_tool_credentials() -> None: migadu_api_key="migadu-key", migadu_mailbox_domain="mail.example.com", brevo_api_key="brevo-key", - brevo_newsletter_list_id=4, + brevo_508_members_newsletter_list_id=4, + brevo_508_members_newsletter_list_name="508 members", ) runtime_config = ToolRuntimeConfig.from_settings(settings) @@ -110,7 +111,8 @@ def test_shared_settings_expose_agent_external_tool_credentials() -> None: assert runtime_config.migadu_api_key == "migadu-key" assert runtime_config.migadu_mailbox_domain == "mail.example.com" assert runtime_config.brevo_api_key == "brevo-key" - assert runtime_config.brevo_newsletter_list_id == 4 + assert runtime_config.brevo_508_members_newsletter_list_id == 4 + assert runtime_config.brevo_508_members_newsletter_list_name == "508 members" def test_shared_settings_docuseal_template_id_accepts_numeric_string() -> None: From e6dce190250fd4f9be45ccc0f24faa1af57618b7 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 10 Jun 2026 14:01:16 +0800 Subject: [PATCH 03/14] Add recurring member newsletter sync --- .env.example | 5 + ENVIRONMENT.md | 11 +- apps/admin_dashboard/src/main.tsx | 43 ++ apps/api/src/five08/backend/api.py | 121 ++++++ apps/discord_bot/README.md | 2 +- .../src/five08/discord_bot/cogs/crm.py | 78 +--- .../src/five08/discord_bot/cogs/migadu.py | 72 +--- apps/worker/src/five08/worker/jobs.py | 9 + packages/shared/src/five08/agent/tools.py | 68 ++- packages/shared/src/five08/clients/brevo.py | 33 ++ packages/shared/src/five08/clients/keila.py | 127 ++++++ packages/shared/src/five08/clients/migadu.py | 55 +++ packages/shared/src/five08/newsletter_sync.py | 391 ++++++++++++++++++ packages/shared/src/five08/settings.py | 11 + tests/unit/test_agent_gateway.py | 77 +++- tests/unit/test_backend_api.py | 79 ++++ tests/unit/test_brevo_client.py | 26 ++ tests/unit/test_crm_create_sso_user.py | 2 +- tests/unit/test_keila_client.py | 121 ++++++ tests/unit/test_newsletter_sync.py | 215 ++++++++++ tests/unit/test_shared_settings.py | 4 + 21 files changed, 1387 insertions(+), 163 deletions(-) create mode 100644 packages/shared/src/five08/clients/keila.py create mode 100644 packages/shared/src/five08/newsletter_sync.py create mode 100644 tests/unit/test_keila_client.py create mode 100644 tests/unit/test_newsletter_sync.py diff --git a/.env.example b/.env.example index 2e505b50..5dc82345 100644 --- a/.env.example +++ b/.env.example @@ -238,6 +238,11 @@ MIGADU_MAILBOX_DOMAIN=508.dev BREVO_API_KEY= BREVO_508_MEMBERS_NEWSLETTER_LIST_ID= BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME=508 members +KEILA_API_KEY= +KEILA_API_BASE_URL=https://app.keila.io +NEWSLETTER_SYNC_ENABLED=true +NEWSLETTER_SYNC_INTERVAL_SECONDS=604800 +NEWSLETTER_SYNC_EXCLUDED_MAILBOXES=authentik@508.dev,baserow@508.dev,cal@508.dev,calendar@508.dev,coolify@508.dev,crm@508.dev,docuseal@508.dev,events@508.dev,keycloak@508.dev,kimai@508.dev,matrix@508.dev,openproject@508.dev,supabase@508.dev,vaultwarden@508.dev,wiki@508.dev # EspoCRM (required for worker integration) ESPO_API_KEY=your_key_here diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index c1409fcb..23505d6d 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -162,9 +162,16 @@ current precedence rules. - `Required for /create-mailbox and /create-user-accounts`: `MIGADU_API_USER`, `MIGADU_API_KEY` - `Optional`: `MIGADU_MAILBOX_DOMAIN` (default: `508.dev`) - `Optional for Brevo newsletter sync`: `BREVO_API_KEY` -- `Optional for Brevo newsletter sync`: `BREVO_508_MEMBERS_NEWSLETTER_LIST_ID` (explicit production Brevo list ID override) +- `Optional for Brevo newsletter sync`: `BREVO_508_MEMBERS_NEWSLETTER_LIST_ID` (explicit production Brevo list ID override; production should set `4` for the 508 members list) - `Optional`: `BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME` (default: `508 members`; used to look up the list ID when the explicit ID is unset) -- Note: mailbox and backup email subscription to Brevo is best effort. Failures are reported as warnings and do not block mailbox or account creation. +- `Optional for Keila contact sync`: `KEILA_API_KEY` +- `Optional`: `KEILA_API_BASE_URL` (default: `https://app.keila.io`) +- `Optional`: `KEILA_API_TIMEOUT_SECONDS` (default: `20.0`) +- `Optional`: `NEWSLETTER_SYNC_ENABLED` (default: `true`) +- `Optional`: `NEWSLETTER_SYNC_INTERVAL_SECONDS` (default: `604800`, one week) +- `Optional`: `NEWSLETTER_SYNC_EXCLUDED_MAILBOXES` (comma-separated system mailboxes to skip during Migadu resync) +- Note: mailbox and backup email subscription to configured newsletter tools is best effort. Failures are reported as warnings and do not block mailbox or account creation. +- Note: the periodic sync uses Migadu mailboxes and password recovery emails as the source of truth for `@508.dev`, skips configured system mailboxes, and does not re-add provider-suppressed contacts. ## Authentik SSO Provisioning diff --git a/apps/admin_dashboard/src/main.tsx b/apps/admin_dashboard/src/main.tsx index 6f30bd4e..dfe5311c 100644 --- a/apps/admin_dashboard/src/main.tsx +++ b/apps/admin_dashboard/src/main.tsx @@ -1667,6 +1667,32 @@ function App() { } } + async function syncNewsletters() { + setBusy("syncNewsletters", true) + showToast("Queueing newsletter sync") + try { + const payload = await requestJson<{ + job_id?: string + dry_run?: boolean + would_enqueue?: { job_type?: string } + }>("/dashboard/api/sync/newsletters", { + method: "POST", + }) + if (payload.dry_run) { + showToast( + `Dry run only: would queue ${payload.would_enqueue?.job_type || "newsletter sync"}`, + "warning", + ) + } else { + showToast(`Queued newsletter sync ${payload.job_id}`, "ok") + } + } catch (error) { + showError(error, "Unable to queue newsletter sync") + } finally { + setBusy("syncNewsletters", false) + } + } + async function assignOnboarder(contactId: string | undefined, onboarder: string) { const normalizedContactId = String(contactId || "").trim() const normalizedOnboarder = onboarder.trim() @@ -2179,6 +2205,7 @@ function App() { people={sortedPeople} sort={sort.people} canSync={canUse("people:sync")} + canSyncNewsletters={canUse("people:sync")} loading={loading} peopleQuery={peopleQuery} peopleMember={peopleMember} @@ -2188,6 +2215,7 @@ function App() { peopleFilterKeys={peopleFilterKeys} onSearch={loadPeople} onSync={syncPeople} + onSyncNewsletters={syncNewsletters} onSort={(key) => handleSort("people", key)} setPeopleQuery={setPeopleQuery} setPeopleMember={setPeopleMember} @@ -5177,6 +5205,7 @@ function PeopleView(props: { people: Person[] sort: { key: string; direction: SortDirection } canSync: boolean + canSyncNewsletters: boolean loading: Record peopleQuery: string peopleMember: string @@ -5186,6 +5215,7 @@ function PeopleView(props: { peopleFilterKeys: PeopleFilterKey[] onSearch: () => void onSync: () => void + onSyncNewsletters: () => void onSort: (key: string) => void setPeopleQuery: (value: string) => void setPeopleMember: (value: string) => void @@ -5214,6 +5244,19 @@ function PeopleView(props: { Sync people ) : null} + {props.canSyncNewsletters ? ( + + ) : null} {props.crmBaseUrl ? ( str: return f"crm-sync:{bucket}" +def _newsletter_sync_idempotency_key(*, now: datetime) -> str: + interval_seconds = max(1, settings.newsletter_sync_interval_seconds) + bucket = int(now.timestamp()) // interval_seconds + return f"newsletter-sync:508-members:{bucket}" + + def _normalize_google_forms_input(value: str | None) -> str | None: if not isinstance(value, str): return None @@ -610,6 +616,34 @@ async def _enqueue_erpnext_project_sync_job( return job +async def _enqueue_newsletter_sync_job( + queue: QueueClient, + *, + reason: str, +) -> EnqueuedJob: + now = datetime.now(tz=timezone.utc) + idempotency_key = ( + _newsletter_sync_idempotency_key(now=now) + if reason == "scheduler" + else f"newsletter-sync:508-members:{reason}:{now.strftime('%Y%m%d%H%M%S')}" + ) + job: EnqueuedJob = await asyncio.to_thread( + enqueue_job, + queue=queue, + fn=JOB_FUNCTIONS["sync_508_members_newsletters_job"], + args=(), + settings=settings, + idempotency_key=idempotency_key, + ) + logger.info( + "Enqueued 508 members newsletter sync job id=%s created=%s reason=%s", + job.id, + job.created, + reason, + ) + return job + + async def _crm_sync_scheduler(app: FastAPI) -> None: queue = app.state.queue interval_seconds = max(1, settings.crm_sync_interval_seconds) @@ -621,6 +655,17 @@ async def _crm_sync_scheduler(app: FastAPI) -> None: await asyncio.sleep(interval_seconds) +async def _newsletter_sync_scheduler(app: FastAPI) -> None: + queue = app.state.queue + interval_seconds = max(1, settings.newsletter_sync_interval_seconds) + while True: + try: + await _enqueue_newsletter_sync_job(queue, reason="scheduler") + except Exception: + logger.exception("Failed scheduling 508 members newsletter sync job") + await asyncio.sleep(interval_seconds) + + async def _email_resume_scheduler() -> None: """Run periodic mailbox polling for resume ingestion.""" poller = ResumeMailboxProcessor(settings) @@ -5886,6 +5931,64 @@ async def dashboard_sync_people_handler(request: Request) -> JSONResponse: ) +async def dashboard_sync_newsletters_handler(request: Request) -> JSONResponse: + """Queue a 508 members newsletter sync from the authenticated dashboard.""" + session, error_response, dry_run = await _dashboard_write_session_or_dry_run( + request, + required_permission=DASHBOARD_PERMISSION_PEOPLE_SYNC, + dry_run_permission=DASHBOARD_PERMISSION_PEOPLE_SYNC_DRY_RUN, + ) + if error_response is not None: + return error_response + assert session is not None + + csrf_error = _dashboard_same_origin_post_or_error(request) + if csrf_error is not None: + return csrf_error + + if dry_run: + return JSONResponse( + { + "status": "dry_run", + "dry_run": True, + "source": "dashboard", + "would_enqueue": { + "queue": settings.redis_queue_name, + "job_type": "sync_508_members_newsletters_job", + "reason": "dashboard", + "idempotency_key_pattern": "newsletter-sync:508-members:dashboard:", + }, + } + ) + + job = await _enqueue_newsletter_sync_job( + request.app.state.queue, reason="dashboard" + ) + actor_provider, actor_subject = _session_audit_actor(session) + await _write_auth_audit_event( + action="newsletter.508_members_sync", + result=AuditResult.SUCCESS, + actor_subject=actor_subject, + actor_display_name=session.display_name, + actor_provider=actor_provider, + resource_type="newsletter_sync", + resource_id=job.id, + metadata={ + "source": "dashboard", + "queue": settings.redis_queue_name, + }, + ) + return JSONResponse( + { + "status": "queued", + "source": "dashboard", + "job_id": job.id, + "created": job.created, + }, + status_code=202, + ) + + async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse: """Queue per-contact people cache sync jobs from CRM webhook events.""" if not _is_webhook_authorized(request): @@ -7360,6 +7463,13 @@ async def _lifespan(app: FastAPI) -> Any: else: logger.info("CRM sync scheduler disabled by config") + if settings.newsletter_sync_enabled: + app.state.newsletter_sync_task = asyncio.create_task( + _newsletter_sync_scheduler(app) + ) + else: + logger.info("508 members newsletter sync scheduler disabled by config") + if settings.email_resume_intake_enabled: app.state.email_resume_task = asyncio.create_task(_email_resume_scheduler()) else: @@ -7380,6 +7490,12 @@ async def _lifespan(app: FastAPI) -> Any: with contextlib.suppress(asyncio.CancelledError): await task + if hasattr(app.state, "newsletter_sync_task"): + task = app.state.newsletter_sync_task + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + if hasattr(app.state, "http_client"): await app.state.http_client.aclose() @@ -7585,6 +7701,11 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI: dashboard_sync_people_handler, methods=["POST"], ) + app.add_api_route( + "/dashboard/api/sync/newsletters", + dashboard_sync_newsletters_handler, + methods=["POST"], + ) app.add_api_route( "/dashboard/gigs/{item_id}", dashboard_handler, diff --git a/apps/discord_bot/README.md b/apps/discord_bot/README.md index 0a538516..c2e4aaa1 100644 --- a/apps/discord_bot/README.md +++ b/apps/discord_bot/README.md @@ -150,7 +150,7 @@ Relevant configuration: - `/create-mailbox` - Description: Create a Migadu mailbox for a 508 user, optionally link it to a CRM contact, and sync `c508Email`. - Prerequisites: `MIGADU_API_USER` and `MIGADU_API_KEY` must be configured (configured in env; command will fail if missing). - - Brevo newsletter sync: if `BREVO_API_KEY` is configured, the new 508 mailbox and backup email are added to `BREVO_508_MEMBERS_NEWSLETTER_LIST_ID`, or to the Brevo list named by `BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME` (default `508 members`) when the explicit ID is unset. Brevo failures are shown as warnings and do not block mailbox creation. + - Newsletter sync: if Brevo and/or Keila are configured, the new 508 mailbox and backup email are added to the configured 508 members audience. Brevo uses `BREVO_508_MEMBERS_NEWSLETTER_LIST_ID`, or the list named by `BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME` (default `508 members`) when the explicit ID is unset. Newsletter failures are shown as warnings and do not block mailbox creation. - Required role: Admin - Args: - `mailbox_username` (required): 508 mailbox username or address. If the domain is omitted, `@508.dev` is added automatically. diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 84ae80bb..fb951691 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -22,7 +22,6 @@ from five08.discord_bot.config import settings from five08.clients.authentik import AuthentikAPIError, AuthentikClient -from five08.clients.brevo import BrevoAPIError, BrevoClient from five08.clients import espo from five08.clients.docuseal import ( DocusealAPIError, @@ -52,6 +51,10 @@ ResumeProcessorConfig, ResumeProfileProcessor, ) +from five08.newsletter_sync import ( + format_newsletter_sync_warning, + sync_newsletter_contacts, +) from five08.skills import normalize_skill, normalize_skill_list from five08.discord_bot.utils.audit import DiscordAuditCogMixin from five08.discord_bot.utils.role_decorators import ( @@ -3820,59 +3823,18 @@ def _migadu_client(self) -> MigaduClient: domain=self._migadu_mailbox_domain(), ) - def _brevo_client(self) -> BrevoClient | None: - """Build a Brevo client when newsletter sync is configured.""" - api_key = self._contact_text_value(settings.brevo_api_key) - if not api_key: - return None - return BrevoClient( - api_key=api_key, - base_url=settings.brevo_api_base_url, - timeout_seconds=settings.brevo_api_timeout_seconds, - ) - async def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: - """Best-effort subscribe mailbox and backup addresses to Brevo.""" - client = self._brevo_client() - if client is None: - return "BREVO_API_KEY is not configured." - - list_id = settings.brevo_508_members_newsletter_list_id - if list_id is None: - try: - list_id = await asyncio.to_thread( - client.find_list_id_by_name, - settings.brevo_508_members_newsletter_list_name, - ) - except (BrevoAPIError, ValueError) as exc: - return f"Brevo list lookup failed: {exc}" - if list_id is None: - return ( - "Brevo list not found: " - f"{settings.brevo_508_members_newsletter_list_name}" - ) - - errors: list[str] = [] - seen: set[str] = set() - for email in emails: - normalized_email = email.strip().lower() - if not normalized_email or normalized_email in seen: - continue - seen.add(normalized_email) - try: - await asyncio.to_thread( - client.add_contact_to_list, - email=normalized_email, - list_id=list_id, - ) - except (BrevoAPIError, ValueError) as exc: - logger.warning( - "Failed to add %s to Brevo newsletter list: %s", - normalized_email, - exc, - ) - errors.append(f"{normalized_email}: {exc}") - return "; ".join(errors) if errors else None + """Best-effort subscribe mailbox and backup addresses to newsletter tools.""" + result = await asyncio.to_thread( + sync_newsletter_contacts, + settings, + emails, + source="discord_create_user_accounts", + ) + warning = format_newsletter_sync_warning(result) + if warning: + logger.warning("Newsletter sync warning: %s", warning) + return warning def _normalize_mailbox_request(self, mailbox_username: str) -> tuple[str, str]: """Normalize a bare or full 508 mailbox request to email and local-part.""" @@ -8639,9 +8601,9 @@ async def _create_user_accounts_for_contact( }, ) newsletter_line = ( - f"\nNewsletter: Brevo subscription failed: `{exc.newsletter_error}`" + f"\nNewsletter: subscription warning: `{exc.newsletter_error}`" if exc.newsletter_error - else "\nNewsletter: added mailbox and backup email to Brevo." + else "\nNewsletter: added mailbox and backup email." ) if exc.partial_success == "mailbox_created_crm_update_failed": await interaction.followup.send( @@ -8801,12 +8763,10 @@ async def _create_user_accounts_for_contact( "Outline invite: sent.", ] if result.mailbox.newsletter_error is None: - message_lines.append( - "Newsletter: added mailbox and backup email to Brevo." - ) + message_lines.append("Newsletter: added mailbox and backup email.") else: message_lines.append( - "Newsletter: Brevo subscription failed: " + "Newsletter: subscription warning: " f"`{result.mailbox.newsletter_error}`" ) if result.sso.created: diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py b/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py index 45faff60..1dbaf8f3 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py @@ -17,7 +17,6 @@ from discord.ext import commands from five08.clients.espo import EspoAPIError, EspoClient -from five08.clients.brevo import BrevoAPIError, BrevoClient from five08.clients.migadu import ( MigaduAPIError, MigaduClient, @@ -27,6 +26,10 @@ from five08.discord_bot.config import settings from five08.discord_bot.utils.audit import DiscordAuditCogMixin from five08.discord_bot.utils.role_decorators import require_role +from five08.newsletter_sync import ( + format_newsletter_sync_warning, + sync_newsletter_contacts, +) logger = logging.getLogger(__name__) @@ -210,59 +213,18 @@ def _migadu_client(self) -> MigaduClient: domain=self._migadu_mailbox_domain(), ) - def _brevo_client(self) -> BrevoClient | None: - """Build a Brevo client when newsletter sync is configured.""" - api_key = (settings.brevo_api_key or "").strip() - if not api_key: - return None - return BrevoClient( - api_key=api_key, - base_url=settings.brevo_api_base_url, - timeout_seconds=settings.brevo_api_timeout_seconds, - ) - async def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: - """Best-effort subscribe mailbox and backup addresses to Brevo.""" - client = self._brevo_client() - if client is None: - return "BREVO_API_KEY is not configured." - - list_id = settings.brevo_508_members_newsletter_list_id - if list_id is None: - try: - list_id = await asyncio.to_thread( - client.find_list_id_by_name, - settings.brevo_508_members_newsletter_list_name, - ) - except (BrevoAPIError, ValueError) as exc: - return f"Brevo list lookup failed: {exc}" - if list_id is None: - return ( - "Brevo list not found: " - f"{settings.brevo_508_members_newsletter_list_name}" - ) - - errors: list[str] = [] - seen: set[str] = set() - for email in emails: - normalized_email = email.strip().lower() - if not normalized_email or normalized_email in seen: - continue - seen.add(normalized_email) - try: - await asyncio.to_thread( - client.add_contact_to_list, - email=normalized_email, - list_id=list_id, - ) - except (BrevoAPIError, ValueError) as exc: - logger.warning( - "Failed to add %s to Brevo newsletter list: %s", - normalized_email, - exc, - ) - errors.append(f"{normalized_email}: {exc}") - return "; ".join(errors) if errors else None + """Best-effort subscribe mailbox and backup addresses to newsletter tools.""" + result = await asyncio.to_thread( + sync_newsletter_contacts, + settings, + emails, + source="discord_create_mailbox", + ) + warning = format_newsletter_sync_warning(result) + if warning: + logger.warning("Newsletter sync warning: %s", warning) + return warning def _normalize_mailbox_request(self, mailbox_username: str) -> tuple[str, str]: """ @@ -713,13 +675,13 @@ def _build_mailbox_embed( if outcome.newsletter_error: embed.add_field( name="Newsletter", - value=f"Brevo subscription failed: {outcome.newsletter_error}", + value=f"Newsletter subscription warning: {outcome.newsletter_error}", inline=False, ) else: embed.add_field( name="Newsletter", - value="Added mailbox and backup email to Brevo.", + value="Added mailbox and backup email to newsletter tools.", inline=False, ) diff --git a/apps/worker/src/five08/worker/jobs.py b/apps/worker/src/five08/worker/jobs.py index 5d86de0b..00d6da11 100644 --- a/apps/worker/src/five08/worker/jobs.py +++ b/apps/worker/src/five08/worker/jobs.py @@ -16,6 +16,7 @@ from five08.worker.erpnext_project_sync import ERPNextProjectSyncProcessor from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor from five08.worker.masking import mask_email +from five08.newsletter_sync import NewsletterSyncProcessor logger = logging.getLogger(__name__) @@ -164,6 +165,13 @@ def sync_projects_from_erpnext_job() -> dict[str, Any]: return processor.sync_open_projects() +def sync_508_members_newsletters_job() -> dict[str, Any]: + """Sync Migadu member emails into configured newsletter providers.""" + logger.info("Processing 508 members newsletter sync job") + processor = NewsletterSyncProcessor(settings) + return processor.sync_508_members() + + JOB_FUNCTIONS: dict[str, Callable[..., dict[str, Any]]] = { process_webhook_event.__name__: process_webhook_event, process_contact_skills_job.__name__: process_contact_skills_job, @@ -174,5 +182,6 @@ def sync_projects_from_erpnext_job() -> dict[str, Any]: sync_people_from_crm_job.__name__: sync_people_from_crm_job, sync_person_from_crm_job.__name__: sync_person_from_crm_job, sync_projects_from_erpnext_job.__name__: sync_projects_from_erpnext_job, + sync_508_members_newsletters_job.__name__: sync_508_members_newsletters_job, process_docuseal_agreement_job.__name__: process_docuseal_agreement_job, } diff --git a/packages/shared/src/five08/agent/tools.py b/packages/shared/src/five08/agent/tools.py index 20a32154..f6c3e7b3 100644 --- a/packages/shared/src/five08/agent/tools.py +++ b/packages/shared/src/five08/agent/tools.py @@ -17,7 +17,6 @@ RiskLevel, ) from five08.clients.authentik import AuthentikAPIError, AuthentikClient -from five08.clients.brevo import BrevoAPIError, BrevoClient from five08.clients.docuseal import create_member_agreement_submission from five08.clients.espo import EspoAPIError, EspoClient from five08.clients.github import GitHubClient @@ -28,6 +27,10 @@ ) from five08.clients.outline import OutlineClient from five08.crm_contacts import EspoContactRepository +from five08.newsletter_sync import ( + format_newsletter_sync_warning, + sync_newsletter_contacts, +) SSO_ID_FIELD = "cSsoID" @@ -81,6 +84,9 @@ class ToolRuntimeConfig: brevo_api_timeout_seconds: float = 20.0 brevo_508_members_newsletter_list_id: int | None = None brevo_508_members_newsletter_list_name: str = "508 members" + keila_api_key: str | None = None + keila_api_base_url: str = "https://app.keila.io" + keila_api_timeout_seconds: float = 20.0 @classmethod def from_settings(cls, settings: Any) -> "ToolRuntimeConfig": @@ -150,6 +156,13 @@ def from_settings(cls, settings: Any) -> "ToolRuntimeConfig": brevo_508_members_newsletter_list_name=getattr( settings, "brevo_508_members_newsletter_list_name", "508 members" ), + keila_api_key=getattr(settings, "keila_api_key", None), + keila_api_base_url=getattr( + settings, "keila_api_base_url", "https://app.keila.io" + ), + keila_api_timeout_seconds=getattr( + settings, "keila_api_timeout_seconds", 20.0 + ), ) @@ -1412,50 +1425,17 @@ def _migadu_client(self) -> MigaduClient: ), ) - def _brevo_client(self) -> BrevoClient | None: - api_key = _optional_str(self.runtime_config.brevo_api_key) - if api_key is None: - return None - return BrevoClient( - api_key=api_key, - base_url=self.runtime_config.brevo_api_base_url, - timeout_seconds=self.runtime_config.brevo_api_timeout_seconds, - ) - def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: - client = self._brevo_client() - if client is None: - return "BREVO_API_KEY is not configured." - - list_id = self.runtime_config.brevo_508_members_newsletter_list_id - if list_id is None: - try: - list_id = client.find_list_id_by_name( - self.runtime_config.brevo_508_members_newsletter_list_name - ) - except (BrevoAPIError, ValueError) as exc: - return f"Brevo list lookup failed: {_short_error(exc)}" - if list_id is None: - return ( - "Brevo list not found: " - f"{self.runtime_config.brevo_508_members_newsletter_list_name}" - ) - - errors: list[str] = [] - seen: set[str] = set() - for email in emails: - normalized_email = email.strip().lower() - if not normalized_email or normalized_email in seen: - continue - seen.add(normalized_email) - try: - client.add_contact_to_list( - email=normalized_email, - list_id=list_id, - ) - except (BrevoAPIError, ValueError) as exc: - errors.append(f"{normalized_email}: {_short_error(exc)}") - return "; ".join(errors) if errors else None + result = sync_newsletter_contacts( + self.runtime_config, + emails, + source="agent_account_creation", + ) + warning = format_newsletter_sync_warning(result) + if not warning: + return None + text = " ".join(warning.split()).strip() + return f"{text[:197]}..." if len(text) > 200 else text def _create_migadu_mailbox( self, diff --git a/packages/shared/src/five08/clients/brevo.py b/packages/shared/src/five08/clients/brevo.py index 060adf41..8f4e20ee 100644 --- a/packages/shared/src/five08/clients/brevo.py +++ b/packages/shared/src/five08/clients/brevo.py @@ -79,6 +79,39 @@ def add_contact_to_list( raise BrevoAPIError("Brevo response payload must be a JSON object.") return data + def get_contact(self, email: str) -> dict[str, Any] | None: + """Return one Brevo contact by email, or None when it does not exist.""" + normalized_email = email.strip().lower() + headers = { + "Accept": "application/json", + "api-key": self.api_key, + } + try: + response = requests.get( + f"{self.base_url}/contacts/{normalized_email}", + headers=headers, + timeout=self.timeout_seconds, + ) + except requests.RequestException as exc: + raise BrevoAPIError(f"Brevo API request failed: {exc}") from exc + + if response.status_code == 404: + return None + if response.status_code != 200: + raise BrevoAPIError( + "Brevo contact lookup failed: " + f"status={response.status_code}, body={response.text}" + ) + + try: + data = response.json() + except ValueError as exc: + raise BrevoAPIError("Brevo response payload must be valid JSON.") from exc + + if not isinstance(data, dict): + raise BrevoAPIError("Brevo response payload must be a JSON object.") + return data + def find_list_id_by_name(self, name: str) -> int | None: """Find a Brevo contact list ID by exact case-insensitive name.""" normalized_name = name.strip().casefold() diff --git a/packages/shared/src/five08/clients/keila.py b/packages/shared/src/five08/clients/keila.py new file mode 100644 index 00000000..7a0804b0 --- /dev/null +++ b/packages/shared/src/five08/clients/keila.py @@ -0,0 +1,127 @@ +"""Keila API client helpers shared across services.""" + +from __future__ import annotations + +from typing import Any + +import requests + +KEILA_API_BASE_URL = "https://app.keila.io" + + +class KeilaAPIError(RuntimeError): + """Raised when the Keila API request fails or returns invalid data.""" + + +class KeilaClient: + """Small Keila API wrapper for contact synchronization.""" + + def __init__( + self, + *, + api_key: str, + base_url: str = KEILA_API_BASE_URL, + timeout_seconds: float = 20.0, + ) -> None: + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.timeout_seconds = timeout_seconds + + def get_contact_by_email(self, email: str) -> dict[str, Any] | None: + """Return one Keila contact by email, or None when it does not exist.""" + normalized_email = email.strip().lower() + response = self._request( + "GET", + f"/api/v1/contacts/{normalized_email}", + params={"id_type": "email"}, + allow_not_found=True, + ) + return response + + def upsert_active_contact( + self, + *, + email: str, + first_name: str | None = None, + last_name: str | None = None, + data: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Create or update a Keila contact without changing suppressed statuses.""" + normalized_email = email.strip().lower() + if not normalized_email or normalized_email.count("@") != 1: + raise ValueError("Keila contact email must be a full email address.") + + payload: dict[str, Any] = { + "email": normalized_email, + "status": "active", + "data": data or {}, + } + if first_name: + payload["first_name"] = first_name + if last_name: + payload["last_name"] = last_name + + existing = self.get_contact_by_email(normalized_email) + if existing is None: + return ( + self._request("POST", "/api/v1/contacts", json={"data": payload}) or {} + ) + + contact_id = str(existing.get("id") or normalized_email) + payload.pop("status", None) + return ( + self._request( + "PATCH", + f"/api/v1/contacts/{contact_id}", + json={"data": payload}, + ) + or {} + ) + + def _request( + self, + method: str, + path: str, + *, + params: dict[str, str] | None = None, + json: dict[str, Any] | None = None, + allow_not_found: bool = False, + ) -> dict[str, Any] | None: + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + if json is not None: + headers["Content-Type"] = "application/json" + + try: + response = requests.request( + method, + f"{self.base_url}{path}", + headers=headers, + params=params, + json=json, + timeout=self.timeout_seconds, + ) + except requests.RequestException as exc: + raise KeilaAPIError(f"Keila API request failed: {exc}") from exc + + if allow_not_found and response.status_code == 404: + return None + if not 200 <= response.status_code < 300: + raise KeilaAPIError( + "Keila API request failed: " + f"status={response.status_code}, body={response.text}" + ) + if not response.content: + return {} + try: + data = response.json() + except ValueError as exc: + raise KeilaAPIError("Keila response payload must be valid JSON.") from exc + if not isinstance(data, dict): + raise KeilaAPIError("Keila response payload must be a JSON object.") + nested = data.get("data") + if isinstance(nested, dict): + return nested + return data diff --git a/packages/shared/src/five08/clients/migadu.py b/packages/shared/src/five08/clients/migadu.py index ddf60b27..e8b014e3 100644 --- a/packages/shared/src/five08/clients/migadu.py +++ b/packages/shared/src/five08/clients/migadu.py @@ -31,6 +31,15 @@ class MigaduMailboxCreateRequest: name: str +@dataclass(frozen=True, slots=True) +class MigaduMailbox: + """Mailbox fields needed for member audience sync.""" + + address: str + name: str + password_recovery_email: str | None + + class MigaduClient: """Small Migadu API wrapper for mailbox creation.""" @@ -84,3 +93,49 @@ def create_mailbox(self, request: MigaduMailboxCreateRequest) -> dict[str, Any]: raise MigaduAPIError("Migadu response payload must be a JSON object.") return data + + def list_mailboxes(self) -> list[MigaduMailbox]: + """List mailboxes for the configured domain.""" + try: + response = requests.get( + f"{self.base_url}/domains/{self.domain}/mailboxes", + auth=(self.username, self.api_key), + timeout=self.timeout_seconds, + ) + except requests.RequestException as exc: + raise MigaduAPIError(f"Migadu API request failed: {exc}") from exc + + if response.status_code != 200: + raise MigaduAPIError( + "Migadu mailbox listing failed: " + f"status={response.status_code}, body={response.text}" + ) + + try: + data = response.json() + except ValueError as exc: + raise MigaduAPIError("Migadu response payload must be valid JSON.") from exc + + if not isinstance(data, dict): + raise MigaduAPIError("Migadu response payload must be a JSON object.") + + raw_mailboxes = data.get("mailboxes", []) + if not isinstance(raw_mailboxes, list): + raise MigaduAPIError("Migadu response payload must include mailboxes list.") + + mailboxes: list[MigaduMailbox] = [] + for item in raw_mailboxes: + if not isinstance(item, dict): + continue + address = str(item.get("address") or "").strip().lower() + if not address: + continue + recovery = str(item.get("password_recovery_email") or "").strip().lower() + mailboxes.append( + MigaduMailbox( + address=address, + name=str(item.get("name") or "").strip(), + password_recovery_email=recovery or None, + ) + ) + return mailboxes diff --git a/packages/shared/src/five08/newsletter_sync.py b/packages/shared/src/five08/newsletter_sync.py new file mode 100644 index 00000000..597f45ed --- /dev/null +++ b/packages/shared/src/five08/newsletter_sync.py @@ -0,0 +1,391 @@ +"""508 member newsletter audience synchronization.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Protocol + +from five08.clients.brevo import BrevoClient +from five08.clients.espo import EspoAPIError, EspoClient +from five08.clients.keila import KeilaClient +from five08.clients.migadu import MigaduClient, MigaduMailbox + +CRM_BLOCKED_TYPES = {"inactive member", "rejected", "blocked"} +CRM_BLOCKED_ONBOARDING_STATES = {"rejected", "waitlist"} +PROVIDER_SUPPRESSED_STATUSES = {"unsubscribed", "unreachable", "blocked"} + + +@dataclass(frozen=True, slots=True) +class NewsletterContact: + """One email address derived from one Migadu mailbox.""" + + email: str + mailbox_email: str + name: str + source: str + + +class NewsletterProvider(Protocol): + """Provider interface for additive newsletter subscription sync.""" + + name: str + + def ensure_contact(self, contact: NewsletterContact) -> str: + """Ensure contact exists, returning added/updated/already/skipped.""" + + +def _split_name(full_name: str) -> tuple[str | None, str | None]: + normalized = full_name.strip() + if not normalized: + return None, None + parts = normalized.rsplit(" ", 1) + if len(parts) == 1: + return parts[0], None + return parts[0], parts[1] + + +def _normalized_emails_for_mailbox(mailbox: MigaduMailbox) -> list[NewsletterContact]: + contacts = [ + NewsletterContact( + email=mailbox.address, + mailbox_email=mailbox.address, + name=mailbox.name, + source="migadu_mailbox", + ) + ] + if mailbox.password_recovery_email: + contacts.append( + NewsletterContact( + email=mailbox.password_recovery_email, + mailbox_email=mailbox.address, + name=mailbox.name, + source="migadu_password_recovery_email", + ) + ) + deduped: list[NewsletterContact] = [] + seen: set[str] = set() + for contact in contacts: + if contact.email in seen: + continue + seen.add(contact.email) + deduped.append(contact) + return deduped + + +def _normalized_csv_set(value: str) -> set[str]: + return {item.strip().lower() for item in value.split(",") if item.strip()} + + +def _is_crm_blocked(contact: dict[str, Any] | None) -> bool: + if contact is None: + return False + contact_type = str(contact.get("type") or "").strip().casefold() + onboarding = str(contact.get("cOnboardingState") or "").strip().casefold() + return ( + contact_type in CRM_BLOCKED_TYPES or onboarding in CRM_BLOCKED_ONBOARDING_STATES + ) + + +class BrevoNewsletterProvider: + """Brevo implementation for the 508 members newsletter list.""" + + name = "brevo" + + def __init__( + self, + client: BrevoClient, + *, + list_id: int | None, + list_name: str, + ) -> None: + self.client = client + self.list_id = list_id + self.list_name = list_name + + def _list_id(self) -> int | None: + if self.list_id is not None: + return self.list_id + return self.client.find_list_id_by_name(self.list_name) + + def ensure_contact(self, contact: NewsletterContact) -> str: + existing = self.client.get_contact(contact.email) + if existing is not None and ( + bool(existing.get("emailBlacklisted")) + or bool(existing.get("smsBlacklisted")) + or str(existing.get("status") or "").strip().casefold() + in PROVIDER_SUPPRESSED_STATUSES + ): + return "skipped_provider_suppressed" + + list_id = self._list_id() + if list_id is None: + return "skipped_list_missing" + self.client.add_contact_to_list(email=contact.email, list_id=list_id) + return "synced" + + +class KeilaNewsletterProvider: + """Keila implementation using project contacts and contact data tags.""" + + name = "keila" + + def __init__(self, client: KeilaClient) -> None: + self.client = client + + def ensure_contact(self, contact: NewsletterContact) -> str: + existing = self.client.get_contact_by_email(contact.email) + if existing is not None: + status = str(existing.get("status") or "").strip().casefold() + if status in PROVIDER_SUPPRESSED_STATUSES: + return "skipped_provider_suppressed" + + first_name, last_name = _split_name(contact.name) + self.client.upsert_active_contact( + email=contact.email, + first_name=first_name, + last_name=last_name, + data={ + "audiences": ["508_members"], + "source": contact.source, + "mailbox_email": contact.mailbox_email, + }, + ) + return "synced" + + +class NewsletterSyncProcessor: + """Synchronize Migadu member mailboxes into configured newsletter providers.""" + + def __init__(self, settings: Any) -> None: + self.settings = settings + self.excluded_mailboxes = _normalized_csv_set( + settings.newsletter_sync_excluded_mailboxes + ) + + def sync_508_members(self) -> dict[str, Any]: + providers = build_newsletter_providers(self.settings) + result: dict[str, Any] = { + "mailboxes_scanned": 0, + "system_mailboxes_skipped": 0, + "crm_blocked_skipped": 0, + "contacts_considered": 0, + "providers": { + provider.name: {"synced": 0, "skipped": 0, "failed": 0} + for provider in providers + }, + } + if not providers: + result["warning"] = "no_newsletter_providers_configured" + return result + + for mailbox in self._migadu_client().list_mailboxes(): + result["mailboxes_scanned"] += 1 + if mailbox.address in self.excluded_mailboxes: + result["system_mailboxes_skipped"] += 1 + continue + + crm_contact = self._find_crm_contact(mailbox) + if _is_crm_blocked(crm_contact): + result["crm_blocked_skipped"] += 1 + continue + + for contact in _normalized_emails_for_mailbox(mailbox): + result["contacts_considered"] += 1 + for provider in providers: + provider_result = result["providers"][provider.name] + try: + status = provider.ensure_contact(contact) + except Exception as exc: + provider_result["failed"] += 1 + failures = provider_result.setdefault("failures", []) + if isinstance(failures, list) and len(failures) < 20: + failures.append({"email": contact.email, "error": str(exc)}) + continue + if status == "synced": + provider_result["synced"] += 1 + else: + provider_result["skipped"] += 1 + statuses = provider_result.setdefault("statuses", {}) + if isinstance(statuses, dict): + statuses[status] = int(statuses.get(status, 0)) + 1 + return result + + def _migadu_client(self) -> MigaduClient: + return MigaduClient( + username=_required(self.settings.migadu_api_user, "MIGADU_API_USER"), + api_key=_required(self.settings.migadu_api_key, "MIGADU_API_KEY"), + domain=self.settings.migadu_mailbox_domain, + ) + + def _crm_client(self) -> EspoClient | None: + base_url = getattr(self.settings, "espo_base_url", None) + api_key = getattr(self.settings, "espo_api_key", None) + if not base_url or not api_key: + return None + return EspoClient(base_url, api_key) + + def _find_crm_contact(self, mailbox: MigaduMailbox) -> dict[str, Any] | None: + client = self._crm_client() + if client is None: + return None + filters: list[dict[str, Any]] = [ + {"type": "equals", "attribute": "c508Email", "value": mailbox.address}, + {"type": "equals", "attribute": "emailAddress", "value": mailbox.address}, + ] + if mailbox.password_recovery_email: + filters.append( + { + "type": "equals", + "attribute": "emailAddress", + "value": mailbox.password_recovery_email, + } + ) + try: + response = client.list_contacts( + { + "where": [{"type": "or", "value": filters}], + "maxSize": 1, + "select": "id,name,emailAddress,c508Email,type,cOnboardingState", + } + ) + except EspoAPIError: + return None + contacts = response.get("list", []) + if not isinstance(contacts, list) or not contacts: + return None + contact = contacts[0] + return contact if isinstance(contact, dict) else None + + +def _required(value: str | None, name: str) -> str: + normalized = (value or "").strip() + if not normalized: + raise ValueError(f"{name} is required.") + return normalized + + +def build_newsletter_providers(settings: Any) -> list[NewsletterProvider]: + """Build configured newsletter providers from shared-like settings.""" + providers: list[NewsletterProvider] = [] + brevo_api_key = str(getattr(settings, "brevo_api_key", "") or "").strip() + if brevo_api_key: + providers.append( + BrevoNewsletterProvider( + BrevoClient( + api_key=brevo_api_key, + base_url=getattr( + settings, "brevo_api_base_url", "https://api.brevo.com/v3" + ), + timeout_seconds=getattr( + settings, "brevo_api_timeout_seconds", 20.0 + ), + ), + list_id=getattr(settings, "brevo_508_members_newsletter_list_id", None), + list_name=getattr( + settings, "brevo_508_members_newsletter_list_name", "508 members" + ), + ) + ) + + keila_api_key = str(getattr(settings, "keila_api_key", "") or "").strip() + if keila_api_key: + providers.append( + KeilaNewsletterProvider( + KeilaClient( + api_key=keila_api_key, + base_url=getattr( + settings, "keila_api_base_url", "https://app.keila.io" + ), + timeout_seconds=getattr( + settings, "keila_api_timeout_seconds", 20.0 + ), + ) + ) + ) + return providers + + +def sync_newsletter_contacts( + settings: Any, + emails: Iterable[str], + *, + name: str = "", + mailbox_email: str | None = None, + source: str = "account_creation", +) -> dict[str, Any]: + """Best-effort additive sync for known member emails at creation time.""" + providers = build_newsletter_providers(settings) + result: dict[str, Any] = { + "contacts_considered": 0, + "providers": { + provider.name: {"synced": 0, "skipped": 0, "failed": 0} + for provider in providers + }, + } + if not providers: + result["warning"] = "no_newsletter_providers_configured" + return result + + seen: set[str] = set() + for email in emails: + normalized_email = email.strip().lower() + if not normalized_email or normalized_email in seen: + continue + seen.add(normalized_email) + result["contacts_considered"] += 1 + contact = NewsletterContact( + email=normalized_email, + mailbox_email=(mailbox_email or normalized_email).strip().lower(), + name=name, + source=source, + ) + for provider in providers: + provider_result = result["providers"][provider.name] + try: + status = provider.ensure_contact(contact) + except Exception as exc: + provider_result["failed"] += 1 + failures = provider_result.setdefault("failures", []) + if isinstance(failures, list) and len(failures) < 20: + failures.append({"email": normalized_email, "error": str(exc)}) + continue + if status == "synced": + provider_result["synced"] += 1 + else: + provider_result["skipped"] += 1 + statuses = provider_result.setdefault("statuses", {}) + if isinstance(statuses, dict): + statuses[status] = int(statuses.get(status, 0)) + 1 + return result + + +def format_newsletter_sync_warning(result: dict[str, Any]) -> str | None: + """Format direct-provisioning sync failures for user-visible warnings.""" + if result.get("warning") == "no_newsletter_providers_configured": + return "No newsletter providers are configured." + + messages: list[str] = [] + providers = result.get("providers", {}) + if not isinstance(providers, dict): + return None + + for provider_name, provider_result in providers.items(): + if not isinstance(provider_result, dict): + continue + failed = int(provider_result.get("failed") or 0) + failures = provider_result.get("failures") + if failed and isinstance(failures, list) and failures: + detail = "; ".join( + f"{item.get('email')}: {item.get('error')}" + for item in failures[:3] + if isinstance(item, dict) + ) + messages.append(f"{provider_name} failed for {failed} contact(s): {detail}") + elif failed: + messages.append(f"{provider_name} failed for {failed} contact(s)") + + statuses = provider_result.get("statuses") + if isinstance(statuses, dict) and statuses.get("skipped_list_missing"): + messages.append(f"{provider_name} list was not found") + + return "; ".join(messages) if messages else None diff --git a/packages/shared/src/five08/settings.py b/packages/shared/src/five08/settings.py index 1a3cb3a4..930fcc28 100644 --- a/packages/shared/src/five08/settings.py +++ b/packages/shared/src/five08/settings.py @@ -84,6 +84,17 @@ class SharedSettings(BaseSettings): brevo_api_timeout_seconds: float = 20.0 brevo_508_members_newsletter_list_id: int | None = Field(default=None, ge=1) brevo_508_members_newsletter_list_name: str = "508 members" + keila_api_key: str | None = None + keila_api_base_url: str = "https://app.keila.io" + keila_api_timeout_seconds: float = 20.0 + newsletter_sync_enabled: bool = True + newsletter_sync_interval_seconds: int = 604800 + newsletter_sync_excluded_mailboxes: str = ( + "authentik@508.dev,baserow@508.dev,cal@508.dev,calendar@508.dev," + "coolify@508.dev,crm@508.dev,docuseal@508.dev,events@508.dev," + "keycloak@508.dev,kimai@508.dev,matrix@508.dev,openproject@508.dev," + "supabase@508.dev,vaultwarden@508.dev,wiki@508.dev" + ) model_config = SettingsConfigDict( env_file=".env", diff --git a/tests/unit/test_agent_gateway.py b/tests/unit/test_agent_gateway.py index c1fc580f..b95200d4 100644 --- a/tests/unit/test_agent_gateway.py +++ b/tests/unit/test_agent_gateway.py @@ -1880,6 +1880,7 @@ def _account_runtime_config( *, outline_api_key: str | None = "outline-key", brevo_api_key: str | None = None, + keila_api_key: str | None = None, ) -> ToolRuntimeConfig: return ToolRuntimeConfig( espo_base_url="https://crm.example", @@ -1892,6 +1893,7 @@ def _account_runtime_config( outline_api_key=outline_api_key, brevo_api_key=brevo_api_key, brevo_508_members_newsletter_list_id=4, + keila_api_key=keila_api_key, ) @@ -2102,20 +2104,60 @@ def add_contact_to_list(self, *, email: str, list_id: int) -> dict[str, Any]: self.subscriptions.append({"email": email, "list_id": list_id}) return {"id": len(self.subscriptions)} + def get_contact(self, email: str) -> dict[str, Any] | None: + return None + def find_list_id_by_name(self, name: str) -> int | None: return 4 if name == "508 members" else None + class FakeKeilaClient: + upserts: list[dict[str, Any]] = [] + + def __init__( + self, + *, + api_key: str, + base_url: str = "https://app.keila.io", + timeout_seconds: float = 20.0, + ) -> None: + self.api_key = api_key + self.base_url = base_url + self.timeout_seconds = timeout_seconds + + def get_contact_by_email(self, email: str) -> dict[str, Any] | None: + return None + + def upsert_active_contact( + self, + *, + email: str, + first_name: str | None = None, + last_name: str | None = None, + data: dict[str, Any] | None = None, + ) -> dict[str, Any]: + self.upserts.append( + { + "email": email, + "first_name": first_name, + "last_name": last_name, + "data": data, + } + ) + return {"id": str(len(self.upserts))} + monkeypatch.setattr("five08.agent.tools.EspoClient", FakeEspoClient) monkeypatch.setattr("five08.agent.tools.AuthentikClient", FakeAuthentikClient) monkeypatch.setattr("five08.agent.tools.MigaduClient", FakeMigaduClient) monkeypatch.setattr("five08.agent.tools.OutlineClient", FakeOutlineClient) - monkeypatch.setattr("five08.agent.tools.BrevoClient", FakeBrevoClient) + monkeypatch.setattr("five08.newsletter_sync.BrevoClient", FakeBrevoClient) + monkeypatch.setattr("five08.newsletter_sync.KeilaClient", FakeKeilaClient) return SimpleNamespace( espo=FakeEspoClient, authentik=FakeAuthentikClient, migadu=FakeMigaduClient, outline=FakeOutlineClient, brevo=FakeBrevoClient, + keila=FakeKeilaClient, events=events, ) @@ -2518,6 +2560,39 @@ def test_user_accounts_tool_subscribes_mailbox_and_backup_email_to_brevo( ] +def test_user_accounts_tool_subscribes_mailbox_and_backup_email_to_keila( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fakes = _install_account_tool_fakes(monkeypatch) + registry = ToolRegistry(runtime_config=_account_runtime_config(keila_api_key="key")) + + result = registry.execute( + "account_write.create_user_accounts", + {"contact_id": "contact-1", "mailbox_username": "jane@508.dev"}, + organization_id="org-1", + actor_id="123", + actor_scopes={ + "mailbox:create", + "user:manage", + "integration:manage", + "crm:contact:read", + "crm:contact:update", + }, + ) + + assert result["mailbox"]["newsletter_subscribed"] is True + assert result["mailbox"]["newsletter_error"] is None + assert [item["email"] for item in fakes.keila.upserts] == [ + "jane@508.dev", + "jane@example.com", + ] + assert fakes.keila.upserts[0]["data"] == { + "audiences": ["508_members"], + "source": "agent_account_creation", + "mailbox_email": "jane@508.dev", + } + + def test_user_accounts_tool_preflights_before_mailbox_creation( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py index 4873e435..0f8d4552 100644 --- a/tests/unit/test_backend_api.py +++ b/tests/unit/test_backend_api.py @@ -6380,6 +6380,85 @@ def test_dashboard_sync_people_workflows_engineer_is_dry_run( mock_insert.assert_not_called() +def test_dashboard_sync_newsletters_audits_discord_session(client: TestClient) -> None: + session = api.AuthSession( + subject="123456789", + email="admin@508.dev", + display_name="Discord Admin", + groups=["discord_admin"], + is_admin=True, + id_token="id-token-1", + expires_at=4_102_444_800, + actor_provider=api.ActorProvider.DISCORD.value, + crm_contact_id="contact-123", + ) + + with ( + patch( + "five08.backend.api._current_session", + new_callable=AsyncMock, + return_value=("session-1", session), + ), + patch( + "five08.backend.api._enqueue_newsletter_sync_job", + new_callable=AsyncMock, + return_value=Mock(id="job-newsletter-1", created=True), + ), + patch("five08.backend.api.insert_audit_event") as mock_insert, + ): + response = client.post("/dashboard/api/sync/newsletters") + + assert response.status_code == 202 + assert response.json()["job_id"] == "job-newsletter-1" + audit_payload = mock_insert.call_args.args[1] + assert audit_payload.source == api.AuditSource.ADMIN_DASHBOARD + assert audit_payload.action == "newsletter.508_members_sync" + assert audit_payload.result == api.AuditResult.SUCCESS + assert audit_payload.actor_provider == api.ActorProvider.DISCORD + assert audit_payload.actor_subject == "123456789" + assert audit_payload.resource_type == "newsletter_sync" + assert audit_payload.resource_id == "job-newsletter-1" + assert audit_payload.metadata is not None + assert audit_payload.metadata["source"] == "dashboard" + + +def test_dashboard_sync_newsletters_workflows_engineer_is_dry_run( + client: TestClient, +) -> None: + session = api.AuthSession( + subject="workflows-1", + email="workflows@508.dev", + display_name="Workflows Engineer", + groups=["Workflows Engineer"], + is_admin=False, + id_token="", + expires_at=4_102_444_800, + actor_provider=api.ActorProvider.DISCORD.value, + ) + + with ( + patch( + "five08.backend.api._current_session", + new_callable=AsyncMock, + return_value=("session-1", session), + ), + patch( + "five08.backend.api._enqueue_newsletter_sync_job", + new_callable=AsyncMock, + ) as mock_enqueue, + patch("five08.backend.api.insert_audit_event") as mock_insert, + ): + response = client.post("/dashboard/api/sync/newsletters") + + assert response.status_code == 200 + assert response.json()["status"] == "dry_run" + assert response.json()["would_enqueue"]["job_type"] == ( + "sync_508_members_newsletters_job" + ) + mock_enqueue.assert_not_called() + mock_insert.assert_not_called() + + def test_dashboard_sync_projects_workflows_engineer_is_dry_run( client: TestClient, ) -> None: diff --git a/tests/unit/test_brevo_client.py b/tests/unit/test_brevo_client.py index e4373318..b15d25ae 100644 --- a/tests/unit/test_brevo_client.py +++ b/tests/unit/test_brevo_client.py @@ -63,6 +63,32 @@ def test_add_contact_to_list_raises_on_request_error() -> None: ) +def test_get_contact_fetches_contact_by_email() -> None: + response = Mock() + response.status_code = 200 + response.json.return_value = {"email": "jane@example.com"} + + with patch("five08.clients.brevo.requests.get", return_value=response) as get: + result = BrevoClient(api_key="brevo-key").get_contact("Jane@Example.com") + + get.assert_called_once_with( + "https://api.brevo.com/v3/contacts/jane@example.com", + headers={"Accept": "application/json", "api-key": "brevo-key"}, + timeout=20.0, + ) + assert result == {"email": "jane@example.com"} + + +def test_get_contact_returns_none_for_missing_contact() -> None: + response = Mock() + response.status_code = 404 + + with patch("five08.clients.brevo.requests.get", return_value=response): + result = BrevoClient(api_key="brevo-key").get_contact("jane@example.com") + + assert result is None + + def test_find_list_id_by_name_gets_matching_list() -> None: response = Mock() response.status_code = 200 diff --git a/tests/unit/test_crm_create_sso_user.py b/tests/unit/test_crm_create_sso_user.py index 6a10bc06..5a37ef80 100644 --- a/tests/unit/test_crm_create_sso_user.py +++ b/tests/unit/test_crm_create_sso_user.py @@ -568,7 +568,7 @@ async def test_create_user_accounts_creates_mailbox_sso_and_outline_invite( assert "User accounts are ready" in message assert "Email: `jane@508.dev`" in message assert "Outline invite: sent." in message - assert "Newsletter: added mailbox and backup email to Brevo." in message + assert "Newsletter: added mailbox and backup email." in message assert mock_interaction.followup.send.call_args.kwargs["ephemeral"] is True assert mock_audit.call_args.kwargs["metadata"]["outline_invited"] is True diff --git a/tests/unit/test_keila_client.py b/tests/unit/test_keila_client.py new file mode 100644 index 00000000..7a2238fd --- /dev/null +++ b/tests/unit/test_keila_client.py @@ -0,0 +1,121 @@ +"""Unit tests for the shared Keila API client.""" + +from unittest.mock import Mock, patch + +import pytest +import requests + +from five08.clients.keila import KeilaAPIError, KeilaClient + + +def test_get_contact_by_email_fetches_contact() -> None: + response = Mock() + response.status_code = 200 + response.content = b'{"data":{"id":"contact-1","email":"jane@example.com"}}' + response.json.return_value = { + "data": {"id": "contact-1", "email": "jane@example.com"} + } + + with patch( + "five08.clients.keila.requests.request", return_value=response + ) as request: + result = KeilaClient(api_key="keila-key").get_contact_by_email( + "Jane@Example.com" + ) + + request.assert_called_once_with( + "GET", + "https://app.keila.io/api/v1/contacts/jane@example.com", + headers={ + "Accept": "application/json", + "Authorization": "Bearer keila-key", + }, + params={"id_type": "email"}, + json=None, + timeout=20.0, + ) + assert result == {"id": "contact-1", "email": "jane@example.com"} + + +def test_get_contact_by_email_returns_none_for_missing_contact() -> None: + response = Mock() + response.status_code = 404 + + with patch("five08.clients.keila.requests.request", return_value=response): + result = KeilaClient(api_key="keila-key").get_contact_by_email( + "jane@example.com" + ) + + assert result is None + + +def test_upsert_active_contact_creates_missing_contact() -> None: + missing = Mock() + missing.status_code = 404 + created = Mock() + created.status_code = 201 + created.content = b'{"data":{"id":"contact-1"}}' + created.json.return_value = {"data": {"id": "contact-1"}} + + with patch( + "five08.clients.keila.requests.request", + side_effect=[missing, created], + ) as request: + result = KeilaClient(api_key="keila-key").upsert_active_contact( + email="jane@example.com", + first_name="Jane", + last_name="Doe", + data={"audiences": ["508_members"]}, + ) + + assert request.call_args_list[1].kwargs["json"] == { + "data": { + "email": "jane@example.com", + "status": "active", + "data": {"audiences": ["508_members"]}, + "first_name": "Jane", + "last_name": "Doe", + } + } + assert result == {"id": "contact-1"} + + +def test_upsert_active_contact_updates_existing_contact_without_status() -> None: + existing = Mock() + existing.status_code = 200 + existing.content = b'{"data":{"id":"contact-1","status":"active"}}' + existing.json.return_value = {"data": {"id": "contact-1", "status": "active"}} + updated = Mock() + updated.status_code = 200 + updated.content = b'{"data":{"id":"contact-1"}}' + updated.json.return_value = {"data": {"id": "contact-1"}} + + with patch( + "five08.clients.keila.requests.request", + side_effect=[existing, updated], + ) as request: + result = KeilaClient(api_key="keila-key").upsert_active_contact( + email="jane@example.com", + data={"audiences": ["508_members"]}, + ) + + assert request.call_args_list[1].args == ( + "PATCH", + "https://app.keila.io/api/v1/contacts/contact-1", + ) + assert request.call_args_list[1].kwargs["json"] == { + "data": { + "email": "jane@example.com", + "data": {"audiences": ["508_members"]}, + } + } + assert result == {"id": "contact-1"} + + +def test_keila_client_raises_on_request_error() -> None: + with patch( + "five08.clients.keila.requests.request", + side_effect=requests.Timeout("timed out"), + ): + with pytest.raises(KeilaAPIError, match="request failed"): + KeilaClient(api_key="keila-key").get_contact_by_email("jane@example.com") diff --git a/tests/unit/test_newsletter_sync.py b/tests/unit/test_newsletter_sync.py new file mode 100644 index 00000000..1c2b26c4 --- /dev/null +++ b/tests/unit/test_newsletter_sync.py @@ -0,0 +1,215 @@ +"""Unit tests for Migadu-backed newsletter audience sync.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from five08.clients.migadu import MigaduMailbox +from five08.newsletter_sync import NewsletterSyncProcessor + + +class FakeMigaduClient: + mailboxes: list[MigaduMailbox] = [] + + def __init__( + self, + *, + username: str, + api_key: str, + domain: str, + ) -> None: + self.username = username + self.api_key = api_key + self.domain = domain + + def list_mailboxes(self) -> list[MigaduMailbox]: + return list(self.mailboxes) + + +class FakeBrevoClient: + contacts: dict[str, dict[str, Any]] = {} + subscriptions: list[dict[str, Any]] = [] + + def __init__( + self, + *, + api_key: str, + base_url: str = "https://api.brevo.com/v3", + timeout_seconds: float = 20.0, + ) -> None: + self.api_key = api_key + self.base_url = base_url + self.timeout_seconds = timeout_seconds + + def get_contact(self, email: str) -> dict[str, Any] | None: + return self.contacts.get(email) + + def add_contact_to_list(self, *, email: str, list_id: int) -> dict[str, Any]: + self.subscriptions.append({"email": email, "list_id": list_id}) + return {"id": len(self.subscriptions)} + + def find_list_id_by_name(self, name: str) -> int | None: + return 4 if name == "508 members" else None + + +class FakeKeilaClient: + contacts: dict[str, dict[str, Any]] = {} + upserts: list[dict[str, Any]] = [] + + def __init__( + self, + *, + api_key: str, + base_url: str = "https://app.keila.io", + timeout_seconds: float = 20.0, + ) -> None: + self.api_key = api_key + self.base_url = base_url + self.timeout_seconds = timeout_seconds + + def get_contact_by_email(self, email: str) -> dict[str, Any] | None: + return self.contacts.get(email) + + def upsert_active_contact( + self, + *, + email: str, + first_name: str | None = None, + last_name: str | None = None, + data: dict[str, Any] | None = None, + ) -> dict[str, Any]: + self.upserts.append( + { + "email": email, + "first_name": first_name, + "last_name": last_name, + "data": data, + } + ) + return {"id": str(len(self.upserts))} + + +class FakeEspoClient: + contacts: list[dict[str, Any]] = [] + + def __init__( + self, + base_url: str, + api_key: str, + timeout_seconds: float = 20.0, + ) -> None: + self.base_url = base_url + self.api_key = api_key + self.timeout_seconds = timeout_seconds + + def list_contacts(self, params: dict[str, Any]) -> dict[str, Any]: + return {"list": [dict(item) for item in self.contacts]} + + +@pytest.fixture(autouse=True) +def reset_fakes(monkeypatch: pytest.MonkeyPatch) -> None: + FakeMigaduClient.mailboxes = [] + FakeBrevoClient.contacts = {} + FakeBrevoClient.subscriptions = [] + FakeKeilaClient.contacts = {} + FakeKeilaClient.upserts = [] + FakeEspoClient.contacts = [] + monkeypatch.setattr("five08.newsletter_sync.MigaduClient", FakeMigaduClient) + monkeypatch.setattr("five08.newsletter_sync.BrevoClient", FakeBrevoClient) + monkeypatch.setattr("five08.newsletter_sync.KeilaClient", FakeKeilaClient) + monkeypatch.setattr("five08.newsletter_sync.EspoClient", FakeEspoClient) + + +def _settings(**overrides: Any) -> SimpleNamespace: + values: dict[str, Any] = { + "migadu_api_user": "migadu-user", + "migadu_api_key": "migadu-key", + "migadu_mailbox_domain": "508.dev", + "brevo_api_key": "brevo-key", + "brevo_508_members_newsletter_list_id": 4, + "keila_api_key": "keila-key", + "newsletter_sync_excluded_mailboxes": "system@508.dev", + } + values.update(overrides) + return SimpleNamespace(**values) + + +def test_sync_508_members_adds_mailbox_and_backup_email_to_configured_providers() -> ( + None +): + FakeMigaduClient.mailboxes = [ + MigaduMailbox( + address="jane@508.dev", + name="Jane Doe", + password_recovery_email="jane@example.com", + ), + MigaduMailbox( + address="system@508.dev", + name="System", + password_recovery_email="ops@example.com", + ), + ] + + result = NewsletterSyncProcessor(_settings()).sync_508_members() + + assert result["mailboxes_scanned"] == 2 + assert result["system_mailboxes_skipped"] == 1 + assert result["contacts_considered"] == 2 + assert FakeBrevoClient.subscriptions == [ + {"email": "jane@508.dev", "list_id": 4}, + {"email": "jane@example.com", "list_id": 4}, + ] + assert [item["email"] for item in FakeKeilaClient.upserts] == [ + "jane@508.dev", + "jane@example.com", + ] + assert result["providers"]["brevo"]["synced"] == 2 + assert result["providers"]["keila"]["synced"] == 2 + + +def test_sync_508_members_skips_provider_suppressed_contacts() -> None: + FakeMigaduClient.mailboxes = [ + MigaduMailbox( + address="jane@508.dev", + name="Jane Doe", + password_recovery_email="jane@example.com", + ) + ] + FakeBrevoClient.contacts = {"jane@example.com": {"emailBlacklisted": True}} + FakeKeilaClient.contacts = {"jane@example.com": {"status": "unsubscribed"}} + + result = NewsletterSyncProcessor(_settings()).sync_508_members() + + assert FakeBrevoClient.subscriptions == [{"email": "jane@508.dev", "list_id": 4}] + assert [item["email"] for item in FakeKeilaClient.upserts] == ["jane@508.dev"] + assert result["providers"]["brevo"]["statuses"] == { + "synced": 1, + "skipped_provider_suppressed": 1, + } + assert result["providers"]["keila"]["statuses"] == { + "synced": 1, + "skipped_provider_suppressed": 1, + } + + +def test_sync_508_members_skips_crm_blocked_mailboxes() -> None: + FakeMigaduClient.mailboxes = [ + MigaduMailbox( + address="jane@508.dev", + name="Jane Doe", + password_recovery_email="jane@example.com", + ) + ] + FakeEspoClient.contacts = [{"id": "contact-1", "type": "Inactive Member"}] + + result = NewsletterSyncProcessor( + _settings(espo_base_url="https://crm.example", espo_api_key="espo-key") + ).sync_508_members() + + assert result["crm_blocked_skipped"] == 1 + assert result["contacts_considered"] == 0 + assert FakeBrevoClient.subscriptions == [] + assert FakeKeilaClient.upserts == [] diff --git a/tests/unit/test_shared_settings.py b/tests/unit/test_shared_settings.py index d2e73bdd..ec58157b 100644 --- a/tests/unit/test_shared_settings.py +++ b/tests/unit/test_shared_settings.py @@ -102,6 +102,8 @@ def test_shared_settings_expose_agent_external_tool_credentials() -> None: brevo_api_key="brevo-key", brevo_508_members_newsletter_list_id=4, brevo_508_members_newsletter_list_name="508 members", + keila_api_key="keila-key", + keila_api_base_url="https://keila.example", ) runtime_config = ToolRuntimeConfig.from_settings(settings) @@ -113,6 +115,8 @@ def test_shared_settings_expose_agent_external_tool_credentials() -> None: assert runtime_config.brevo_api_key == "brevo-key" assert runtime_config.brevo_508_members_newsletter_list_id == 4 assert runtime_config.brevo_508_members_newsletter_list_name == "508 members" + assert runtime_config.keila_api_key == "keila-key" + assert runtime_config.keila_api_base_url == "https://keila.example" def test_shared_settings_docuseal_template_id_accepts_numeric_string() -> None: From 224a385abaf83de0ab9acc9def49accb31aee1b3 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 10 Jun 2026 17:02:46 +0800 Subject: [PATCH 04/14] Address newsletter sync review feedback --- .env.example | 3 ++ ENVIRONMENT.md | 2 ++ .../src/five08/discord_bot/cogs/crm.py | 19 +++++++++--- .../src/five08/discord_bot/cogs/migadu.py | 5 ++- packages/shared/src/five08/newsletter_sync.py | 20 ++++++++++-- packages/shared/src/five08/settings.py | 22 +++++++++++++ tests/unit/test_newsletter_sync.py | 31 +++++++++++++++++++ tests/unit/test_shared_settings.py | 14 +++++++++ 8 files changed, 108 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 5dc82345..fba87499 100644 --- a/.env.example +++ b/.env.example @@ -236,10 +236,13 @@ MIGADU_API_USER=your_migadu_api_user MIGADU_API_KEY=your_migadu_api_key MIGADU_MAILBOX_DOMAIN=508.dev BREVO_API_KEY= +BREVO_API_BASE_URL=https://api.brevo.com/v3 +BREVO_API_TIMEOUT_SECONDS=20.0 BREVO_508_MEMBERS_NEWSLETTER_LIST_ID= BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME=508 members KEILA_API_KEY= KEILA_API_BASE_URL=https://app.keila.io +KEILA_API_TIMEOUT_SECONDS=20.0 NEWSLETTER_SYNC_ENABLED=true NEWSLETTER_SYNC_INTERVAL_SECONDS=604800 NEWSLETTER_SYNC_EXCLUDED_MAILBOXES=authentik@508.dev,baserow@508.dev,cal@508.dev,calendar@508.dev,coolify@508.dev,crm@508.dev,docuseal@508.dev,events@508.dev,keycloak@508.dev,kimai@508.dev,matrix@508.dev,openproject@508.dev,supabase@508.dev,vaultwarden@508.dev,wiki@508.dev diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 23505d6d..583638ab 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -162,6 +162,8 @@ current precedence rules. - `Required for /create-mailbox and /create-user-accounts`: `MIGADU_API_USER`, `MIGADU_API_KEY` - `Optional`: `MIGADU_MAILBOX_DOMAIN` (default: `508.dev`) - `Optional for Brevo newsletter sync`: `BREVO_API_KEY` +- `Optional`: `BREVO_API_BASE_URL` (default: `https://api.brevo.com/v3`) +- `Optional`: `BREVO_API_TIMEOUT_SECONDS` (default: `20.0`) - `Optional for Brevo newsletter sync`: `BREVO_508_MEMBERS_NEWSLETTER_LIST_ID` (explicit production Brevo list ID override; production should set `4` for the 508 members list) - `Optional`: `BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME` (default: `508 members`; used to look up the list ID when the explicit ID is unset) - `Optional for Keila contact sync`: `KEILA_API_KEY` diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index fb951691..d9162738 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -8600,9 +8600,17 @@ async def _create_user_accounts_for_contact( "error": message, }, ) - newsletter_line = ( - f"\nNewsletter: subscription warning: `{exc.newsletter_error}`" + newsletter_warning = ( + self._sanitize_error_message_for_discord( + exc.newsletter_error, + max_length=500, + ) if exc.newsletter_error + else None + ) + newsletter_line = ( + f"\nNewsletter: subscription warning: `{newsletter_warning}`" + if newsletter_warning else "\nNewsletter: added mailbox and backup email." ) if exc.partial_success == "mailbox_created_crm_update_failed": @@ -8765,9 +8773,12 @@ async def _create_user_accounts_for_contact( if result.mailbox.newsletter_error is None: message_lines.append("Newsletter: added mailbox and backup email.") else: + newsletter_warning = self._sanitize_error_message_for_discord( + result.mailbox.newsletter_error, + max_length=500, + ) message_lines.append( - "Newsletter: subscription warning: " - f"`{result.mailbox.newsletter_error}`" + f"Newsletter: subscription warning: `{newsletter_warning}`" ) if result.sso.created: if result.sso.recovery_email_error is None: diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py b/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py index 1dbaf8f3..b6818d36 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py @@ -675,7 +675,10 @@ def _build_mailbox_embed( if outcome.newsletter_error: embed.add_field( name="Newsletter", - value=f"Newsletter subscription warning: {outcome.newsletter_error}", + value=_truncate_discord_text( + f"Newsletter subscription warning: {outcome.newsletter_error}", + limit=1024, + ), inline=False, ) else: diff --git a/packages/shared/src/five08/newsletter_sync.py b/packages/shared/src/five08/newsletter_sync.py index 597f45ed..0ee393f2 100644 --- a/packages/shared/src/five08/newsletter_sync.py +++ b/packages/shared/src/five08/newsletter_sync.py @@ -15,6 +15,10 @@ PROVIDER_SUPPRESSED_STATUSES = {"unsubscribed", "unreachable", "blocked"} +class CRMContactLookupError(RuntimeError): + """Raised when CRM block-state lookup fails during member sync.""" + + @dataclass(frozen=True, slots=True) class NewsletterContact: """One email address derived from one Migadu mailbox.""" @@ -168,6 +172,7 @@ def sync_508_members(self) -> dict[str, Any]: "mailboxes_scanned": 0, "system_mailboxes_skipped": 0, "crm_blocked_skipped": 0, + "crm_lookup_failed_skipped": 0, "contacts_considered": 0, "providers": { provider.name: {"synced": 0, "skipped": 0, "failed": 0} @@ -184,7 +189,14 @@ def sync_508_members(self) -> dict[str, Any]: result["system_mailboxes_skipped"] += 1 continue - crm_contact = self._find_crm_contact(mailbox) + try: + crm_contact = self._find_crm_contact(mailbox) + except CRMContactLookupError as exc: + result["crm_lookup_failed_skipped"] += 1 + failures = result.setdefault("crm_lookup_failures", []) + if isinstance(failures, list) and len(failures) < 20: + failures.append({"mailbox": mailbox.address, "error": str(exc)}) + continue if _is_crm_blocked(crm_contact): result["crm_blocked_skipped"] += 1 continue @@ -248,8 +260,10 @@ def _find_crm_contact(self, mailbox: MigaduMailbox) -> dict[str, Any] | None: "select": "id,name,emailAddress,c508Email,type,cOnboardingState", } ) - except EspoAPIError: - return None + except EspoAPIError as exc: + raise CRMContactLookupError( + f"CRM contact lookup failed for {mailbox.address}: {exc}" + ) from exc contacts = response.get("list", []) if not isinstance(contacts, list) or not contacts: return None diff --git a/packages/shared/src/five08/settings.py b/packages/shared/src/five08/settings.py index 930fcc28..290ec8a1 100644 --- a/packages/shared/src/five08/settings.py +++ b/packages/shared/src/five08/settings.py @@ -124,6 +124,28 @@ def _normalize_docuseal_member_agreement_template_id( ) from exc raise TypeError("DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID must be an integer") + @field_validator("brevo_508_members_newsletter_list_id", mode="before") + @classmethod + def _normalize_brevo_508_members_newsletter_list_id( + cls, + value: object, + ) -> int | None: + if value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, str): + normalized = value.strip() + if not normalized: + return None + try: + return int(normalized) + except ValueError as exc: + raise ValueError( + "BREVO_508_MEMBERS_NEWSLETTER_LIST_ID must be an integer" + ) from exc + raise TypeError("BREVO_508_MEMBERS_NEWSLETTER_LIST_ID must be an integer") + @classmethod def _skip_dotenv(cls) -> bool: if os.getenv("ENVIRONMENT", "").strip().lower() == "test": diff --git a/tests/unit/test_newsletter_sync.py b/tests/unit/test_newsletter_sync.py index 1c2b26c4..e861bc64 100644 --- a/tests/unit/test_newsletter_sync.py +++ b/tests/unit/test_newsletter_sync.py @@ -8,6 +8,7 @@ import pytest from five08.clients.migadu import MigaduMailbox +from five08.clients.espo import EspoAPIError from five08.newsletter_sync import NewsletterSyncProcessor @@ -94,6 +95,7 @@ def upsert_active_contact( class FakeEspoClient: contacts: list[dict[str, Any]] = [] + raise_error = False def __init__( self, @@ -106,6 +108,8 @@ def __init__( self.timeout_seconds = timeout_seconds def list_contacts(self, params: dict[str, Any]) -> dict[str, Any]: + if self.raise_error: + raise EspoAPIError("CRM unavailable") return {"list": [dict(item) for item in self.contacts]} @@ -117,6 +121,7 @@ def reset_fakes(monkeypatch: pytest.MonkeyPatch) -> None: FakeKeilaClient.contacts = {} FakeKeilaClient.upserts = [] FakeEspoClient.contacts = [] + FakeEspoClient.raise_error = False monkeypatch.setattr("five08.newsletter_sync.MigaduClient", FakeMigaduClient) monkeypatch.setattr("five08.newsletter_sync.BrevoClient", FakeBrevoClient) monkeypatch.setattr("five08.newsletter_sync.KeilaClient", FakeKeilaClient) @@ -213,3 +218,29 @@ def test_sync_508_members_skips_crm_blocked_mailboxes() -> None: assert result["contacts_considered"] == 0 assert FakeBrevoClient.subscriptions == [] assert FakeKeilaClient.upserts == [] + + +def test_sync_508_members_skips_mailbox_when_crm_lookup_fails() -> None: + FakeMigaduClient.mailboxes = [ + MigaduMailbox( + address="jane@508.dev", + name="Jane Doe", + password_recovery_email="jane@example.com", + ) + ] + FakeEspoClient.raise_error = True + + result = NewsletterSyncProcessor( + _settings(espo_base_url="https://crm.example", espo_api_key="espo-key") + ).sync_508_members() + + assert result["crm_lookup_failed_skipped"] == 1 + assert result["contacts_considered"] == 0 + assert result["crm_lookup_failures"] == [ + { + "mailbox": "jane@508.dev", + "error": "CRM contact lookup failed for jane@508.dev: CRM unavailable", + } + ] + assert FakeBrevoClient.subscriptions == [] + assert FakeKeilaClient.upserts == [] diff --git a/tests/unit/test_shared_settings.py b/tests/unit/test_shared_settings.py index ec58157b..6802009e 100644 --- a/tests/unit/test_shared_settings.py +++ b/tests/unit/test_shared_settings.py @@ -135,6 +135,20 @@ def test_shared_settings_docuseal_template_id_rejects_non_numeric_string() -> No SharedSettings(docuseal_member_agreement_template_id="abc") +def test_shared_settings_brevo_members_list_id_accepts_blank_string_as_none() -> None: + """Blank Brevo list IDs from env should leave list-name lookup enabled.""" + settings = SharedSettings(brevo_508_members_newsletter_list_id=" ") + + assert settings.brevo_508_members_newsletter_list_id is None + + +def test_shared_settings_brevo_members_list_id_accepts_numeric_string() -> None: + """Numeric Brevo list IDs from env should coerce to integers.""" + settings = SharedSettings(brevo_508_members_newsletter_list_id="4") + + assert settings.brevo_508_members_newsletter_list_id == 4 + + def test_local_service_defaults_target_host_runtime( monkeypatch: pytest.MonkeyPatch, ) -> None: From 8e1ce53951c3dd2785550b81d8a0bd5a7bcca540 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 10 Jun 2026 23:13:07 +0800 Subject: [PATCH 05/14] Skip Brevo list-level unsubscribes --- packages/shared/src/five08/newsletter_sync.py | 9 +++++++++ tests/unit/test_newsletter_sync.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/shared/src/five08/newsletter_sync.py b/packages/shared/src/five08/newsletter_sync.py index 0ee393f2..7126ff42 100644 --- a/packages/shared/src/five08/newsletter_sync.py +++ b/packages/shared/src/five08/newsletter_sync.py @@ -90,6 +90,12 @@ def _is_crm_blocked(contact: dict[str, Any] | None) -> bool: ) +def _contains_list_id(value: object, list_id: int) -> bool: + if not isinstance(value, list): + return False + return any(str(item).strip() == str(list_id) for item in value) + + class BrevoNewsletterProvider: """Brevo implementation for the 508 members newsletter list.""" @@ -124,6 +130,9 @@ def ensure_contact(self, contact: NewsletterContact) -> str: list_id = self._list_id() if list_id is None: return "skipped_list_missing" + if existing is not None: + if _contains_list_id(existing.get("listUnsubscribed"), list_id): + return "skipped_provider_suppressed" self.client.add_contact_to_list(email=contact.email, list_id=list_id) return "synced" diff --git a/tests/unit/test_newsletter_sync.py b/tests/unit/test_newsletter_sync.py index e861bc64..af6f4090 100644 --- a/tests/unit/test_newsletter_sync.py +++ b/tests/unit/test_newsletter_sync.py @@ -200,6 +200,25 @@ def test_sync_508_members_skips_provider_suppressed_contacts() -> None: } +def test_sync_508_members_skips_brevo_list_unsubscribed_contacts() -> None: + FakeMigaduClient.mailboxes = [ + MigaduMailbox( + address="jane@508.dev", + name="Jane Doe", + password_recovery_email="jane@example.com", + ) + ] + FakeBrevoClient.contacts = {"jane@example.com": {"listUnsubscribed": ["4"]}} + + result = NewsletterSyncProcessor(_settings()).sync_508_members() + + assert FakeBrevoClient.subscriptions == [{"email": "jane@508.dev", "list_id": 4}] + assert result["providers"]["brevo"]["statuses"] == { + "synced": 1, + "skipped_provider_suppressed": 1, + } + + def test_sync_508_members_skips_crm_blocked_mailboxes() -> None: FakeMigaduClient.mailboxes = [ MigaduMailbox( From 8c434d46a9d7cfa767777ec8a1d8595b04db2c3b Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 11 Jun 2026 01:13:17 +0800 Subject: [PATCH 06/14] Harden newsletter contact lookups --- packages/shared/src/five08/clients/brevo.py | 3 +- packages/shared/src/five08/clients/keila.py | 3 +- packages/shared/src/five08/newsletter_sync.py | 30 +++++++++------ tests/unit/test_brevo_client.py | 18 ++++++++- tests/unit/test_keila_client.py | 31 +++++++++++++++- tests/unit/test_newsletter_sync.py | 37 ++++++++++++++++++- 6 files changed, 105 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/five08/clients/brevo.py b/packages/shared/src/five08/clients/brevo.py index 8f4e20ee..aca1dc1b 100644 --- a/packages/shared/src/five08/clients/brevo.py +++ b/packages/shared/src/five08/clients/brevo.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from urllib.parse import quote import requests @@ -88,7 +89,7 @@ def get_contact(self, email: str) -> dict[str, Any] | None: } try: response = requests.get( - f"{self.base_url}/contacts/{normalized_email}", + f"{self.base_url}/contacts/{quote(normalized_email, safe='')}", headers=headers, timeout=self.timeout_seconds, ) diff --git a/packages/shared/src/five08/clients/keila.py b/packages/shared/src/five08/clients/keila.py index 7a0804b0..e255ebb8 100644 --- a/packages/shared/src/five08/clients/keila.py +++ b/packages/shared/src/five08/clients/keila.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from urllib.parse import quote import requests @@ -32,7 +33,7 @@ def get_contact_by_email(self, email: str) -> dict[str, Any] | None: normalized_email = email.strip().lower() response = self._request( "GET", - f"/api/v1/contacts/{normalized_email}", + f"/api/v1/contacts/{quote(normalized_email, safe='')}", params={"id_type": "email"}, allow_not_found=True, ) diff --git a/packages/shared/src/five08/newsletter_sync.py b/packages/shared/src/five08/newsletter_sync.py index 7126ff42..60244430 100644 --- a/packages/shared/src/five08/newsletter_sync.py +++ b/packages/shared/src/five08/newsletter_sync.py @@ -199,14 +199,14 @@ def sync_508_members(self) -> dict[str, Any]: continue try: - crm_contact = self._find_crm_contact(mailbox) + crm_contacts = self._list_crm_contacts(mailbox) except CRMContactLookupError as exc: result["crm_lookup_failed_skipped"] += 1 failures = result.setdefault("crm_lookup_failures", []) if isinstance(failures, list) and len(failures) < 20: failures.append({"mailbox": mailbox.address, "error": str(exc)}) continue - if _is_crm_blocked(crm_contact): + if any(_is_crm_blocked(contact) for contact in crm_contacts): result["crm_blocked_skipped"] += 1 continue @@ -245,10 +245,10 @@ def _crm_client(self) -> EspoClient | None: return None return EspoClient(base_url, api_key) - def _find_crm_contact(self, mailbox: MigaduMailbox) -> dict[str, Any] | None: + def _list_crm_contacts(self, mailbox: MigaduMailbox) -> list[dict[str, Any]]: client = self._crm_client() if client is None: - return None + return [] filters: list[dict[str, Any]] = [ {"type": "equals", "attribute": "c508Email", "value": mailbox.address}, {"type": "equals", "attribute": "emailAddress", "value": mailbox.address}, @@ -265,7 +265,7 @@ def _find_crm_contact(self, mailbox: MigaduMailbox) -> dict[str, Any] | None: response = client.list_contacts( { "where": [{"type": "or", "value": filters}], - "maxSize": 1, + "maxSize": 20, "select": "id,name,emailAddress,c508Email,type,cOnboardingState", } ) @@ -274,10 +274,9 @@ def _find_crm_contact(self, mailbox: MigaduMailbox) -> dict[str, Any] | None: f"CRM contact lookup failed for {mailbox.address}: {exc}" ) from exc contacts = response.get("list", []) - if not isinstance(contacts, list) or not contacts: - return None - contact = contacts[0] - return contact if isinstance(contact, dict) else None + if not isinstance(contacts, list): + return [] + return [contact for contact in contacts if isinstance(contact, dict)] def _required(value: str | None, name: str) -> str: @@ -292,6 +291,15 @@ def build_newsletter_providers(settings: Any) -> list[NewsletterProvider]: providers: list[NewsletterProvider] = [] brevo_api_key = str(getattr(settings, "brevo_api_key", "") or "").strip() if brevo_api_key: + list_name = ( + str( + getattr( + settings, "brevo_508_members_newsletter_list_name", "508 members" + ) + or "" + ).strip() + or "508 members" + ) providers.append( BrevoNewsletterProvider( BrevoClient( @@ -304,9 +312,7 @@ def build_newsletter_providers(settings: Any) -> list[NewsletterProvider]: ), ), list_id=getattr(settings, "brevo_508_members_newsletter_list_id", None), - list_name=getattr( - settings, "brevo_508_members_newsletter_list_name", "508 members" - ), + list_name=list_name, ) ) diff --git a/tests/unit/test_brevo_client.py b/tests/unit/test_brevo_client.py index b15d25ae..97fd1a9f 100644 --- a/tests/unit/test_brevo_client.py +++ b/tests/unit/test_brevo_client.py @@ -72,13 +72,29 @@ def test_get_contact_fetches_contact_by_email() -> None: result = BrevoClient(api_key="brevo-key").get_contact("Jane@Example.com") get.assert_called_once_with( - "https://api.brevo.com/v3/contacts/jane@example.com", + "https://api.brevo.com/v3/contacts/jane%40example.com", headers={"Accept": "application/json", "api-key": "brevo-key"}, timeout=20.0, ) assert result == {"email": "jane@example.com"} +def test_get_contact_url_encodes_plus_in_email() -> None: + response = Mock() + response.status_code = 200 + response.json.return_value = {"email": "jane+tag@example.com"} + + with patch("five08.clients.brevo.requests.get", return_value=response) as get: + result = BrevoClient(api_key="brevo-key").get_contact("Jane+Tag@Example.com") + + get.assert_called_once_with( + "https://api.brevo.com/v3/contacts/jane%2Btag%40example.com", + headers={"Accept": "application/json", "api-key": "brevo-key"}, + timeout=20.0, + ) + assert result == {"email": "jane+tag@example.com"} + + def test_get_contact_returns_none_for_missing_contact() -> None: response = Mock() response.status_code = 404 diff --git a/tests/unit/test_keila_client.py b/tests/unit/test_keila_client.py index 7a2238fd..fde2177d 100644 --- a/tests/unit/test_keila_client.py +++ b/tests/unit/test_keila_client.py @@ -25,7 +25,7 @@ def test_get_contact_by_email_fetches_contact() -> None: request.assert_called_once_with( "GET", - "https://app.keila.io/api/v1/contacts/jane@example.com", + "https://app.keila.io/api/v1/contacts/jane%40example.com", headers={ "Accept": "application/json", "Authorization": "Bearer keila-key", @@ -37,6 +37,35 @@ def test_get_contact_by_email_fetches_contact() -> None: assert result == {"id": "contact-1", "email": "jane@example.com"} +def test_get_contact_by_email_url_encodes_plus_in_email() -> None: + response = Mock() + response.status_code = 200 + response.content = b'{"data":{"id":"contact-1","email":"jane+tag@example.com"}}' + response.json.return_value = { + "data": {"id": "contact-1", "email": "jane+tag@example.com"} + } + + with patch( + "five08.clients.keila.requests.request", return_value=response + ) as request: + result = KeilaClient(api_key="keila-key").get_contact_by_email( + "Jane+Tag@Example.com" + ) + + request.assert_called_once_with( + "GET", + "https://app.keila.io/api/v1/contacts/jane%2Btag%40example.com", + headers={ + "Accept": "application/json", + "Authorization": "Bearer keila-key", + }, + params={"id_type": "email"}, + json=None, + timeout=20.0, + ) + assert result == {"id": "contact-1", "email": "jane+tag@example.com"} + + def test_get_contact_by_email_returns_none_for_missing_contact() -> None: response = Mock() response.status_code = 404 diff --git a/tests/unit/test_newsletter_sync.py b/tests/unit/test_newsletter_sync.py index af6f4090..a51bb877 100644 --- a/tests/unit/test_newsletter_sync.py +++ b/tests/unit/test_newsletter_sync.py @@ -9,7 +9,7 @@ from five08.clients.migadu import MigaduMailbox from five08.clients.espo import EspoAPIError -from five08.newsletter_sync import NewsletterSyncProcessor +from five08.newsletter_sync import NewsletterSyncProcessor, build_newsletter_providers class FakeMigaduClient: @@ -239,6 +239,41 @@ def test_sync_508_members_skips_crm_blocked_mailboxes() -> None: assert FakeKeilaClient.upserts == [] +def test_sync_508_members_skips_mailbox_when_any_crm_match_is_blocked() -> None: + FakeMigaduClient.mailboxes = [ + MigaduMailbox( + address="jane@508.dev", + name="Jane Doe", + password_recovery_email="jane@example.com", + ) + ] + FakeEspoClient.contacts = [ + {"id": "contact-1", "type": "Member"}, + {"id": "contact-2", "type": "Inactive Member"}, + ] + + result = NewsletterSyncProcessor( + _settings(espo_base_url="https://crm.example", espo_api_key="espo-key") + ).sync_508_members() + + assert result["crm_blocked_skipped"] == 1 + assert result["contacts_considered"] == 0 + assert FakeBrevoClient.subscriptions == [] + assert FakeKeilaClient.upserts == [] + + +def test_build_newsletter_providers_uses_default_list_name_when_blank() -> None: + providers = build_newsletter_providers( + _settings( + brevo_508_members_newsletter_list_id=None, + brevo_508_members_newsletter_list_name=" ", + ) + ) + + assert len(providers) == 2 + assert providers[0].list_name == "508 members" + + def test_sync_508_members_skips_mailbox_when_crm_lookup_fails() -> None: FakeMigaduClient.mailboxes = [ MigaduMailbox( From cfacfe495162cb13bde9d284c28e2417ba074206 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 11 Jun 2026 01:24:23 +0800 Subject: [PATCH 07/14] Report suppressed newsletter skips --- packages/shared/src/five08/newsletter_sync.py | 10 ++++- tests/unit/test_agent_gateway.py | 39 ++++++++++++++++++- tests/unit/test_newsletter_sync.py | 23 ++++++++++- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/five08/newsletter_sync.py b/packages/shared/src/five08/newsletter_sync.py index 60244430..548a7a0b 100644 --- a/packages/shared/src/five08/newsletter_sync.py +++ b/packages/shared/src/five08/newsletter_sync.py @@ -414,7 +414,13 @@ def format_newsletter_sync_warning(result: dict[str, Any]) -> str | None: messages.append(f"{provider_name} failed for {failed} contact(s)") statuses = provider_result.get("statuses") - if isinstance(statuses, dict) and statuses.get("skipped_list_missing"): - messages.append(f"{provider_name} list was not found") + if isinstance(statuses, dict): + if statuses.get("skipped_list_missing"): + messages.append(f"{provider_name} list was not found") + suppressed = int(statuses.get("skipped_provider_suppressed") or 0) + if suppressed: + messages.append( + f"{provider_name} skipped {suppressed} suppressed contact(s)" + ) return "; ".join(messages) if messages else None diff --git a/tests/unit/test_agent_gateway.py b/tests/unit/test_agent_gateway.py index b95200d4..6e5be1ab 100644 --- a/tests/unit/test_agent_gateway.py +++ b/tests/unit/test_agent_gateway.py @@ -2087,6 +2087,7 @@ def invite_user( return invite class FakeBrevoClient: + contacts: dict[str, dict[str, Any]] = {} subscriptions: list[dict[str, Any]] = [] def __init__( @@ -2105,12 +2106,13 @@ def add_contact_to_list(self, *, email: str, list_id: int) -> dict[str, Any]: return {"id": len(self.subscriptions)} def get_contact(self, email: str) -> dict[str, Any] | None: - return None + return self.contacts.get(email) def find_list_id_by_name(self, name: str) -> int | None: return 4 if name == "508 members" else None class FakeKeilaClient: + contacts: dict[str, dict[str, Any]] = {} upserts: list[dict[str, Any]] = [] def __init__( @@ -2125,7 +2127,7 @@ def __init__( self.timeout_seconds = timeout_seconds def get_contact_by_email(self, email: str) -> dict[str, Any] | None: - return None + return self.contacts.get(email) def upsert_active_contact( self, @@ -2145,6 +2147,11 @@ def upsert_active_contact( ) return {"id": str(len(self.upserts))} + FakeBrevoClient.contacts = {} + FakeBrevoClient.subscriptions = [] + FakeKeilaClient.contacts = {} + FakeKeilaClient.upserts = [] + monkeypatch.setattr("five08.agent.tools.EspoClient", FakeEspoClient) monkeypatch.setattr("five08.agent.tools.AuthentikClient", FakeAuthentikClient) monkeypatch.setattr("five08.agent.tools.MigaduClient", FakeMigaduClient) @@ -2560,6 +2567,34 @@ def test_user_accounts_tool_subscribes_mailbox_and_backup_email_to_brevo( ] +def test_user_accounts_tool_reports_suppressed_newsletter_contact( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fakes = _install_account_tool_fakes(monkeypatch) + fakes.brevo.contacts = {"jane@example.com": {"emailBlacklisted": True}} + registry = ToolRegistry(runtime_config=_account_runtime_config(brevo_api_key="key")) + + result = registry.execute( + "account_write.create_user_accounts", + {"contact_id": "contact-1", "mailbox_username": "jane@508.dev"}, + organization_id="org-1", + actor_id="123", + actor_scopes={ + "mailbox:create", + "user:manage", + "integration:manage", + "crm:contact:read", + "crm:contact:update", + }, + ) + + assert result["mailbox"]["newsletter_subscribed"] is False + assert result["mailbox"]["newsletter_error"] == ( + "brevo skipped 1 suppressed contact(s)" + ) + assert fakes.brevo.subscriptions == [{"email": "jane@508.dev", "list_id": 4}] + + def test_user_accounts_tool_subscribes_mailbox_and_backup_email_to_keila( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit/test_newsletter_sync.py b/tests/unit/test_newsletter_sync.py index a51bb877..4977add5 100644 --- a/tests/unit/test_newsletter_sync.py +++ b/tests/unit/test_newsletter_sync.py @@ -9,7 +9,11 @@ from five08.clients.migadu import MigaduMailbox from five08.clients.espo import EspoAPIError -from five08.newsletter_sync import NewsletterSyncProcessor, build_newsletter_providers +from five08.newsletter_sync import ( + NewsletterSyncProcessor, + build_newsletter_providers, + format_newsletter_sync_warning, +) class FakeMigaduClient: @@ -274,6 +278,23 @@ def test_build_newsletter_providers_uses_default_list_name_when_blank() -> None: assert providers[0].list_name == "508 members" +def test_format_newsletter_sync_warning_reports_suppressed_skips() -> None: + warning = format_newsletter_sync_warning( + { + "providers": { + "brevo": { + "synced": 1, + "skipped": 1, + "failed": 0, + "statuses": {"skipped_provider_suppressed": 1}, + } + } + } + ) + + assert warning == "brevo skipped 1 suppressed contact(s)" + + def test_sync_508_members_skips_mailbox_when_crm_lookup_fails() -> None: FakeMigaduClient.mailboxes = [ MigaduMailbox( From 7345a8f1ed8a90a6543998f90f8f11558fb6ba9c Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 11 Jun 2026 01:40:28 +0800 Subject: [PATCH 08/14] Address newsletter sync review hardening --- .env.example | 2 +- .../static/dashboard/.vite/manifest.json | 2 +- .../{index-DTe7lDq-.js => index-BHZftuFZ.js} | 2 +- .../backend/static/dashboard/index.html | 2 +- .../src/five08/discord_bot/cogs/crm.py | 20 +++++-- .../src/five08/discord_bot/cogs/migadu.py | 20 +++++-- apps/worker/src/five08/worker/jobs.py | 24 +++++++- packages/shared/src/five08/agent/tools.py | 14 +++-- packages/shared/src/five08/clients/brevo.py | 2 + packages/shared/src/five08/clients/keila.py | 7 ++- packages/shared/src/five08/newsletter_sync.py | 5 +- tests/unit/test_agent_gateway.py | 34 +++++++++++ tests/unit/test_brevo_client.py | 6 ++ tests/unit/test_crm_create_sso_user.py | 15 +++++ tests/unit/test_keila_client.py | 16 ++++++ tests/unit/test_migadu_create_mailbox.py | 15 +++++ tests/unit/test_newsletter_sync.py | 19 +++++++ tests/unit/test_worker_newsletter_sync.py | 56 +++++++++++++++++++ 18 files changed, 237 insertions(+), 24 deletions(-) rename apps/api/src/five08/backend/static/dashboard/assets/{index-DTe7lDq-.js => index-BHZftuFZ.js} (73%) create mode 100644 tests/unit/test_worker_newsletter_sync.py diff --git a/.env.example b/.env.example index fba87499..dcaf4b36 100644 --- a/.env.example +++ b/.env.example @@ -239,7 +239,7 @@ BREVO_API_KEY= BREVO_API_BASE_URL=https://api.brevo.com/v3 BREVO_API_TIMEOUT_SECONDS=20.0 BREVO_508_MEMBERS_NEWSLETTER_LIST_ID= -BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME=508 members +BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME="508 members" KEILA_API_KEY= KEILA_API_BASE_URL=https://app.keila.io KEILA_API_TIMEOUT_SECONDS=20.0 diff --git a/apps/api/src/five08/backend/static/dashboard/.vite/manifest.json b/apps/api/src/five08/backend/static/dashboard/.vite/manifest.json index 2631b894..88a0c1e6 100644 --- a/apps/api/src/five08/backend/static/dashboard/.vite/manifest.json +++ b/apps/api/src/five08/backend/static/dashboard/.vite/manifest.json @@ -1,6 +1,6 @@ { "index.html": { - "file": "assets/index-DTe7lDq-.js", + "file": "assets/index-BHZftuFZ.js", "name": "index", "src": "index.html", "isEntry": true, diff --git a/apps/api/src/five08/backend/static/dashboard/assets/index-DTe7lDq-.js b/apps/api/src/five08/backend/static/dashboard/assets/index-BHZftuFZ.js similarity index 73% rename from apps/api/src/five08/backend/static/dashboard/assets/index-DTe7lDq-.js rename to apps/api/src/five08/backend/static/dashboard/assets/index-BHZftuFZ.js index 345bc264..5cf12b2b 100644 --- a/apps/api/src/five08/backend/static/dashboard/assets/index-DTe7lDq-.js +++ b/apps/api/src/five08/backend/static/dashboard/assets/index-BHZftuFZ.js @@ -6,4 +6,4 @@ var e=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports);(function `+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{Te=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?we(n):``}function De(e,t){switch(e.tag){case 26:case 27:case 5:return we(e.type);case 16:return we(`Lazy`);case 13:return e.child!==t&&t!==null?we(`Suspense Fallback`):we(`Suspense`);case 19:return we(`SuspenseList`);case 0:case 15:return Ee(e.type,!1);case 11:return Ee(e.type.render,!1);case 1:return Ee(e.type,!0);case 31:return we(`Activity`);default:return``}}function Oe(e){try{var t=``,n=null;do t+=De(e,n),n=e,e=e.return;while(e);return t}catch(e){return` Error generating stack: `+e.message+` `+e.stack}}var ke=Object.prototype.hasOwnProperty,Ae=t.unstable_scheduleCallback,je=t.unstable_cancelCallback,Me=t.unstable_shouldYield,Ne=t.unstable_requestPaint,Pe=t.unstable_now,Fe=t.unstable_getCurrentPriorityLevel,Ie=t.unstable_ImmediatePriority,Le=t.unstable_UserBlockingPriority,Re=t.unstable_NormalPriority,ze=t.unstable_LowPriority,Be=t.unstable_IdlePriority,Ve=t.log,He=t.unstable_setDisableYieldValue,Ue=null,A=null;function We(e){if(typeof Ve==`function`&&He(e),A&&typeof A.setStrictMode==`function`)try{A.setStrictMode(Ue,e)}catch{}}var Ge=Math.clz32?Math.clz32:Je,Ke=Math.log,qe=Math.LN2;function Je(e){return e>>>=0,e===0?32:31-(Ke(e)/qe|0)|0}var Ye=256,Xe=262144,Ze=4194304;function Qe(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function j(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Qe(n))):i=Qe(o):i=Qe(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Qe(n))):i=Qe(o)):i=Qe(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function $e(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function et(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function tt(){var e=Ze;return Ze<<=1,!(Ze&62914560)&&(Ze=4194304),e}function nt(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function rt(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function it(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),rn=!1;if(nn)try{var an={};Object.defineProperty(an,`passive`,{get:function(){rn=!0}}),window.addEventListener(`test`,an,an),window.removeEventListener(`test`,an,an)}catch{rn=!1}var on=null,sn=null,cn=null;function ln(){if(cn)return cn;var e,t=sn,n=t.length,r,i=`value`in on?on.value:on.textContent,a=i.length;for(e=0;e=Bn),Un=` `,Wn=!1;function Gn(e,t){switch(e){case`keyup`:return Rn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Kn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var qn=!1;function Jn(e,t){switch(e){case`compositionend`:return Kn(t);case`keypress`:return t.which===32?(Wn=!0,Un):null;case`textInput`:return e=t.data,e===Un&&Wn?null:e;default:return null}}function Yn(e,t){if(qn)return e===`compositionend`||!zn&&Gn(e,t)?(e=ln(),cn=sn=on=null,qn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=_r(n)}}function yr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?yr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function br(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=z(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=z(e.document)}return t}function xr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Sr=nn&&`documentMode`in document&&11>=document.documentMode,Cr=null,wr=null,Tr=null,Er=!1;function Dr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Er||Cr==null||Cr!==z(r)||(r=Cr,`selectionStart`in r&&xr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Tr&&gr(Tr,r)||(Tr=r,r=Td(wr,`onSelect`),0>=o,i-=o,yi=1<<32-Ge(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),K&&xi(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(i,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(i,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(i,h),K&&xi(i,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(i,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return K&&xi(i,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,i,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(i,e)}),K&&xi(i,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===_&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case h:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===_){if(r.tag===7){n(e,r.sibling),c=i(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===ne&&xa(l)===r.type){n(e,r.sibling),c=i(r,o.props),Oa(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===_?(c=oi(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=ai(o.type,o.key,o.props,null,e.mode,c),Oa(c,o),c.return=e,e=c)}return s(e);case g:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=i(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=li(o,e.mode,c),c.return=e,e=c}return s(e);case ne:return o=xa(o),b(e,r,o,c)}if(ue(o))return v(e,r,o,c);if(oe(o)){if(l=oe(o),typeof l!=`function`)throw Error(a(150));return o=l.call(o),y(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Da(o),c);if(o.$$typeof===x)return b(e,r,Yi(e,o),c);ka(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=i(r,o),c.return=e,e=c):(n(e,r),c=si(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Ea=0;var i=b(e,t,n,r);return Ta=null,i}catch(t){if(t===ha||t===_a)throw t;var a=ti(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var ja=Aa(!0),Ma=Aa(!1),Na=!1;function Pa(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Fa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ia(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function La(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,J&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=Qr(e),Zr(e,null,n),t}return Jr(e,r,t,n),Qr(e)}function Ra(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ot(e,n)}}function za(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Ba=!1;function Va(){if(Ba){var e=oa;if(e!==null)throw e}}function Ha(e,t,n,r){Ba=!1;var i=e.updateQueue;Na=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,m=f!==s.lane;if(m?(X&f)===f:(r&f)===f){f!==0&&f===aa&&(Ba=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,f);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,f=typeof h==`function`?h.call(_,d,f):h,f==null)break a;d=p({},d,f);break a;case 2:Na=!0}}f=s.callback,f!==null&&(e.flags|=64,m&&(e.flags|=8192),m=i.callbacks,m===null?i.callbacks=[f]:m.push(f))}else m={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=m,c=d):u=u.next=m,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;m=s,s=m.next,m.next=null,i.lastBaseUpdate=m,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Wl|=o,e.lanes=o,e.memoizedState=d}}function Ua(e,t){if(typeof e!=`function`)throw Error(a(191,e));e.call(t)}function Wa(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=T.T,s={};T.T=s,As(e,!1,t,n);try{var c=i(),l=T.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?ks(e,t,la(c,r),fu(e)):ks(e,t,r,fu(e))}catch(n){ks(e,t,{then:function(){},status:`rejected`,reason:n},fu())}finally{E.p=a,o!==null&&s.types!==null&&(o.types=s.types),T.T=o}}function ys(){}function bs(e,t,n,r){if(e.tag!==5)throw Error(a(476));var i=xs(e).queue;vs(e,i,t,de,n===null?ys:function(){return Ss(e),n(r)})}function xs(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:de,baseState:de,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:jo,lastRenderedState:de},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:jo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Ss(e){var t=xs(e);t.next===null&&(t=e.alternate.memoizedState),ks(e,t.next.queue,{},fu())}function Cs(){return Ji(Qf)}function ws(){return Eo().memoizedState}function Ts(){return Eo().memoizedState}function Es(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=fu();e=Ia(n);var r=La(t,e,n);r!==null&&(mu(r,t,n),Ra(r,t,n)),t={cache:ta()},e.payload=t;return}t=t.return}}function Ds(e,t,n){var r=fu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},js(e)?Ms(t,n):(n=Yr(e,t,n,r),n!==null&&(mu(n,e,r),Ns(n,t,r)))}function Os(e,t,n){ks(e,t,n,fu())}function ks(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(js(e))Ms(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,hr(s,o))return Jr(e,t,i,0),Ll===null&&qr(),!1}catch{}if(n=Yr(e,t,i,r),n!==null)return mu(n,e,r),Ns(n,t,r),!0}return!1}function As(e,t,n,r){if(r={lane:2,revertLane:ud(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},js(e)){if(t)throw Error(a(479))}else t=Yr(e,n,r,2),t!==null&&mu(t,e,2)}function js(e){var t=e.alternate;return e===q||t!==null&&t===q}function Ms(e,t){lo=co=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Ns(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ot(e,n)}}var Ps={readContext:Ji,use:ko,useCallback:go,useContext:go,useEffect:go,useImperativeHandle:go,useLayoutEffect:go,useInsertionEffect:go,useMemo:go,useReducer:go,useRef:go,useState:go,useDebugValue:go,useDeferredValue:go,useTransition:go,useSyncExternalStore:go,useId:go,useHostTransitionStatus:go,useFormState:go,useActionState:go,useOptimistic:go,useMemoCache:go,useCacheRefresh:go};Ps.useEffectEvent=go;var Fs={readContext:Ji,use:ko,useCallback:function(e,t){return To().memoizedState=[e,t===void 0?null:t],e},useContext:Ji,useEffect:as,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),rs(4194308,4,ds.bind(null,t,e),n)},useLayoutEffect:function(e,t){return rs(4194308,4,e,t)},useInsertionEffect:function(e,t){rs(4,2,e,t)},useMemo:function(e,t){var n=To();t=t===void 0?null:t;var r=e();if(uo){We(!0);try{e()}finally{We(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=To();if(n!==void 0){var i=n(t);if(uo){We(!0);try{n(t)}finally{We(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Ds.bind(null,q,e),[r.memoizedState,e]},useRef:function(e){var t=To();return e={current:e},t.memoizedState=e},useState:function(e){e=Vo(e);var t=e.queue,n=Os.bind(null,q,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:ps,useDeferredValue:function(e,t){return gs(To(),e,t)},useTransition:function(){var e=Vo(!1);return e=vs.bind(null,q,e.queue,!0,!1),To().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=q,i=To();if(K){if(n===void 0)throw Error(a(407));n=n()}else{if(n=t(),Ll===null)throw Error(a(349));X&127||Io(r,t,n)}i.memoizedState=n;var o={value:n,getSnapshot:t};return i.queue=o,as(Ro.bind(null,r,o,e),[e]),r.flags|=2048,ts(9,{destroy:void 0},Lo.bind(null,r,o,n,t),null),n},useId:function(){var e=To(),t=Ll.identifierPrefix;if(K){var n=bi,r=yi;n=(r&~(1<<32-Ge(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=fo++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(i,{is:r.is}):s.createElement(i)}}o[ft]=t,o[pt]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,i,r),i){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&kc(t)}}return Pc(t),Ac(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&kc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(a(166));if(e=ge.current,Pi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,i=Ei,i!==null)switch(i.tag){case 27:case 5:r=i.memoizedProps}e[ft]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||jd(e.nodeValue,n)),e||ji(t,!0)}else e=Bd(e).createTextNode(r),e[ft]=t,t.stateNode=e}return Pc(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Pi(t),n!==null){if(e===null){if(!r)throw Error(a(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(a(557));e[ft]=t}else Fi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Pc(t),e=!1}else n=Ii(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(no(t),t):(no(t),null);if(t.flags&128)throw Error(a(558))}return Pc(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(i=Pi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!i)throw Error(a(318));if(i=t.memoizedState,i=i===null?null:i.dehydrated,!i)throw Error(a(317));i[ft]=t}else Fi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Pc(t),i=!1}else i=Ii(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=i),i=!0;if(!i)return t.flags&256?(no(t),t):(no(t),null)}return no(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,i=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(i=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==i&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Mc(t,t.updateQueue),Pc(t),null);case 4:return ye(),e===null&&xd(t.stateNode.containerInfo),Pc(t),null;case 10:return Hi(t.type),Pc(t),null;case 19:if(O(ro),r=t.memoizedState,r===null)return Pc(t),null;if(i=(t.flags&128)!=0,o=r.rendering,o===null)if(i)Nc(r,!1);else{if(Ul!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=io(e),o!==null){for(t.flags|=128,Nc(r,!1),e=o.updateQueue,t.updateQueue=e,Mc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)ii(n,e),n=n.sibling;return k(ro,ro.current&1|2),K&&xi(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Pe()>eu&&(t.flags|=128,i=!0,Nc(r,!1),t.lanes=4194304)}else{if(!i)if(e=io(o),e!==null){if(t.flags|=128,i=!0,e=e.updateQueue,t.updateQueue=e,Mc(t,e),Nc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!K)return Pc(t),null}else 2*Pe()-r.renderingStartTime>eu&&n!==536870912&&(t.flags|=128,i=!0,Nc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(Pc(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Pe(),e.sibling=null,n=ro.current,k(ro,i?n&1|2:n&1),K&&xi(t,r.treeForkCount),e);case 22:case 23:return no(t),Ya(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Pc(t),t.subtreeFlags&6&&(t.flags|=8192)):Pc(t),n=t.updateQueue,n!==null&&Mc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&O(da),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Hi(ea),Pc(t),null;case 25:return null;case 30:return null}throw Error(a(156,t.tag))}function Ic(e,t){switch(wi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Hi(ea),ye(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return xe(t),null;case 31:if(t.memoizedState!==null){if(no(t),t.alternate===null)throw Error(a(340));Fi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(no(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(a(340));Fi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return O(ro),null;case 4:return ye(),null;case 10:return Hi(t.type),null;case 22:case 23:return no(t),Ya(),e!==null&&O(da),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Hi(ea),null;case 25:return null;default:return null}}function Lc(e,t){switch(wi(t),t.tag){case 3:Hi(ea),ye();break;case 26:case 27:case 5:xe(t);break;case 4:ye();break;case 31:t.memoizedState!==null&&no(t);break;case 13:no(t);break;case 19:O(ro);break;case 10:Hi(t.type);break;case 22:case 23:no(t),Ya(),e!==null&&O(da);break;case 24:Hi(ea)}}function Rc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Q(t,t.return,e)}}function zc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Q(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Q(t,t.return,e)}}function Bc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Wa(t,n)}catch(t){Q(e,e.return,t)}}}function Vc(e,t,n){n.props=Hs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Q(e,t,n)}}function Hc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Q(e,t,n)}}function Uc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Q(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Q(e,t,n)}else n.current=null}function Wc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Q(e,e.return,t)}}function Gc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[pt]=t}catch(t){Q(e,e.return,t)}}function Kc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function qc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Kc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Jc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=qt));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Jc(e,t,n),e=e.sibling;e!==null;)Jc(e,t,n),e=e.sibling}function Yc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(Yc(e,t,n),e=e.sibling;e!==null;)Yc(e,t,n),e=e.sibling}function Xc(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[ft]=e,t[pt]=n}catch(t){Q(e,e.return,t)}}var Zc=!1,Qc=!1,$c=!1,el=typeof WeakSet==`function`?WeakSet:Set,tl=null;function nl(e,t){if(e=e.containerInfo,Rd=sp,e=br(e),xr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var i=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||i!==0&&f.nodeType!==3||(c=s+i),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===i&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,tl=t;tl!==null;)if(t=tl,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,tl=e;else for(;tl!==null;){switch(t=tl,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[ft]=e,St(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,i).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=vr(s,h),v=vr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,T.T=null,n=cu,cu=null;var o=iu,s=ou;if(ru=0,au=iu=null,ou=0,J&6)throw Error(a(331));var c=J;if(J|=4,Ml(o.current),wl(o,o.current,s,n),J=c,rd(0,!1),A&&typeof A.onPostCommitFiberRoot==`function`)try{A.onPostCommitFiberRoot(Ue,o)}catch{}return!0}finally{E.p=i,T.T=r,Bu(e,t)}}function Uu(e,t,n){t=di(n,t),t=Js(e.stateNode,t,2),e=La(e,t,2),e!==null&&(rt(e,2),nd(e))}function Q(e,t,n){if(e.tag===3)Uu(e,e,n);else for(;t!==null;){if(t.tag===3){Uu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(nu===null||!nu.has(r))){e=di(n,e),n=Ys(2),r=La(t,n,2),r!==null&&(Xs(n,r,t,e),rt(r,2),nd(r));break}}t=t.return}}function Wu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Il;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Vl=!0,i.add(n),e=Gu.bind(null,e,t,n),t.then(e,e))}function Gu(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Ll===e&&(X&n)===n&&(Ul===4||Ul===3&&(X&62914560)===X&&300>Pe()-Ql?!(J&2)&&xu(e,0):Kl|=n,Jl===X&&(Jl=0)),nd(e)}function Ku(e,t){t===0&&(t=tt()),e=Xr(e,t),e!==null&&(rt(e,t),nd(e))}function qu(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Ku(e,n)}function Ju(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,i=e.memoizedState;i!==null&&(n=i.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(a(314))}r!==null&&r.delete(t),Ku(e,n)}function Yu(e,t){return Ae(e,t)}var Xu=null,Zu=null,Qu=!1,$u=!1,ed=!1,td=0;function nd(e){e!==Zu&&e.next===null&&(Zu===null?Xu=Zu=e:Zu=Zu.next=e),$u=!0,Qu||(Qu=!0,ld())}function rd(e,t){if(!ed&&$u){ed=!0;do for(var n=!1,r=Xu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Ge(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,cd(r,a))}else a=X,a=j(r,r===Ll?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||$e(r,a)||(n=!0,cd(r,a));r=r.next}while(n);ed=!1}}function id(){ad()}function ad(){$u=Qu=!1;var e=0;td!==0&&Gd()&&(e=td);for(var t=Pe(),n=null,r=Xu;r!==null;){var i=r.next,a=od(r,t);a===0?(r.next=null,n===null?Xu=i:n.next=i,i===null&&(Zu=n)):(n=r,(e!==0||a&3)&&($u=!0)),r=i}ru!==0&&ru!==5||rd(e,!1),td!==0&&(td=0)}function od(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=B(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),St(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+B(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+B(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+B(n.imageSizes)+`"]`)):i+=`[href="`+B(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=p({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),St(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+B(r)+`"][href="`+B(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=p({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),St(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=xt(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=p({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);St(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=xt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=p({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),St(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=xt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=p({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),St(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var i=(i=ge.current)?gf(i):null;if(!i)throw Error(a(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=xt(i).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=xt(i).hoistableStyles,s=o.get(e);if(s||(i=i.ownerDocument||i,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=i.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(i,e,n,s.state))),t&&r===null)throw Error(a(528,``));return s}if(t&&r!==null)throw Error(a(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=xt(i).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(a(444,e))}}function Af(e){return`href="`+B(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return p({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),St(t),e.head.appendChild(t))}function Pf(e){return`[src="`+B(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+B(n.href)+`"]`);if(r)return t.instance=r,St(r),r;var i=p({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),St(r),Pd(r,`style`,i),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:i=Af(n.href);var o=e.querySelector(jf(i));if(o)return t.state.loading|=4,t.instance=o,St(o),o;r=Mf(n),(i=mf.get(i))&&Rf(r,i),o=(e.ownerDocument||e).createElement(`link`),St(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(i=e.querySelector(Ff(o)))?(t.instance=i,St(i),i):(r=n,(i=mf.get(o))&&(r=p({},n),zf(r,i)),e=e.ownerDocument||e,i=e.createElement(`script`),St(i),Pd(i,`link`,r),e.head.appendChild(i),t.instance=i);case`void`:return null;default:throw Error(a(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,St(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),St(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=le()}))();function T(e){var t,n,r=``;if(typeof e==`string`||typeof e==`number`)r+=e;else if(typeof e==`object`)if(Array.isArray(e)){var i=e.length;for(t=0;ttypeof e==`boolean`?`${e}`:e===0?`0`:e,fe=E,pe=(e,t)=>n=>{if(t?.variants==null)return fe(e,n?.class,n?.className);let{variants:r,defaultVariants:i}=t,a=Object.keys(r).map(e=>{let t=n?.[e],a=i?.[e];if(t===null)return null;let o=de(t)||de(a);return r[e][o]}),o=n&&Object.entries(n).reduce((e,t)=>{let[n,r]=t;return r===void 0||(e[n]=r),e},{});return fe(e,a,t?.compoundVariants?.reduce((e,t)=>{let{class:n,className:r,...a}=t;return Object.entries(a).every(e=>{let[t,n]=e;return Array.isArray(n)?n.includes({...i,...o}[t]):{...i,...o}[t]===n})?[...e,n,r]:e},[]),n?.class,n?.className)},D=(e,t)=>{let n=Array(e.length+t.length);for(let t=0;t({classGroupId:e,validator:t}),k=(e=new Map,t=null,n)=>({nextPart:e,validators:t,classGroupId:n}),me=`-`,he=[],ge=`arbitrary..`,_e=e=>{let t=be(e),{conflictingClassGroups:n,conflictingClassGroupModifiers:r}=e;return{getClassGroupId:e=>{if(e.startsWith(`[`)&&e.endsWith(`]`))return ye(e);let n=e.split(me);return ve(n,+(n[0]===``&&n.length>1),t)},getConflictingClassGroupIds:(e,t)=>{if(t){let t=r[e],i=n[e];return t?i?D(i,t):t:i||he}return n[e]||he}}},ve=(e,t,n)=>{if(e.length-t===0)return n.classGroupId;let r=e[t],i=n.nextPart.get(r);if(i){let n=ve(e,t+1,i);if(n)return n}let a=n.validators;if(a===null)return;let o=t===0?e.join(me):e.slice(t).join(me),s=a.length;for(let e=0;ee.slice(1,-1).indexOf(`:`)===-1?void 0:(()=>{let t=e.slice(1,-1),n=t.indexOf(`:`),r=t.slice(0,n);return r?ge+r:void 0})(),be=e=>{let{theme:t,classGroups:n}=e;return xe(n,t)},xe=(e,t)=>{let n=k();for(let r in e){let i=e[r];Se(i,n,r,t)}return n},Se=(e,t,n,r)=>{let i=e.length;for(let a=0;a{if(typeof e==`string`){we(e,t,n);return}if(typeof e==`function`){Te(e,t,n,r);return}Ee(e,t,n,r)},we=(e,t,n)=>{let r=e===``?t:De(t,e);r.classGroupId=n},Te=(e,t,n,r)=>{if(Oe(e)){Se(e(r),t,n,r);return}t.validators===null&&(t.validators=[]),t.validators.push(O(n,e))},Ee=(e,t,n,r)=>{let i=Object.entries(e),a=i.length;for(let e=0;e{let n=e,r=t.split(me),i=r.length;for(let e=0;e`isThemeGetter`in e&&e.isThemeGetter===!0,ke=e=>{if(e<1)return{get:()=>void 0,set:()=>{}};let t=0,n=Object.create(null),r=Object.create(null),i=(i,a)=>{n[i]=a,t++,t>e&&(t=0,r=n,n=Object.create(null))};return{get(e){let t=n[e];if(t!==void 0)return t;if((t=r[e])!==void 0)return i(e,t),t},set(e,t){e in n?n[e]=t:i(e,t)}}},Ae=`!`,je=`:`,Me=[],Ne=(e,t,n,r,i)=>({modifiers:e,hasImportantModifier:t,baseClassName:n,maybePostfixModifierPosition:r,isExternal:i}),Pe=e=>{let{prefix:t,experimentalParseClassName:n}=e,r=e=>{let t=[],n=0,r=0,i=0,a,o=e.length;for(let s=0;si?a-i:void 0;return Ne(t,l,c,u)};if(t){let e=t+je,n=r;r=t=>t.startsWith(e)?n(t.slice(e.length)):Ne(Me,!1,t,void 0,!0)}if(n){let e=r;r=t=>n({className:t,parseClassName:e})}return r},Fe=e=>{let t=new Map;return e.orderSensitiveModifiers.forEach((e,n)=>{t.set(e,1e6+n)}),e=>{let n=[],r=[];for(let i=0;i0&&(r.sort(),n.push(...r),r=[]),n.push(a)):r.push(a)}return r.length>0&&(r.sort(),n.push(...r)),n}},Ie=e=>({cache:ke(e.cacheSize),parseClassName:Pe(e),sortModifiers:Fe(e),postfixLookupClassGroupIds:Le(e),..._e(e)}),Le=e=>{let t=Object.create(null),n=e.postfixLookupClassGroups;if(n)for(let e=0;e{let{parseClassName:n,getClassGroupId:r,getConflictingClassGroupIds:i,sortModifiers:a,postfixLookupClassGroupIds:o}=t,s=[],c=e.trim().split(Re),l=``;for(let e=c.length-1;e>=0;--e){let t=c[e],{isExternal:u,modifiers:d,hasImportantModifier:f,baseClassName:p,maybePostfixModifierPosition:m}=n(t);if(u){l=t+(l.length>0?` `+l:l);continue}let h=!!m,g;if(h){g=r(p.substring(0,m));let e=g&&o[g]?r(p):void 0;e&&e!==g&&(g=e,h=!1)}else g=r(p);if(!g){if(!h){l=t+(l.length>0?` `+l:l);continue}if(g=r(p),!g){l=t+(l.length>0?` `+l:l);continue}h=!1}let _=d.length===0?``:d.length===1?d[0]:a(d).join(`:`),v=f?_+Ae:_,y=v+g;if(s.indexOf(y)>-1)continue;s.push(y);let b=i(g,h);for(let e=0;e0?` `+l:l)}return l},Be=(...e)=>{let t=0,n,r,i=``;for(;t{if(typeof e==`string`)return e;let t,n=``;for(let r=0;r{let n,r,i,a,o=o=>(n=Ie(t.reduce((e,t)=>t(e),e())),r=n.cache.get,i=n.cache.set,a=s,s(o)),s=e=>{let t=r(e);if(t)return t;let a=ze(e,n);return i(e,a),a};return a=o,(...e)=>a(Be(...e))},Ue=[],A=e=>{let t=t=>t[e]||Ue;return t.isThemeGetter=!0,t},We=/^\[(?:(\w[\w-]*):)?(.+)\]$/i,Ge=/^\((?:(\w[\w-]*):)?(.+)\)$/i,Ke=/^\d+(?:\.\d+)?\/\d+(?:\.\d+)?$/,qe=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,Je=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,Ye=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,Xe=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,Ze=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,Qe=e=>Ke.test(e),j=e=>!!e&&!Number.isNaN(Number(e)),$e=e=>!!e&&Number.isInteger(Number(e)),et=e=>e.endsWith(`%`)&&j(e.slice(0,-1)),tt=e=>qe.test(e),nt=()=>!0,rt=e=>Je.test(e)&&!Ye.test(e),it=()=>!1,at=e=>Xe.test(e),ot=e=>Ze.test(e),st=e=>!M(e)&&!N(e),ct=e=>e.startsWith(`@container`)&&(e[10]===`/`&&e[11]!==void 0||e[11]===`s`&&e[16]!==void 0&&e.startsWith(`-size/`,10)||e[11]===`n`&&e[18]!==void 0&&e.startsWith(`-normal/`,10)),lt=e=>St(e,Et,it),M=e=>We.test(e),ut=e=>St(e,Dt,rt),dt=e=>St(e,Ot,j),ft=e=>St(e,At,nt),pt=e=>St(e,kt,it),mt=e=>St(e,wt,it),ht=e=>St(e,Tt,ot),gt=e=>St(e,jt,at),N=e=>Ge.test(e),P=e=>Ct(e,Dt),F=e=>Ct(e,kt),_t=e=>Ct(e,wt),vt=e=>Ct(e,Et),yt=e=>Ct(e,Tt),bt=e=>Ct(e,jt,!0),xt=e=>Ct(e,At,!0),St=(e,t,n)=>{let r=We.exec(e);return r?r[1]?t(r[1]):n(r[2]):!1},Ct=(e,t,n=!1)=>{let r=Ge.exec(e);return r?r[1]?t(r[1]):n:!1},wt=e=>e===`position`||e===`percentage`,Tt=e=>e===`image`||e===`url`,Et=e=>e===`length`||e===`size`||e===`bg-size`,Dt=e=>e===`length`,Ot=e=>e===`number`,kt=e=>e===`family-name`,At=e=>e===`number`||e===`weight`,jt=e=>e===`shadow`,Mt=He(()=>{let e=A(`color`),t=A(`font`),n=A(`text`),r=A(`font-weight`),i=A(`tracking`),a=A(`leading`),o=A(`breakpoint`),s=A(`container`),c=A(`spacing`),l=A(`radius`),u=A(`shadow`),d=A(`inset-shadow`),f=A(`text-shadow`),p=A(`drop-shadow`),m=A(`blur`),h=A(`perspective`),g=A(`aspect`),_=A(`ease`),v=A(`animate`),y=()=>[`auto`,`avoid`,`all`,`avoid-page`,`page`,`left`,`right`,`column`],b=()=>[`center`,`top`,`bottom`,`left`,`right`,`top-left`,`left-top`,`top-right`,`right-top`,`bottom-right`,`right-bottom`,`bottom-left`,`left-bottom`],x=()=>[...b(),N,M],ee=()=>[`auto`,`hidden`,`clip`,`visible`,`scroll`],S=()=>[`auto`,`contain`,`none`],C=()=>[N,M,c],te=()=>[Qe,`full`,`auto`,...C()],ne=()=>[$e,`none`,`subgrid`,N,M],re=()=>[`auto`,{span:[`full`,$e,N,M]},$e,N,M],ie=()=>[$e,`auto`,N,M],ae=()=>[`auto`,`min`,`max`,`fr`,N,M],oe=()=>[`start`,`end`,`center`,`between`,`around`,`evenly`,`stretch`,`baseline`,`center-safe`,`end-safe`],se=()=>[`start`,`end`,`center`,`stretch`,`center-safe`,`end-safe`],w=()=>[`auto`,...C()],ce=()=>[Qe,`auto`,`full`,`dvw`,`dvh`,`lvw`,`lvh`,`svw`,`svh`,`min`,`max`,`fit`,...C()],le=()=>[Qe,`screen`,`full`,`dvw`,`lvw`,`svw`,`min`,`max`,`fit`,...C()],ue=()=>[Qe,`screen`,`full`,`lh`,`dvh`,`lvh`,`svh`,`min`,`max`,`fit`,...C()],T=()=>[e,N,M],E=()=>[...b(),_t,mt,{position:[N,M]}],de=()=>[`no-repeat`,{repeat:[``,`x`,`y`,`space`,`round`]}],fe=()=>[`auto`,`cover`,`contain`,vt,lt,{size:[N,M]}],pe=()=>[et,P,ut],D=()=>[``,`none`,`full`,l,N,M],O=()=>[``,j,P,ut],k=()=>[`solid`,`dashed`,`dotted`,`double`],me=()=>[`normal`,`multiply`,`screen`,`overlay`,`darken`,`lighten`,`color-dodge`,`color-burn`,`hard-light`,`soft-light`,`difference`,`exclusion`,`hue`,`saturation`,`color`,`luminosity`],he=()=>[j,et,_t,mt],ge=()=>[``,`none`,m,N,M],_e=()=>[`none`,j,N,M],ve=()=>[`none`,j,N,M],ye=()=>[j,N,M],be=()=>[Qe,`full`,...C()];return{cacheSize:500,theme:{animate:[`spin`,`ping`,`pulse`,`bounce`],aspect:[`video`],blur:[tt],breakpoint:[tt],color:[nt],container:[tt],"drop-shadow":[tt],ease:[`in`,`out`,`in-out`],font:[st],"font-weight":[`thin`,`extralight`,`light`,`normal`,`medium`,`semibold`,`bold`,`extrabold`,`black`],"inset-shadow":[tt],leading:[`none`,`tight`,`snug`,`normal`,`relaxed`,`loose`],perspective:[`dramatic`,`near`,`normal`,`midrange`,`distant`,`none`],radius:[tt],shadow:[tt],spacing:[`px`,j],text:[tt],"text-shadow":[tt],tracking:[`tighter`,`tight`,`normal`,`wide`,`wider`,`widest`]},classGroups:{aspect:[{aspect:[`auto`,`square`,Qe,M,N,g]}],container:[`container`],"container-type":[{"@container":[``,`normal`,`size`,N,M]}],"container-named":[ct],columns:[{columns:[j,M,N,s]}],"break-after":[{"break-after":y()}],"break-before":[{"break-before":y()}],"break-inside":[{"break-inside":[`auto`,`avoid`,`avoid-page`,`avoid-column`]}],"box-decoration":[{"box-decoration":[`slice`,`clone`]}],box:[{box:[`border`,`content`]}],display:[`block`,`inline-block`,`inline`,`flex`,`inline-flex`,`table`,`inline-table`,`table-caption`,`table-cell`,`table-column`,`table-column-group`,`table-footer-group`,`table-header-group`,`table-row-group`,`table-row`,`flow-root`,`grid`,`inline-grid`,`contents`,`list-item`,`hidden`],sr:[`sr-only`,`not-sr-only`],float:[{float:[`right`,`left`,`none`,`start`,`end`]}],clear:[{clear:[`left`,`right`,`both`,`none`,`start`,`end`]}],isolation:[`isolate`,`isolation-auto`],"object-fit":[{object:[`contain`,`cover`,`fill`,`none`,`scale-down`]}],"object-position":[{object:x()}],overflow:[{overflow:ee()}],"overflow-x":[{"overflow-x":ee()}],"overflow-y":[{"overflow-y":ee()}],overscroll:[{overscroll:S()}],"overscroll-x":[{"overscroll-x":S()}],"overscroll-y":[{"overscroll-y":S()}],position:[`static`,`fixed`,`absolute`,`relative`,`sticky`],inset:[{inset:te()}],"inset-x":[{"inset-x":te()}],"inset-y":[{"inset-y":te()}],start:[{"inset-s":te(),start:te()}],end:[{"inset-e":te(),end:te()}],"inset-bs":[{"inset-bs":te()}],"inset-be":[{"inset-be":te()}],top:[{top:te()}],right:[{right:te()}],bottom:[{bottom:te()}],left:[{left:te()}],visibility:[`visible`,`invisible`,`collapse`],z:[{z:[$e,`auto`,N,M]}],basis:[{basis:[Qe,`full`,`auto`,s,...C()]}],"flex-direction":[{flex:[`row`,`row-reverse`,`col`,`col-reverse`]}],"flex-wrap":[{flex:[`nowrap`,`wrap`,`wrap-reverse`]}],flex:[{flex:[j,Qe,`auto`,`initial`,`none`,M]}],grow:[{grow:[``,j,N,M]}],shrink:[{shrink:[``,j,N,M]}],order:[{order:[$e,`first`,`last`,`none`,N,M]}],"grid-cols":[{"grid-cols":ne()}],"col-start-end":[{col:re()}],"col-start":[{"col-start":ie()}],"col-end":[{"col-end":ie()}],"grid-rows":[{"grid-rows":ne()}],"row-start-end":[{row:re()}],"row-start":[{"row-start":ie()}],"row-end":[{"row-end":ie()}],"grid-flow":[{"grid-flow":[`row`,`col`,`dense`,`row-dense`,`col-dense`]}],"auto-cols":[{"auto-cols":ae()}],"auto-rows":[{"auto-rows":ae()}],gap:[{gap:C()}],"gap-x":[{"gap-x":C()}],"gap-y":[{"gap-y":C()}],"justify-content":[{justify:[...oe(),`normal`]}],"justify-items":[{"justify-items":[...se(),`normal`]}],"justify-self":[{"justify-self":[`auto`,...se()]}],"align-content":[{content:[`normal`,...oe()]}],"align-items":[{items:[...se(),{baseline:[``,`last`]}]}],"align-self":[{self:[`auto`,...se(),{baseline:[``,`last`]}]}],"place-content":[{"place-content":oe()}],"place-items":[{"place-items":[...se(),`baseline`]}],"place-self":[{"place-self":[`auto`,...se()]}],p:[{p:C()}],px:[{px:C()}],py:[{py:C()}],ps:[{ps:C()}],pe:[{pe:C()}],pbs:[{pbs:C()}],pbe:[{pbe:C()}],pt:[{pt:C()}],pr:[{pr:C()}],pb:[{pb:C()}],pl:[{pl:C()}],m:[{m:w()}],mx:[{mx:w()}],my:[{my:w()}],ms:[{ms:w()}],me:[{me:w()}],mbs:[{mbs:w()}],mbe:[{mbe:w()}],mt:[{mt:w()}],mr:[{mr:w()}],mb:[{mb:w()}],ml:[{ml:w()}],"space-x":[{"space-x":C()}],"space-x-reverse":[`space-x-reverse`],"space-y":[{"space-y":C()}],"space-y-reverse":[`space-y-reverse`],size:[{size:ce()}],"inline-size":[{inline:[`auto`,...le()]}],"min-inline-size":[{"min-inline":[`auto`,...le()]}],"max-inline-size":[{"max-inline":[`none`,...le()]}],"block-size":[{block:[`auto`,...ue()]}],"min-block-size":[{"min-block":[`auto`,...ue()]}],"max-block-size":[{"max-block":[`none`,...ue()]}],w:[{w:[s,`screen`,...ce()]}],"min-w":[{"min-w":[s,`screen`,`none`,...ce()]}],"max-w":[{"max-w":[s,`screen`,`none`,`prose`,{screen:[o]},...ce()]}],h:[{h:[`screen`,`lh`,...ce()]}],"min-h":[{"min-h":[`screen`,`lh`,`none`,...ce()]}],"max-h":[{"max-h":[`screen`,`lh`,...ce()]}],"font-size":[{text:[`base`,n,P,ut]}],"font-smoothing":[`antialiased`,`subpixel-antialiased`],"font-style":[`italic`,`not-italic`],"font-weight":[{font:[r,xt,ft]}],"font-stretch":[{"font-stretch":[`ultra-condensed`,`extra-condensed`,`condensed`,`semi-condensed`,`normal`,`semi-expanded`,`expanded`,`extra-expanded`,`ultra-expanded`,et,M]}],"font-family":[{font:[F,pt,t]}],"font-features":[{"font-features":[M]}],"fvn-normal":[`normal-nums`],"fvn-ordinal":[`ordinal`],"fvn-slashed-zero":[`slashed-zero`],"fvn-figure":[`lining-nums`,`oldstyle-nums`],"fvn-spacing":[`proportional-nums`,`tabular-nums`],"fvn-fraction":[`diagonal-fractions`,`stacked-fractions`],tracking:[{tracking:[i,N,M]}],"line-clamp":[{"line-clamp":[j,`none`,N,dt]}],leading:[{leading:[a,...C()]}],"list-image":[{"list-image":[`none`,N,M]}],"list-style-position":[{list:[`inside`,`outside`]}],"list-style-type":[{list:[`disc`,`decimal`,`none`,N,M]}],"text-alignment":[{text:[`left`,`center`,`right`,`justify`,`start`,`end`]}],"placeholder-color":[{placeholder:T()}],"text-color":[{text:T()}],"text-decoration":[`underline`,`overline`,`line-through`,`no-underline`],"text-decoration-style":[{decoration:[...k(),`wavy`]}],"text-decoration-thickness":[{decoration:[j,`from-font`,`auto`,N,ut]}],"text-decoration-color":[{decoration:T()}],"underline-offset":[{"underline-offset":[j,`auto`,N,M]}],"text-transform":[`uppercase`,`lowercase`,`capitalize`,`normal-case`],"text-overflow":[`truncate`,`text-ellipsis`,`text-clip`],"text-wrap":[{text:[`wrap`,`nowrap`,`balance`,`pretty`]}],indent:[{indent:C()}],"tab-size":[{tab:[$e,N,M]}],"vertical-align":[{align:[`baseline`,`top`,`middle`,`bottom`,`text-top`,`text-bottom`,`sub`,`super`,N,M]}],whitespace:[{whitespace:[`normal`,`nowrap`,`pre`,`pre-line`,`pre-wrap`,`break-spaces`]}],break:[{break:[`normal`,`words`,`all`,`keep`]}],wrap:[{wrap:[`break-word`,`anywhere`,`normal`]}],hyphens:[{hyphens:[`none`,`manual`,`auto`]}],content:[{content:[`none`,N,M]}],"bg-attachment":[{bg:[`fixed`,`local`,`scroll`]}],"bg-clip":[{"bg-clip":[`border`,`padding`,`content`,`text`]}],"bg-origin":[{"bg-origin":[`border`,`padding`,`content`]}],"bg-position":[{bg:E()}],"bg-repeat":[{bg:de()}],"bg-size":[{bg:fe()}],"bg-image":[{bg:[`none`,{linear:[{to:[`t`,`tr`,`r`,`br`,`b`,`bl`,`l`,`tl`]},$e,N,M],radial:[``,N,M],conic:[$e,N,M]},yt,ht]}],"bg-color":[{bg:T()}],"gradient-from-pos":[{from:pe()}],"gradient-via-pos":[{via:pe()}],"gradient-to-pos":[{to:pe()}],"gradient-from":[{from:T()}],"gradient-via":[{via:T()}],"gradient-to":[{to:T()}],rounded:[{rounded:D()}],"rounded-s":[{"rounded-s":D()}],"rounded-e":[{"rounded-e":D()}],"rounded-t":[{"rounded-t":D()}],"rounded-r":[{"rounded-r":D()}],"rounded-b":[{"rounded-b":D()}],"rounded-l":[{"rounded-l":D()}],"rounded-ss":[{"rounded-ss":D()}],"rounded-se":[{"rounded-se":D()}],"rounded-ee":[{"rounded-ee":D()}],"rounded-es":[{"rounded-es":D()}],"rounded-tl":[{"rounded-tl":D()}],"rounded-tr":[{"rounded-tr":D()}],"rounded-br":[{"rounded-br":D()}],"rounded-bl":[{"rounded-bl":D()}],"border-w":[{border:O()}],"border-w-x":[{"border-x":O()}],"border-w-y":[{"border-y":O()}],"border-w-s":[{"border-s":O()}],"border-w-e":[{"border-e":O()}],"border-w-bs":[{"border-bs":O()}],"border-w-be":[{"border-be":O()}],"border-w-t":[{"border-t":O()}],"border-w-r":[{"border-r":O()}],"border-w-b":[{"border-b":O()}],"border-w-l":[{"border-l":O()}],"divide-x":[{"divide-x":O()}],"divide-x-reverse":[`divide-x-reverse`],"divide-y":[{"divide-y":O()}],"divide-y-reverse":[`divide-y-reverse`],"border-style":[{border:[...k(),`hidden`,`none`]}],"divide-style":[{divide:[...k(),`hidden`,`none`]}],"border-color":[{border:T()}],"border-color-x":[{"border-x":T()}],"border-color-y":[{"border-y":T()}],"border-color-s":[{"border-s":T()}],"border-color-e":[{"border-e":T()}],"border-color-bs":[{"border-bs":T()}],"border-color-be":[{"border-be":T()}],"border-color-t":[{"border-t":T()}],"border-color-r":[{"border-r":T()}],"border-color-b":[{"border-b":T()}],"border-color-l":[{"border-l":T()}],"divide-color":[{divide:T()}],"outline-style":[{outline:[...k(),`none`,`hidden`]}],"outline-offset":[{"outline-offset":[j,N,M]}],"outline-w":[{outline:[``,j,P,ut]}],"outline-color":[{outline:T()}],shadow:[{shadow:[``,`none`,u,bt,gt]}],"shadow-color":[{shadow:T()}],"inset-shadow":[{"inset-shadow":[`none`,d,bt,gt]}],"inset-shadow-color":[{"inset-shadow":T()}],"ring-w":[{ring:O()}],"ring-w-inset":[`ring-inset`],"ring-color":[{ring:T()}],"ring-offset-w":[{"ring-offset":[j,ut]}],"ring-offset-color":[{"ring-offset":T()}],"inset-ring-w":[{"inset-ring":O()}],"inset-ring-color":[{"inset-ring":T()}],"text-shadow":[{"text-shadow":[`none`,f,bt,gt]}],"text-shadow-color":[{"text-shadow":T()}],opacity:[{opacity:[j,N,M]}],"mix-blend":[{"mix-blend":[...me(),`plus-darker`,`plus-lighter`]}],"bg-blend":[{"bg-blend":me()}],"mask-clip":[{"mask-clip":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]},`mask-no-clip`],"mask-composite":[{mask:[`add`,`subtract`,`intersect`,`exclude`]}],"mask-image-linear-pos":[{"mask-linear":[j]}],"mask-image-linear-from-pos":[{"mask-linear-from":he()}],"mask-image-linear-to-pos":[{"mask-linear-to":he()}],"mask-image-linear-from-color":[{"mask-linear-from":T()}],"mask-image-linear-to-color":[{"mask-linear-to":T()}],"mask-image-t-from-pos":[{"mask-t-from":he()}],"mask-image-t-to-pos":[{"mask-t-to":he()}],"mask-image-t-from-color":[{"mask-t-from":T()}],"mask-image-t-to-color":[{"mask-t-to":T()}],"mask-image-r-from-pos":[{"mask-r-from":he()}],"mask-image-r-to-pos":[{"mask-r-to":he()}],"mask-image-r-from-color":[{"mask-r-from":T()}],"mask-image-r-to-color":[{"mask-r-to":T()}],"mask-image-b-from-pos":[{"mask-b-from":he()}],"mask-image-b-to-pos":[{"mask-b-to":he()}],"mask-image-b-from-color":[{"mask-b-from":T()}],"mask-image-b-to-color":[{"mask-b-to":T()}],"mask-image-l-from-pos":[{"mask-l-from":he()}],"mask-image-l-to-pos":[{"mask-l-to":he()}],"mask-image-l-from-color":[{"mask-l-from":T()}],"mask-image-l-to-color":[{"mask-l-to":T()}],"mask-image-x-from-pos":[{"mask-x-from":he()}],"mask-image-x-to-pos":[{"mask-x-to":he()}],"mask-image-x-from-color":[{"mask-x-from":T()}],"mask-image-x-to-color":[{"mask-x-to":T()}],"mask-image-y-from-pos":[{"mask-y-from":he()}],"mask-image-y-to-pos":[{"mask-y-to":he()}],"mask-image-y-from-color":[{"mask-y-from":T()}],"mask-image-y-to-color":[{"mask-y-to":T()}],"mask-image-radial":[{"mask-radial":[N,M]}],"mask-image-radial-from-pos":[{"mask-radial-from":he()}],"mask-image-radial-to-pos":[{"mask-radial-to":he()}],"mask-image-radial-from-color":[{"mask-radial-from":T()}],"mask-image-radial-to-color":[{"mask-radial-to":T()}],"mask-image-radial-shape":[{"mask-radial":[`circle`,`ellipse`]}],"mask-image-radial-size":[{"mask-radial":[{closest:[`side`,`corner`],farthest:[`side`,`corner`]}]}],"mask-image-radial-pos":[{"mask-radial-at":b()}],"mask-image-conic-pos":[{"mask-conic":[j]}],"mask-image-conic-from-pos":[{"mask-conic-from":he()}],"mask-image-conic-to-pos":[{"mask-conic-to":he()}],"mask-image-conic-from-color":[{"mask-conic-from":T()}],"mask-image-conic-to-color":[{"mask-conic-to":T()}],"mask-mode":[{mask:[`alpha`,`luminance`,`match`]}],"mask-origin":[{"mask-origin":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]}],"mask-position":[{mask:E()}],"mask-repeat":[{mask:de()}],"mask-size":[{mask:fe()}],"mask-type":[{"mask-type":[`alpha`,`luminance`]}],"mask-image":[{mask:[`none`,N,M]}],filter:[{filter:[``,`none`,N,M]}],blur:[{blur:ge()}],brightness:[{brightness:[j,N,M]}],contrast:[{contrast:[j,N,M]}],"drop-shadow":[{"drop-shadow":[``,`none`,p,bt,gt]}],"drop-shadow-color":[{"drop-shadow":T()}],grayscale:[{grayscale:[``,j,N,M]}],"hue-rotate":[{"hue-rotate":[j,N,M]}],invert:[{invert:[``,j,N,M]}],saturate:[{saturate:[j,N,M]}],sepia:[{sepia:[``,j,N,M]}],"backdrop-filter":[{"backdrop-filter":[``,`none`,N,M]}],"backdrop-blur":[{"backdrop-blur":ge()}],"backdrop-brightness":[{"backdrop-brightness":[j,N,M]}],"backdrop-contrast":[{"backdrop-contrast":[j,N,M]}],"backdrop-grayscale":[{"backdrop-grayscale":[``,j,N,M]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[j,N,M]}],"backdrop-invert":[{"backdrop-invert":[``,j,N,M]}],"backdrop-opacity":[{"backdrop-opacity":[j,N,M]}],"backdrop-saturate":[{"backdrop-saturate":[j,N,M]}],"backdrop-sepia":[{"backdrop-sepia":[``,j,N,M]}],"border-collapse":[{border:[`collapse`,`separate`]}],"border-spacing":[{"border-spacing":C()}],"border-spacing-x":[{"border-spacing-x":C()}],"border-spacing-y":[{"border-spacing-y":C()}],"table-layout":[{table:[`auto`,`fixed`]}],caption:[{caption:[`top`,`bottom`]}],transition:[{transition:[``,`all`,`colors`,`opacity`,`shadow`,`transform`,`none`,N,M]}],"transition-behavior":[{transition:[`normal`,`discrete`]}],duration:[{duration:[j,`initial`,N,M]}],ease:[{ease:[`linear`,`initial`,_,N,M]}],delay:[{delay:[j,N,M]}],animate:[{animate:[`none`,v,N,M]}],backface:[{backface:[`hidden`,`visible`]}],perspective:[{perspective:[h,N,M]}],"perspective-origin":[{"perspective-origin":x()}],rotate:[{rotate:_e()}],"rotate-x":[{"rotate-x":_e()}],"rotate-y":[{"rotate-y":_e()}],"rotate-z":[{"rotate-z":_e()}],scale:[{scale:ve()}],"scale-x":[{"scale-x":ve()}],"scale-y":[{"scale-y":ve()}],"scale-z":[{"scale-z":ve()}],"scale-3d":[`scale-3d`],skew:[{skew:ye()}],"skew-x":[{"skew-x":ye()}],"skew-y":[{"skew-y":ye()}],transform:[{transform:[N,M,``,`none`,`gpu`,`cpu`]}],"transform-origin":[{origin:x()}],"transform-style":[{transform:[`3d`,`flat`]}],translate:[{translate:be()}],"translate-x":[{"translate-x":be()}],"translate-y":[{"translate-y":be()}],"translate-z":[{"translate-z":be()}],"translate-none":[`translate-none`],zoom:[{zoom:[$e,N,M]}],accent:[{accent:T()}],appearance:[{appearance:[`none`,`auto`]}],"caret-color":[{caret:T()}],"color-scheme":[{scheme:[`normal`,`dark`,`light`,`light-dark`,`only-dark`,`only-light`]}],cursor:[{cursor:[`auto`,`default`,`pointer`,`wait`,`text`,`move`,`help`,`not-allowed`,`none`,`context-menu`,`progress`,`cell`,`crosshair`,`vertical-text`,`alias`,`copy`,`no-drop`,`grab`,`grabbing`,`all-scroll`,`col-resize`,`row-resize`,`n-resize`,`e-resize`,`s-resize`,`w-resize`,`ne-resize`,`nw-resize`,`se-resize`,`sw-resize`,`ew-resize`,`ns-resize`,`nesw-resize`,`nwse-resize`,`zoom-in`,`zoom-out`,N,M]}],"field-sizing":[{"field-sizing":[`fixed`,`content`]}],"pointer-events":[{"pointer-events":[`auto`,`none`]}],resize:[{resize:[`none`,``,`y`,`x`]}],"scroll-behavior":[{scroll:[`auto`,`smooth`]}],"scrollbar-thumb-color":[{"scrollbar-thumb":T()}],"scrollbar-track-color":[{"scrollbar-track":T()}],"scrollbar-gutter":[{"scrollbar-gutter":[`auto`,`stable`,`both`]}],"scrollbar-w":[{scrollbar:[`auto`,`thin`,`none`]}],"scroll-m":[{"scroll-m":C()}],"scroll-mx":[{"scroll-mx":C()}],"scroll-my":[{"scroll-my":C()}],"scroll-ms":[{"scroll-ms":C()}],"scroll-me":[{"scroll-me":C()}],"scroll-mbs":[{"scroll-mbs":C()}],"scroll-mbe":[{"scroll-mbe":C()}],"scroll-mt":[{"scroll-mt":C()}],"scroll-mr":[{"scroll-mr":C()}],"scroll-mb":[{"scroll-mb":C()}],"scroll-ml":[{"scroll-ml":C()}],"scroll-p":[{"scroll-p":C()}],"scroll-px":[{"scroll-px":C()}],"scroll-py":[{"scroll-py":C()}],"scroll-ps":[{"scroll-ps":C()}],"scroll-pe":[{"scroll-pe":C()}],"scroll-pbs":[{"scroll-pbs":C()}],"scroll-pbe":[{"scroll-pbe":C()}],"scroll-pt":[{"scroll-pt":C()}],"scroll-pr":[{"scroll-pr":C()}],"scroll-pb":[{"scroll-pb":C()}],"scroll-pl":[{"scroll-pl":C()}],"snap-align":[{snap:[`start`,`end`,`center`,`align-none`]}],"snap-stop":[{snap:[`normal`,`always`]}],"snap-type":[{snap:[`none`,`x`,`y`,`both`]}],"snap-strictness":[{snap:[`mandatory`,`proximity`]}],touch:[{touch:[`auto`,`none`,`manipulation`]}],"touch-x":[{"touch-pan":[`x`,`left`,`right`]}],"touch-y":[{"touch-pan":[`y`,`up`,`down`]}],"touch-pz":[`touch-pinch-zoom`],select:[{select:[`none`,`text`,`all`,`auto`]}],"will-change":[{"will-change":[`auto`,`scroll`,`contents`,`transform`,N,M]}],fill:[{fill:[`none`,...T()]}],"stroke-w":[{stroke:[j,P,ut,dt]}],stroke:[{stroke:[`none`,...T()]}],"forced-color-adjust":[{"forced-color-adjust":[`auto`,`none`]}]},conflictingClassGroups:{"container-named":[`container-type`],overflow:[`overflow-x`,`overflow-y`],overscroll:[`overscroll-x`,`overscroll-y`],inset:[`inset-x`,`inset-y`,`inset-bs`,`inset-be`,`start`,`end`,`top`,`right`,`bottom`,`left`],"inset-x":[`right`,`left`],"inset-y":[`top`,`bottom`],flex:[`basis`,`grow`,`shrink`],gap:[`gap-x`,`gap-y`],p:[`px`,`py`,`ps`,`pe`,`pbs`,`pbe`,`pt`,`pr`,`pb`,`pl`],px:[`pr`,`pl`],py:[`pt`,`pb`],m:[`mx`,`my`,`ms`,`me`,`mbs`,`mbe`,`mt`,`mr`,`mb`,`ml`],mx:[`mr`,`ml`],my:[`mt`,`mb`],size:[`w`,`h`],"font-size":[`leading`],"fvn-normal":[`fvn-ordinal`,`fvn-slashed-zero`,`fvn-figure`,`fvn-spacing`,`fvn-fraction`],"fvn-ordinal":[`fvn-normal`],"fvn-slashed-zero":[`fvn-normal`],"fvn-figure":[`fvn-normal`],"fvn-spacing":[`fvn-normal`],"fvn-fraction":[`fvn-normal`],"line-clamp":[`display`,`overflow`],rounded:[`rounded-s`,`rounded-e`,`rounded-t`,`rounded-r`,`rounded-b`,`rounded-l`,`rounded-ss`,`rounded-se`,`rounded-ee`,`rounded-es`,`rounded-tl`,`rounded-tr`,`rounded-br`,`rounded-bl`],"rounded-s":[`rounded-ss`,`rounded-es`],"rounded-e":[`rounded-se`,`rounded-ee`],"rounded-t":[`rounded-tl`,`rounded-tr`],"rounded-r":[`rounded-tr`,`rounded-br`],"rounded-b":[`rounded-br`,`rounded-bl`],"rounded-l":[`rounded-tl`,`rounded-bl`],"border-spacing":[`border-spacing-x`,`border-spacing-y`],"border-w":[`border-w-x`,`border-w-y`,`border-w-s`,`border-w-e`,`border-w-bs`,`border-w-be`,`border-w-t`,`border-w-r`,`border-w-b`,`border-w-l`],"border-w-x":[`border-w-r`,`border-w-l`],"border-w-y":[`border-w-t`,`border-w-b`],"border-color":[`border-color-x`,`border-color-y`,`border-color-s`,`border-color-e`,`border-color-bs`,`border-color-be`,`border-color-t`,`border-color-r`,`border-color-b`,`border-color-l`],"border-color-x":[`border-color-r`,`border-color-l`],"border-color-y":[`border-color-t`,`border-color-b`],translate:[`translate-x`,`translate-y`,`translate-none`],"translate-none":[`translate`,`translate-x`,`translate-y`,`translate-z`],"scroll-m":[`scroll-mx`,`scroll-my`,`scroll-ms`,`scroll-me`,`scroll-mbs`,`scroll-mbe`,`scroll-mt`,`scroll-mr`,`scroll-mb`,`scroll-ml`],"scroll-mx":[`scroll-mr`,`scroll-ml`],"scroll-my":[`scroll-mt`,`scroll-mb`],"scroll-p":[`scroll-px`,`scroll-py`,`scroll-ps`,`scroll-pe`,`scroll-pbs`,`scroll-pbe`,`scroll-pt`,`scroll-pr`,`scroll-pb`,`scroll-pl`],"scroll-px":[`scroll-pr`,`scroll-pl`],"scroll-py":[`scroll-pt`,`scroll-pb`],touch:[`touch-x`,`touch-y`,`touch-pz`],"touch-x":[`touch`],"touch-y":[`touch`],"touch-pz":[`touch`]},conflictingClassGroupModifiers:{"font-size":[`leading`]},postfixLookupClassGroups:[`container-type`],orderSensitiveModifiers:[`*`,`**`,`after`,`backdrop`,`before`,`details-content`,`file`,`first-letter`,`first-line`,`marker`,`placeholder`,`selection`]}});function I(...e){return Mt(E(e))}var Nt=e((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),L=e(((e,t)=>{t.exports=Nt()}))(),Pt=pe(`inline-flex min-h-[22px] items-center rounded-full border px-2 py-0.5 text-[11px] font-extrabold uppercase leading-tight`,{variants:{variant:{neutral:`border-border bg-secondary text-muted-foreground`,succeeded:`border-emerald-400/35 bg-emerald-500/15 text-emerald-300`,failed:`border-red-400/40 bg-red-500/15 text-red-300`,dead:`border-red-400/40 bg-red-500/15 text-red-300`,missing:`border-red-400/40 bg-red-500/15 text-red-300`,running:`border-amber-400/40 bg-amber-500/15 text-amber-300`,queued:`border-teal-400/40 bg-teal-500/15 text-teal-200`,canceled:`border-border bg-secondary text-muted-foreground`}},defaultVariants:{variant:`neutral`}});function R({className:e,variant:t,...n}){return(0,L.jsx)(`span`,{"data-slot":`badge`,className:I(Pt({variant:t,className:e})),...n})}var Ft=pe(`inline-flex min-h-9 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md border text-sm font-semibold transition-colors focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0`,{variants:{variant:{default:`border-primary bg-primary text-primary-foreground hover:bg-primary/90`,secondary:`border-border bg-secondary text-secondary-foreground hover:bg-secondary/80`,outline:`border-border bg-background hover:bg-accent hover:text-accent-foreground`,ghost:`border-transparent hover:bg-accent hover:text-accent-foreground`,destructive:`border-destructive bg-destructive text-white hover:bg-destructive/90`},size:{default:`h-9 px-4 py-2`,sm:`h-8 rounded-md px-3 text-xs`,icon:`size-9`}},defaultVariants:{variant:`secondary`,size:`default`}});function z({className:e,variant:t,size:n,type:r=`button`,...i}){return(0,L.jsx)(`button`,{"data-slot":`button`,type:r,className:I(Ft({variant:t,size:n,className:e})),...i})}function It({className:e,...t}){return(0,L.jsx)(`div`,{"data-slot":`card`,className:I(`rounded-lg border bg-card text-card-foreground shadow-[0_18px_44px_rgb(0_0_0/0.22)]`,e),...t})}function B({className:e,...t}){return(0,L.jsx)(`div`,{"data-slot":`card-header`,className:I(`flex items-center justify-between gap-3 border-b px-4 py-3`,e),...t})}function Lt({className:e,...t}){return(0,L.jsx)(`h2`,{"data-slot":`card-title`,className:I(`text-[15px] font-bold`,e),...t})}function Rt({className:e,...t}){return(0,L.jsx)(`div`,{"data-slot":`card-content`,className:I(`p-4`,e),...t})}function V({className:e,type:t,...n}){return(0,L.jsx)(`input`,{"data-slot":`input`,type:t,className:I(`flex min-h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50`,e),...n})}function H({className:e,...t}){return(0,L.jsx)(`label`,{"data-slot":`label`,className:I(`grid gap-1.5 text-xs font-bold text-muted-foreground`,e),...t})}function zt({className:e,...t}){return(0,L.jsx)(`select`,{"data-slot":`select`,className:I(`flex min-h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs transition-colors focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50`,e),...t})}function Bt({className:e,...t}){return(0,L.jsx)(`table`,{"data-slot":`table`,className:I(`w-full border-collapse text-sm`,e),...t})}function Vt({className:e,...t}){return(0,L.jsx)(`thead`,{"data-slot":`table-header`,className:e,...t})}function Ht({className:e,...t}){return(0,L.jsx)(`tbody`,{"data-slot":`table-body`,className:e,...t})}function Ut({className:e,...t}){return(0,L.jsx)(`tr`,{"data-slot":`table-row`,className:I(`border-b transition-colors hover:bg-muted/45`,e),...t})}function U({className:e,...t}){return(0,L.jsx)(`th`,{"data-slot":`table-head`,className:I(`bg-secondary px-3 py-3 text-left align-middle text-xs font-extrabold text-muted-foreground`,e),...t})}function W({className:e,...t}){return(0,L.jsx)(`td`,{"data-slot":`table-cell`,className:I(`px-3 py-3 align-middle text-sm`,e),...t})}var Wt={pending:`Needs review`,selected:`Assigned to onboarder`,reachingout:`Reaching out`,awaitingcontribution:`Awaiting contribution`,onboarded:`Onboarded`,waitlist:`Waitlist`,rejected:`Rejected`};function Gt(e){if(!e)return``;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString(void 0,{year:`numeric`,month:`short`,day:`numeric`,hour:`2-digit`,minute:`2-digit`})}function Kt(e,t=new Date){if(!e)return null;let n=new Date(e);if(Number.isNaN(n.getTime()))return null;let r=t.getTime()-n.getTime();return r<0?0:Math.floor(r/864e5)}function qt(e){return e==null?``:JSON.stringify(e,null,2)}function Jt(e){return e.onboarding_state||e.onboardingState||e.cOnboardingState||``}function Yt(e){let t=String(e||``).trim();if(!t)return`No status`;let n=t.toLowerCase();return Wt[n]?Wt[n]:t.replace(/[-_]+/g,` `).replace(/\s+/g,` `).trim().replace(/\b\w/g,e=>e.toUpperCase())}function Xt(e){let t=String(e||``).trim().toLowerCase();return!t||t===`pending`?`neutral`:t===`selected`?`queued`:t===`rejected`?`failed`:t===`onboarded`?`succeeded`:t===`waitlist`?`running`:`queued`}function Zt(e){let t=String(e||``).trim();return!t||t.toLowerCase()===`none`?``:t}function Qt(e){let t=String(e||``).trim();return t?/^https?:\/\//i.test(t)?t:`https://${t.replace(/^\/+/,``)}`:``}function $t(e){try{return new URL(Qt(e))}catch{return null}}function en(e,t){let n=e.toLowerCase();return n===t||n.endsWith(`.${t}`)}function tn(e){return e.split(`/`).filter(Boolean).map(e=>encodeURIComponent(e)).join(`/`)}function nn(e){let t=String(e||``).trim();if(!t)return``;let n=$t(t);if(n&&en(n.hostname,`linkedin.com`))return n.href;if(/^https?:\/\//i.test(t))return``;let r=t.replace(/^@/,``).replace(/^\/+|\/+$/g,``).replace(/^in\//i,``);return r?`https://www.linkedin.com/in/${tn(r)}`:``}function rn(e){let t=String(e||``).trim().replace(/^@/,``);if(!t)return``;let n=$t(t);if(n&&en(n.hostname,`github.com`))return n.href;if(/^https?:\/\//i.test(t))return``;let r=t.replace(/^\/+|\/+$/g,``);return r?`https://github.com/${tn(r)}`:``}var an={people:`/dashboard/people`,gigs:`/dashboard/gigs`,projects:`/dashboard/projects`,onboarding:`/dashboard/onboarding`,jobs:`/dashboard/jobs`,agent:`/dashboard/agent`,audit:`/dashboard/audit`},on={people:`people:read`,gigs:`gigs:read`,projects:`projects:read`,onboarding:`onboarding:read`,jobs:`jobs:read`,agent:`audit:read`,audit:`audit:read`},sn={discord:{label:`Discord`,options:[[`linked`,`Linked`],[`missing`,`Missing`]]},email_508:{label:`508 email`,options:[[`present`,`Present`],[`missing`,`Missing`]]},resume:{label:`Resume`,options:[[`present`,`Present`],[`missing`,`Missing`]]},skills:{label:`Skills`,options:[[`present`,`Parsed`],[`missing`,`Not parsed`]]},sync_status:{label:`Sync status`,options:[[`active`,`Active`],[`conflict`,`Conflict`],[`missing_in_crm`,`Missing in CRM`]]}},cn=[[`pending`,`Needs review`],[`selected`,`Assigned to onboarder`],[`reachingout`,`Reaching out`],[`awaitingcontribution`,`Awaiting contribution`],[`onboarded`,`Onboarded`],[`waitlist`,`Waitlist`],[`rejected`,`Rejected`]],ln=cn.slice(0,4),un=new Set([`onboarded`,`waitlist`,`rejected`]);function dn(e){return String(e||``).trim().toLowerCase().replace(/[-_\s]+/g,``)}var fn=class extends Error{status;statusText;payload;url;method;constructor(e,t,n,r,i,a){super(e),this.name=`ApiRequestError`,this.status=t,this.statusText=n,this.payload=r,this.url=i,this.method=a}};function pn(e,t){let n=e.detail;if(typeof n==`string`&&n.trim())return n;let r=e.error;return typeof r==`string`?r===`person_not_found`?`No CRM person, ERPNext user, or ERPNext supplier matched "${typeof e.person==`string`&&e.person.trim()?e.person:`that person`}". Try an email address or an exact name from CRM/ERPNext.`:r===`candidate_not_found`?`The selected person record is no longer available. Search again and choose one of the current matches.`:r===`invalid_crm_profile`?`Paste a valid CRM Contact profile URL or Contact id.`:r===`crm_profile_not_found`?`That CRM Contact profile was not found.`:r===`crm_profile_mismatch`?`CRM returned a different Contact than the profile requested. Check the profile URL and try again.`:r===`crm_profile_lookup_failed`?`CRM profile lookup failed. Try again after CRM is reachable.`:r===`ambiguous_person`?`Multiple people matched. Choose the matching person record.`:r||t:t}function mn(e,t){return typeof e==`string`&&e.trim()?e:e instanceof Error&&e.message.trim()?e.message:t}function hn(){return window.location.pathname.split(`/`).filter(Boolean)[1]||``}function gn(){let e=hn();return Object.hasOwn(an,e)?e:`people`}function _n(e=`gigs`){let[,t,n]=window.location.pathname.split(`/`).filter(Boolean);if(t!==e||!n)return``;try{return decodeURIComponent(n)}catch{return``}}async function G(e,t={}){let n=String(t.method||`GET`).toUpperCase(),r=new Headers(t.headers);r.set(`Accept`,`application/json`);let i;try{i=await fetch(e,{credentials:`same-origin`,...t,headers:r})}catch(t){throw new fn(mn(t,`Network request failed`),0,`Network request failed`,null,e,n)}if(i.status===401){let t=`${window.location.pathname}${window.location.search}`||`/dashboard`;throw window.location.assign(`/auth/login?next=${encodeURIComponent(t)}`),new fn(`Session expired`,i.status,i.statusText,null,e,n)}if(!i.ok){let t=i.statusText,r=null;try{r=await i.json(),r&&typeof r==`object`&&(t=pn(r,String(t||`Request failed`)))}catch{t=i.statusText}throw new fn(typeof t==`string`?t:JSON.stringify(t),i.status,i.statusText,r,e,n)}return i.json()}function vn(e,t,n){if(e===`gigs`){let e=t;if(n===`title`)return e.title||``;if(n===`status`)return e.status||``;if(n===`applications`)return Number(e.application_count||0);if(n===`activity`)return Nn(e)}if(e===`projects`){let e=t;if(n===`display_name`)return e.display_name||``;if(n===`customer`)return e.customer||``;if(n===`status`)return e.source_status||``;if(n===`roster_count`)return Number(e.roster_count||0);if(n===`modified`)return e.source_modified_at||e.last_synced_at||``}if(e===`onboarding`){let e=t,r=e.profile_status||{};if(n===`name`)return e.name||e.email_508||e.email||``;if(n===`onboarding_state`){let t=Jt(e);return t.toLowerCase()===`pending`?`zzz-${t}`:t}if(n===`onboarder`)return e.onboarder||``;if(n===`updated`)return e.onboarding_updated_at||``;if(n===`profile_gaps`)return[!r.discord_linked,!r.latest_resume,Number(r.skills_count||0)<=0].filter(Boolean).length}if(e===`people`){let e=t,r=e.profile_status||{};if(n===`name`)return e.name||e.email_508||e.email||``;if(n===`status`)return[r.crm_active,r.is_member,r.discord_linked,r.email_508,r.latest_resume].filter(Boolean).length;if(n===`discord`)return e.discord_username||e.discord_user_id||``;if(n===`resume`)return e.latest_resume_name||e.latest_resume_id||``}if(e===`audit`){let e=t;if(n===`actor`)return e.actor_display_name||e.actor_subject||e.actor_provider||``}return t[n]??``}function yn(e,t,n){let r=n.direction===`asc`?1:-1;return[...t].sort((t,i)=>{let a=vn(e,t,n.key),o=vn(e,i,n.key);return typeof a==`number`&&typeof o==`number`?(a-o)*r:String(a).localeCompare(String(o),void 0,{numeric:!0})*r})}function bn({label:e,scope:t,sort:n,sortKey:r,onSort:i}){let a=n.key===r,o=n.direction===`asc`?`↑`:`↓`;return(0,L.jsx)(`button`,{type:`button`,"data-sort-scope":t,"data-sort-key":r,className:`text-left font-[inherit] text-inherit hover:text-foreground`,onClick:()=>i(t,r),children:a?`${e} ${o}`:e})}function xn({className:e,label:t,scope:n,sort:r,sortKey:i,onSort:a}){return(0,L.jsx)(U,{className:e,"aria-sort":r.key===i?r.direction===`asc`?`ascending`:`descending`:`none`,children:(0,L.jsx)(bn,{label:t,scope:n,sort:r,sortKey:i,onSort:a})})}function Sn({label:e,value:t,id:n}){return(0,L.jsxs)(It,{className:`p-4`,children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:e}),(0,L.jsx)(`strong`,{id:n,className:`block text-2xl`,children:t})]})}function Cn({children:e,hidden:t}){return t?null:(0,L.jsx)(`div`,{className:`px-4 py-7 text-center text-sm text-muted-foreground`,children:e})}function wn({value:e,query:t}){let n=t.trim().toLowerCase();if(!n)return(0,L.jsx)(L.Fragment,{children:e});let r=e.toLowerCase(),i=[],a=0,o=r.indexOf(n);for(;o>=0;){o>a&&i.push(e.slice(a,o));let t=o+n.length;i.push((0,L.jsx)(`mark`,{className:`rounded-sm bg-amber-200 px-0.5 text-inherit dark:bg-amber-500/35`,children:e.slice(o,t)},`${o}-${t}`)),a=t,o=r.indexOf(n,a)}return avoid 0);function ft(e){return s.includes(e)}function pt(e){return s.includes(`${e}:dry_run`)}function mt(e){return ft(e)||pt(e)}function ht(e){return ft(on[e])}function gt(){return Object.keys(an).find(e=>ht(e))||`people`}function N(e,t){o({message:e,tone:t})}function P(e,t){N(mn(e,t),`error`)}function F(e,t){xe(n=>({...n,[e]:t}))}function _t(e,t=!1){let n=e;ht(n)||(N(`${n[0].toUpperCase()}${n.slice(1)} requires SSO validation`,`error`),n=gt()),n!==`gigs`&&ce(``),n!==`projects`&&ue(``),n===`gigs`&&t&&ce(``),n===`projects`&&t&&ue(``),i(n),t?window.history.pushState({view:n},``,an[n]):(!Object.hasOwn(an,hn())||n!==e)&&window.history.replaceState({view:n},``,an[n])}dt.current=_t;function vt(e){return!u||!e?``:`${u}/#Contact/view/${encodeURIComponent(e)}`}function yt(e){return!u||!e?``:`${u}/api/v1/Attachment/file/${encodeURIComponent(e)}`}function bt(e,t){De(n=>{let r=n[e];return{...n,[e]:{key:t,direction:r.key===t&&r.direction===`asc`?`desc`:`asc`}}})}function xt(e){ce(e),se(m.find(t=>t.id===e)||null),i(`gigs`),window.history.pushState({view:`gigs`,gigId:e},``,`/dashboard/gigs/${encodeURIComponent(e)}`)}function St(){ce(``),se(null),window.history.replaceState({view:`gigs`},``,an.gigs)}function Ct(e){ue(e),i(`projects`),window.history.pushState({view:`projects`,projectId:e},``,`/dashboard/projects/${encodeURIComponent(e)}`)}function wt(){ue(``),window.history.replaceState({view:`projects`},``,an.projects)}async function Tt(){let e=await G(`/dashboard/api/me`);n(e);let t=Array.isArray(e.permissions)?e.permissions:[];return c(t),d((e.crm_base_url||``).replace(/\/+$/,``)),t}function Et(){let e=new URLSearchParams({minutes:Oe,limit:`100`});return Ae&&e.set(`status`,Ae),Me.trim()&&e.set(`type`,Me.trim()),`/dashboard/api/jobs?${e.toString()}`}function Dt(){let e=new URLSearchParams({limit:String(Re)});return Pe&&e.set(`status`,Pe),Ie&&e.set(`include_historical`,`true`),`/dashboard/api/gigs?${e.toString()}`}function Ot(){let e=new URLSearchParams({limit:`100`,status:He});return Be.trim()&&e.set(`query`,Be.trim()),`/dashboard/api/projects?${e.toString()}`}async function kt(){F(`jobs`,!0),N(`Loading jobs`);try{let e=await G(Et());p(e),N(`Loaded ${e.length} jobs`,`ok`)}catch(e){P(e,`Unable to load jobs`)}finally{F(`jobs`,!1)}}async function At(){F(`gigs`,!0);try{let e=await G(Dt());v(e),N(`Loaded ${e.length} gig${e.length===1?``:`s`}`,`ok`),U()}catch(e){P(e,`Unable to load gigs`)}finally{F(`gigs`,!1)}}async function jt(){F(`projects`,!0);try{let e=await G(Ot());S(e.projects||[]),ne(e.summary||{}),N(`Loaded ${(e.projects||[]).length} project${(e.projects||[]).length===1?``:`s`}`,`ok`)}catch(e){P(e,`Unable to load projects`)}finally{F(`projects`,!1)}}async function Mt(){F(`syncProjects`,!0),N(`Queueing project sync`);try{let e=await G(`/dashboard/api/sync/projects`,{method:`POST`});e.dry_run?N(`Dry run only: would queue ${e.would_enqueue?.job_type||`project sync`}`,`warning`):N(`Queued project sync ${e.job_id}`,`ok`)}catch(e){P(e,`Unable to queue project sync`)}finally{F(`syncProjects`,!1)}}async function Nt(e){let t=e.trim();if(t.length<2)return[];try{return(await G(`/dashboard/api/erpnext/customers?${new URLSearchParams({query:t}).toString()}`)).customers||[]}catch(e){return N(e instanceof Error?e.message:`Unable to search customers`,`error`),[]}}async function Pt(e){let t=e.trim();if(t.length<2)return[];try{return(await G(`/dashboard/api/erpnext/contacts?${new URLSearchParams({query:t}).toString()}`)).contacts||[]}catch(e){return N(e instanceof Error?e.message:`Unable to search contacts`,`error`),[]}}async function R(e){let t=e.trim();if(t.length<2)return[];try{return(await G(`/dashboard/api/erpnext/account-managers?${new URLSearchParams({query:t}).toString()}`)).users||[]}catch(e){return N(e instanceof Error?e.message:`Unable to search account managers`,`error`),[]}}async function Ft(){try{let e=(await G(`/dashboard/api/erpnext/cost-centers`)).cost_centers||[];return e.length?e:[{name:`Projects - 5`,cost_center_name:`Projects`}]}catch(e){return N(e instanceof Error?e.message:`Unable to load cost centers`,`error`),[{name:`Projects - 5`,cost_center_name:`Projects`}]}}async function It(e){F(`createProject`,!0);try{let t=await G(`/dashboard/api/projects/create`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(e)});return t.project.id?(S(e=>e.some(e=>e.id===t.project.id)?e.map(e=>e.id===t.project.id?t.project:e):[t.project,...e]),N(t.setup_warnings?.length?t.setup_warning_message||`Created ERP project setup; account manager setup needs follow-up`:`Created ERP project setup`,t.setup_warnings?.length?`warning`:`ok`),Ct(t.project.id)):(N([t.cache_refresh_message||`Created ERP project in ERPNext; local sync is pending`,t.setup_warnings?.length?t.setup_warning_message||`Account manager setup needs follow-up`:``].filter(Boolean).join(` `),t.setup_warnings?.length?`warning`:`ok`),jt()),!0}catch(e){return N(e instanceof Error?e.message:`Unable to create project`,`error`),!1}finally{F(`createProject`,!1)}}async function B(e,t){F(`project:${e}:status`,!0);try{let n=await G(`/dashboard/api/projects/${encodeURIComponent(e)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t})});S(t=>t.map(t=>t.id===e?n.project:t)),N(`Updated project status`,`ok`)}catch(e){P(e,`Unable to update project`)}finally{F(`project:${e}:status`,!1)}}async function Lt(e,t){if(e.length===0)return!1;F(`projectsBulkUpdate`,!0);try{let n=await G(`/dashboard/api/projects/bulk`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({project_ids:e,...t})}),r=n.projects||[];S(e=>e.map(e=>r.find(t=>t.id===e.id)||e));let i=n.failures||[];return N(i.length?`Updated ${r.length}; ${i.length} failed`:`Updated ${r.length} project${r.length===1?``:`s`}`,i.length?`error`:`ok`),i.length===0}catch(e){return P(e,`Unable to bulk update projects`),!1}finally{F(`projectsBulkUpdate`,!1)}}async function Rt(e,t,n,r){let i=t.trim(),a=n.trim();if(!i||!a)return!1;F(`project:${e}:user`,!0);try{let t=await G(`/dashboard/api/projects/${encodeURIComponent(e)}/users`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({user:i,candidate_id:a,...r||{}})});return S(n=>n.map(n=>n.id===e?t.project:n)),N(t.activity_cost_error?`Added project user; rate failed`:t.activity_cost?`Added project user and rate`:`Added project user`,t.activity_cost_error?`error`:`ok`),!0}catch(e){return P(e,`Unable to add project user`),!1}finally{F(`project:${e}:user`,!1)}}async function V(e,t){let n=t.trim();if(!n)return!1;F(`project:${e}:user`,!0);try{let t=await G(`/dashboard/api/projects/${encodeURIComponent(e)}/users/remove`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({user:n})});return S(n=>n.map(n=>n.id===e?t.project:n)),N(`Removed project user`,`ok`),!0}catch(e){return N(e instanceof Error?e.message:`Unable to remove project user`,`error`),!1}finally{F(`project:${e}:user`,!1)}}async function H(e,t,n){let r=t.trim();if(!r)return!1;F(`project:${e}:historical`,!0);try{let t=await G(`/dashboard/api/projects/${encodeURIComponent(e)}/historical-members`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({person:r,candidate_id:n})});return S(n=>n.map(n=>n.id===e?t.project:n)),Te(null),N(`Added historical project member`,`ok`),!0}catch(t){if(t instanceof fn&&t.status===409){let n=t.payload?.candidates||[];if(n.length>0)return Te({projectId:e,person:r,candidates:n}),N(`Choose the matching person record`,`error`),!1}return P(t,`Unable to add historical member`),!1}finally{F(`project:${e}:historical`,!1)}}async function zt(e,t){let n=t.trim();if(!n)return!1;F(`project:${e}:historical`,!0);try{let t=await G(`/dashboard/api/projects/${encodeURIComponent(e)}/historical-members/remove`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({source_user_id:n})});return S(n=>n.map(n=>n.id===e?t.project:n)),N(`Removed historical project member`,`ok`),!0}catch(e){return N(e instanceof Error?e.message:`Unable to remove historical member`,`error`),!1}finally{F(`project:${e}:historical`,!1)}}async function Bt(e,t,n){F(`project:${e}:wiki`,!0);try{await G(`/dashboard/api/projects/${encodeURIComponent(e)}/wiki-match`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t,row_key:n})}),N(t===`no_row`?`Marked as no wiki row`:`Confirmed wiki match`,`ok`),await Vt()}catch(e){P(e,`Unable to save wiki match`)}finally{F(`project:${e}:wiki`,!1)}}async function Vt(){F(`wikiMatches`,!0);try{ae(await G(`/dashboard/api/projects/wiki-matches`)),N(`Loaded wiki match preview`,`ok`)}catch(e){P(e,`Unable to load wiki matches`)}finally{F(`wikiMatches`,!1)}}async function Ht(e){F(`gig:${e}:detail`,!0);try{se(await G(`/dashboard/api/gigs/${encodeURIComponent(e)}`))}catch(e){se(null),P(e,`Unable to load gig`)}finally{F(`gig:${e}:detail`,!1)}}async function Ut(){await At(),w&&await Ht(w)}async function U(){if(ft(`gigs:read`)){F(`notifications`,!0);try{let e=await G(`/dashboard/api/notifications?limit=20`);We(e.stale_days||7),E(e.notifications||[])}catch(e){P(e,`Unable to load notifications`)}finally{F(`notifications`,!1)}}}async function W(e,t){F(`gig:${e}:status`,!0);try{let n=(await G(`/dashboard/api/gigs/${encodeURIComponent(e)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t})})).discord_title_sync?.status;N(n===`error`?`Updated gig status; Discord title sync failed`:`Updated gig status`,n===`error`?`error`:`ok`),await At(),w===e&&await Ht(e)}catch(e){P(e,`Unable to update gig`)}finally{F(`gig:${e}:status`,!1)}}async function Wt(e,t,n){F(`application:${t}:status`,!0);try{await G(`/dashboard/api/gigs/${encodeURIComponent(e)}/applications/${encodeURIComponent(t)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:n})}),N(`Updated candidate status`,`ok`),await At(),w===e&&await Ht(e)}catch(e){P(e,`Unable to update candidate`)}finally{F(`application:${t}:status`,!1)}}async function Gt(e,t){let n=t.trim();if(!n)return N(`Paste a CRM Contact profile first`,`warning`),!1;F(`gig:${e}:addCandidate`,!0);try{return await G(`/dashboard/api/gigs/${encodeURIComponent(e)}/applications`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({crm_profile:n})}),N(`Added candidate`,`ok`),await At(),w===e&&await Ht(e),!0}catch(e){return P(e,`Unable to add candidate`),!1}finally{F(`gig:${e}:addCandidate`,!1)}}function Kt(){let e=new URLSearchParams({limit:`25`});Ge.trim()&&e.set(`query`,Ge.trim()),qe&&e.set(`is_member`,qe);for(let[t,n]of Object.entries(Ye))n&&e.set(t,n);return`/dashboard/api/people?${e.toString()}`}async function qt(){F(`people`,!0);try{D(await G(Kt()))}catch(e){P(e,`Unable to load people`)}finally{F(`people`,!1)}}function Jt(){let e=new URLSearchParams({limit:`25`});et.trim()&&e.set(`query`,et.trim()),nt&&e.set(`onboarding_state`,nt),it.trim()&&e.set(`onboarder`,it.trim());for(let[t,n]of Object.entries(ot))n&&e.set(t,n);return`/dashboard/api/onboarding?${e.toString()}`}async function Xt(){F(`onboarding`,!0);try{k(await G(Jt()))}catch(e){P(e,`Unable to load onboarding`)}finally{F(`onboarding`,!1)}}async function Zt(){F(`audit`,!0);try{he(await G(`/dashboard/api/audit-events?limit=25`))}catch(e){P(e,`Unable to load audit events`)}finally{F(`audit`,!1)}}async function Qt(){F(`agent`,!0);try{_e(await G(`/dashboard/api/agent?limit=100`))}catch(e){P(e,`Unable to load agent report`)}finally{F(`agent`,!1)}}async function $t(e){F(`detail:${e}`,!0),N(`Loading ${e}`);try{ye(await G(`/dashboard/api/jobs/${encodeURIComponent(e)}`)),N(`Loaded ${e}`,`ok`)}catch(e){P(e,`Unable to load job detail`)}finally{F(`detail:${e}`,!1)}}async function en(e){F(`rerun:${e}`,!0),N(`Rerunning ${e}`);try{let t=await G(`/dashboard/api/jobs/${encodeURIComponent(e)}/rerun`,{method:`POST`});t.dry_run?N(`Dry run only: would rerun ${t.would_enqueue?.job_type||e}`,`warning`):(N(`Queued rerun ${t.job_id}`,`ok`),await kt())}catch(e){P(e,`Unable to rerun job`)}finally{F(`rerun:${e}`,!1)}}async function tn(){F(`syncPeople`,!0),N(`Queueing people sync`);try{let e=await G(`/dashboard/api/sync/people`,{method:`POST`});e.dry_run?N(`Dry run only: would queue ${e.would_enqueue?.job_type||`people sync`}`,`warning`):N(`Queued people sync ${e.job_id}`,`ok`)}catch(e){P(e,`Unable to queue people sync`)}finally{F(`syncPeople`,!1)}}async function nn(e,t){let n=String(e||``).trim(),r=t.trim();if(!n){N(`Missing CRM contact id`,`error`);return}if(!r){N(`Enter a 508 username`,`error`);return}F(`onboarder:${n}`,!0),N(`Assigning ${r}`);try{let e=await G(`/dashboard/api/onboarding/${encodeURIComponent(n)}/onboarder`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({onboarder:r})});k(t=>t.map(t=>t.crm_contact_id===e.contact_id?{...t,onboarder:e.onboarder,onboarding_state:e.state_updated&&e.onboarding_state?e.onboarding_state:t.onboarding_state,onboarding_status_label:e.onboarding_status_label||(e.state_updated?void 0:t.onboarding_status_label)}:t)),N(`Assigned ${e.onboarder}`,`ok`)}catch(e){P(e,`Unable to assign onboarder`)}finally{F(`onboarder:${n}`,!1)}}async function rn(e,t){let n=String(e||``).trim(),r=t.trim();if(!n){N(`Missing CRM contact id`,`error`);return}if(!r){N(`Choose an onboarding status`,`error`);return}F(`onboarding-status:${n}`,!0),N(`Updating onboarding status`);try{let e=await G(`/dashboard/api/onboarding/${encodeURIComponent(n)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:r})}),t=dn(e.onboarding_state),i=e.onboarding_status_label||Yt(t);k(n=>n.map(n=>n.crm_contact_id===e.contact_id?{...n,onboarding_state:t,onboarding_status_label:i}:n).filter(n=>n.crm_contact_id!==e.contact_id||!un.has(t))),N(`Status set to ${i}`,`ok`)}catch(e){P(e,`Unable to update onboarding status`)}finally{F(`onboarding-status:${n}`,!1)}}async function cn(e){let t=e.email.trim().toLowerCase(),n=e.first_name.trim();if(!t?.endsWith(`@508.dev`))return N(`Enter the engineer's @508.dev email`,`error`),null;if(!n)return N(`Enter the engineer name`,`error`),null;F(`engineerSetup`,!0);try{let r=await G(`/dashboard/api/onboarding/engineers`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({...e,email:t,first_name:n})});return N(`Set up ${r.employee_name||r.user||t}`,`ok`),r}catch(e){if(e instanceof fn&&e.status===409){let t=e.payload&&typeof e.payload==`object`?e.payload:null,n=(Array.isArray(t?.matches)?t.matches:[]).map(e=>e?.label||e?.email).filter(Boolean).slice(0,2).join(`, `);N(n?`Similar account exists: ${n}`:`Similar account exists; confirm before creating`,`error`)}else N(e instanceof Error?e.message:`Unable to set up engineer`,`error`);return null}finally{F(`engineerSetup`,!1)}}async function ln(){F(`logout`,!0);try{let e=await G(`/auth/logout`,{method:`POST`});window.location.assign(e.end_session_url||`/dashboard`)}catch(e){P(e,`Unable to log out`),F(`logout`,!1)}}(0,l.useEffect)(()=>{Tt().then(e=>{let t=gn(),n=e.includes(on[t])?t:Object.keys(an).find(t=>e.includes(on[t]))||`people`;ce(n===`gigs`?_n():``),ue(n===`projects`?_n(`projects`):``),i(n),(!Object.hasOwn(an,hn())||n!==t)&&window.history.replaceState({view:n},``,an[n])}).catch(e=>{P(e,`Dashboard failed to load`)})},[]),(0,l.useEffect)(()=>{let e=()=>{ce(_n()),ue(_n(`projects`)),dt.current(gn(),!1)};return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),(0,l.useEffect)(()=>{if(!a.message)return;let e=window.setTimeout(()=>o({message:``}),4500);return()=>window.clearTimeout(e)},[a.message]),(0,l.useEffect)(()=>{},[]),(0,l.useEffect)(()=>{s.length!==0&&(ft(`gigs:read`)&&U(),r===`people`&&qt(),r===`gigs`&&At(),r===`projects`&&jt(),r===`onboarding`&&Xt(),r===`jobs`&&kt(),r===`agent`&&Qt(),r===`audit`&&Zt())},[r]),(0,l.useEffect)(()=>{s.length!==0&&(ft(`gigs:read`)&&U(),r===`people`&&qt(),r===`gigs`&&At(),r===`projects`&&jt(),r===`onboarding`&&Xt(),r===`jobs`&&kt(),r===`agent`&&Qt(),r===`audit`&&Zt())},[s]),(0,l.useEffect)(()=>{r===`jobs`&&s.length>0&&kt()},[Oe,Ae]),(0,l.useEffect)(()=>{r===`gigs`&&s.length>0&&At()},[Pe,Ie,Re]),(0,l.useEffect)(()=>{r===`projects`&&s.length>0&&jt()},[He]),(0,l.useEffect)(()=>{r===`gigs`&&w&&s.length>0&&Ht(w)},[r,w,s]),(0,l.useEffect)(()=>{r===`people`&&s.length>0&&qt()},[qe]),(0,l.useEffect)(()=>{r===`people`&&s.length>0&&qt()},[Ye]),(0,l.useEffect)(()=>{r===`onboarding`&&s.length>0&&Xt()},[nt]),(0,l.useEffect)(()=>{r===`onboarding`&&s.length>0&&Xt()},[ot]);let pn=(0,l.useMemo)(()=>yn(`jobs`,f,Ee.jobs),[f,Ee.jobs]),vn=(0,l.useMemo)(()=>yn(`people`,pe,Ee.people),[pe,Ee.people]),bn=(0,l.useMemo)(()=>yn(`onboarding`,O,Ee.onboarding),[O,Ee.onboarding]),xn=(0,l.useMemo)(()=>yn(`gigs`,m,Ee.gigs),[m,Ee.gigs]),Sn=(0,l.useMemo)(()=>yn(`projects`,ee,Ee.projects),[ee,Ee.projects]),Cn=(0,l.useMemo)(()=>oe?.id===w?oe:xn.find(e=>e.id===w)||null,[oe,w,xn]),wn=(0,l.useMemo)(()=>Sn.find(e=>e.id===le)||null,[le,Sn]),Tn=(0,l.useMemo)(()=>yn(`audit`,me,Ee.audit),[me,Ee.audit]),kn=(0,l.useMemo)(()=>f.reduce((e,t)=>(e[t.status]=(e[t.status]||0)+1,e),{}),[f]),An=Object.keys(sn).filter(e=>!Ye[e]),jn=Object.keys(sn).filter(e=>e!==`sync_status`&&e!==`email_508`&&!ot[e]);function Mn(e){if(e.type===`stale_recruiting_gig`){let t=e.engagement_id||(e.id.startsWith(`stale-recruiting:`)?e.id.slice(17):``);t?xt(t):(Fe(`recruiting`),_t(`gigs`,!0))}fe(!1)}(0,l.useEffect)(()=>{!An.includes(Ze)&&An[0]&&Qe(An[0])},[An,Ze]),(0,l.useEffect)(()=>{let e=sn[Ze]?.options;e?.[0]&&!e.some(([e])=>e===j)&&$e(e[0][0])},[Ze,j]),(0,l.useEffect)(()=>{!jn.includes(ct)&&jn[0]&<(jn[0])},[jn,ct]),(0,l.useEffect)(()=>{let e=sn[ct]?.options;e?.[0]&&!e.some(([e])=>e===M)&&ut(e[0][0])},[ct,M]);let Nn=[t?.email,t?.crm_contact_id?`CRM ${t.crm_contact_id}`:``,t?.actor_provider].filter(Boolean).join(` | `);return(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`header`,{className:`sticky top-0 z-20 border-b bg-background/90 backdrop-blur`,children:(0,L.jsxs)(`div`,{className:`mx-auto flex max-w-7xl flex-col gap-4 px-5 py-4 md:flex-row md:items-center md:justify-between`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`h1`,{className:`text-xl font-bold`,children:`508 Operations Dashboard`}),(0,L.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Operations view for authenticated 508 operators.`})]}),(0,L.jsxs)(`div`,{className:`flex min-w-0 items-center gap-3`,children:[ft(`gigs:read`)?(0,L.jsx)(`div`,{className:`relative`,children:(0,L.jsxs)(z,{id:`notifications`,type:`button`,variant:`outline`,size:`icon`,"aria-label":`Notifications`,"aria-expanded":de,onClick:()=>fe(e=>!e),children:[(0,L.jsx)(h,{}),T.length>0?(0,L.jsx)(`span`,{className:`absolute -right-1 -top-1 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold text-white`,children:T.length}):null]})}):null,(0,L.jsxs)(`div`,{className:`grid min-w-0 gap-0.5 text-right text-sm text-muted-foreground`,children:[(0,L.jsx)(`strong`,{id:`userName`,className:`truncate text-foreground`,children:t?.display_name||t?.email||t?.subject||`Loading user`}),(0,L.jsx)(`span`,{id:`userMeta`,className:`truncate`,children:Nn||`Checking session`})]}),(0,L.jsxs)(z,{id:`logout`,type:`button`,variant:`outline`,onClick:ln,disabled:be.logout,children:[(0,L.jsx)(x,{}),`Log out`]})]})]})}),(0,L.jsx)(En,{open:de,notifications:T,loading:be.notifications,onClose:()=>fe(!1),onRefresh:U,onOpenNotification:Mn}),(0,L.jsx)(On,{toast:a}),null,(0,L.jsx)(Dn,{choice:we,loading:!!(we&&be[`project:${we.projectId}:historical`]),crmContactUrl:vt,onClose:()=>Te(null),onChoose:e=>{we&&H(we.projectId,we.person,e)}}),(0,L.jsxs)(`main`,{className:`mx-auto grid max-w-7xl grid-cols-1 gap-5 px-5 py-5 md:grid-cols-[190px_minmax(0,1fr)]`,children:[(0,L.jsx)(`nav`,{className:`grid content-start gap-1 md:sticky md:top-24`,"aria-label":`Dashboard sections`,children:[[`people`,`People`,ie],[`gigs`,`Gigs`,g],[`projects`,`Projects`,b],[`onboarding`,`Onboarding`,_],[`jobs`,`Jobs`,g],[`agent`,`Agent`,te],[`audit`,`Audit`,y]].filter(([e])=>ht(e)).map(([e,t,n])=>(0,L.jsxs)(`a`,{className:I(`flex min-h-10 items-center gap-2 rounded-md border border-transparent px-3 text-sm font-extrabold text-muted-foreground hover:border-border hover:bg-secondary hover:text-foreground`,r===e&&`border-primary bg-accent text-accent-foreground`),"data-view-link":e,"data-permission":on[e],href:an[e],"aria-current":r===e?`page`:void 0,onClick:t=>{t.preventDefault(),_t(e,!0)},children:[(0,L.jsx)(n,{className:`size-4`}),t]},e))}),(0,L.jsxs)(`div`,{className:`grid min-w-0 gap-5`,children:[r===`people`?(0,L.jsx)(qn,{crmBaseUrl:u,people:vn,sort:Ee.people,canSync:mt(`people:sync`),loading:be,peopleQuery:Ge,peopleMember:qe,peopleFilters:Ye,peopleFilterKind:Ze,peopleFilterValue:j,peopleFilterKeys:An,onSearch:qt,onSync:tn,onSort:e=>bt(`people`,e),setPeopleQuery:Ke,setPeopleMember:Je,setPeopleFilterKind:Qe,setPeopleFilterValue:$e,addFilter:()=>{Xe(e=>({...e,[Ze]:j}))},removeFilter:e=>{Xe(t=>{let n={...t};return delete n[e],n})},crmContactUrl:vt,crmAttachmentUrl:yt}):null,r===`gigs`?(0,L.jsx)(Hn,{gigs:xn,selectedGig:Cn,selectedGigId:w,sort:Ee.gigs,loading:be,status:Pe,includeHistorical:Ie,limit:Re,staleDays:A,canWrite:ft(`gigs:write`),canIncludeHistorical:ft(`people:read`),crmContactUrl:vt,crmAttachmentUrl:yt,setStatus:Fe,setIncludeHistorical:Le,setLimit:ze,onRefresh:Ut,onSort:e=>bt(`gigs`,e),onOpenGig:xt,onCloseGig:St,onUpdateStatus:W,onAddApplication:Gt,onUpdateApplicationStatus:Wt}):null,r===`projects`?(0,L.jsx)(zn,{projects:Sn,selectedProject:wn,selectedProjectId:le,summary:C,wikiMatches:re,sort:Ee.projects,loading:be,query:Be,status:He,canSync:mt(`projects:sync`),canWrite:ft(`projects:write`),crmContactUrl:vt,setQuery:Ve,setStatus:Ue,onSearch:jt,onSync:Mt,onSearchCustomers:Nt,onSearchContacts:Pt,onSearchAccountManagers:R,onLoadCostCenters:Ft,onCreateProject:It,onUpdateStatus:B,onBulkUpdate:Lt,onAddUser:Rt,onRemoveUser:V,onAddHistoricalMember:H,onRemoveHistoricalMember:zt,onUpdateWikiMatch:Bt,onWikiMatches:Vt,onOpenProject:Ct,onCloseProject:wt,onSort:e=>bt(`projects`,e)}):null,r===`onboarding`?(0,L.jsx)(Jn,{people:bn,sort:Ee.onboarding,loading:be,onboardingQuery:et,onboardingState:nt,onboarderFilter:it,onboardingFilters:ot,onboardingFilterKind:ct,onboardingFilterValue:M,onboardingFilterKeys:jn,onSearch:Xt,onSort:e=>bt(`onboarding`,e),onAssign:nn,onStatusChange:rn,onSetupEngineer:cn,setOnboardingQuery:tt,setOnboardingState:rt,setOnboarderFilter:at,setOnboardingFilterKind:lt,setOnboardingFilterValue:ut,addFilter:()=>{st(e=>({...e,[ct]:M}))},removeFilter:e=>{st(t=>{let n={...t};return delete n[e],n})},crmContactUrl:vt,crmAttachmentUrl:yt,canWrite:ft(`onboarding:write`)}):null,r===`jobs`?(0,L.jsx)(nr,{jobs:pn,jobDetail:ve,sort:Ee.jobs,loading:be,minutes:Oe,status:Ae,jobType:Me,jobCounts:kn,canWrite:mt(`jobs:write`),setMinutes:ke,setStatus:je,setJobType:Ne,onSearch:kt,onSort:e=>bt(`jobs`,e),onDetail:$t,onRerun:en}):null,r===`audit`?(0,L.jsx)(rr,{events:Tn,sort:Ee.audit,loading:be,onRefresh:Zt,onSort:e=>bt(`audit`,e)}):null,r===`agent`?(0,L.jsx)(ir,{report:ge,loading:be,onRefresh:Qt}):null]})]})]})}function En({open:e,notifications:t,loading:n,onClose:r,onRefresh:i,onOpenNotification:a}){return e?(0,L.jsxs)(`div`,{className:`fixed inset-0 z-40`,"aria-labelledby":`notificationsTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,L.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close notifications`,onClick:r}),(0,L.jsxs)(`aside`,{className:`absolute right-0 top-0 grid h-full w-full max-w-md grid-rows-[auto_minmax(0,1fr)] border-l bg-background shadow-2xl`,children:[(0,L.jsxs)(`div`,{className:`flex items-center justify-between gap-3 border-b p-4`,children:[(0,L.jsxs)(`div`,{className:`grid gap-0.5`,children:[(0,L.jsx)(`strong`,{id:`notificationsTitle`,className:`text-base`,children:`Notifications`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:t.length===0?`No active notifications`:`${t.length} active`})]}),(0,L.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,L.jsxs)(z,{type:`button`,variant:`outline`,size:`sm`,onClick:i,disabled:n,children:[(0,L.jsx)(S,{}),`Refresh`]}),(0,L.jsx)(z,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close`,onClick:r,children:(0,L.jsx)(ae,{})})]})]}),(0,L.jsx)(`div`,{className:`min-h-0 overflow-auto p-4`,children:t.length===0?(0,L.jsx)(`div`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:`No active notifications.`}):(0,L.jsx)(`div`,{className:`grid gap-3`,children:t.map(e=>(0,L.jsxs)(`button`,{type:`button`,className:`grid gap-2 rounded-md border p-3 text-left hover:bg-secondary`,onClick:()=>a(e),children:[(0,L.jsx)(`span`,{className:`text-sm font-bold`,children:e.title}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.message})]},e.id))})})]})]}):null}function Dn({choice:e,loading:t,crmContactUrl:n,onClose:r,onChoose:i}){return e?(0,L.jsxs)(`div`,{className:`fixed inset-0 z-50 grid place-items-center p-4`,"aria-labelledby":`historicalPersonChoiceTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,L.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close person selection`,onClick:r}),(0,L.jsxs)(`div`,{className:`relative grid w-full max-w-2xl gap-4 rounded-md border bg-background p-5 shadow-2xl`,children:[(0,L.jsxs)(`div`,{className:`flex items-start justify-between gap-3`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`strong`,{id:`historicalPersonChoiceTitle`,className:`block text-base`,children:`Choose person record`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.person})]}),(0,L.jsx)(z,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close person selection`,onClick:r,children:(0,L.jsx)(ae,{})})]}),(0,L.jsx)(`div`,{className:`grid gap-2`,children:e.candidates.map(e=>(0,L.jsxs)(`div`,{className:`grid gap-3 rounded-md border p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center`,children:[(0,L.jsxs)(`div`,{className:`min-w-0`,children:[(0,L.jsx)(`strong`,{className:`block truncate`,children:e.label||e.full_name||e.email||`Person`}),(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground`,children:[e.email?(0,L.jsx)(`span`,{children:e.email}):null,e.sources?.length?(0,L.jsx)(`span`,{children:e.sources.join(`, `)}):null,e.erpnext_user_id?(0,L.jsxs)(`span`,{children:[`ERP `,e.erpnext_user_id]}):null,e.supplier_erpnext_id?(0,L.jsxs)(`span`,{children:[`Supplier `,e.supplier_erpnext_id]}):null,e.crm_contact_id&&n(e.crm_contact_id)?(0,L.jsx)(`a`,{className:`font-semibold text-primary underline-offset-4 hover:underline`,href:n(e.crm_contact_id),target:`_blank`,rel:`noreferrer`,children:`CRM`}):null]})]}),(0,L.jsx)(z,{type:`button`,disabled:t,onClick:()=>i(e.candidate_id),children:`Select`})]},e.candidate_id))})]})]}):null}function On({toast:e}){return e.message?(0,L.jsx)(`div`,{id:`toast`,role:`status`,className:I(`fixed bottom-5 right-5 z-50 max-w-sm rounded-md border bg-background px-4 py-3 text-sm font-semibold shadow-lg`,e.tone===`ok`&&`border-emerald-500/40 text-emerald-300`,e.tone===`warning`&&`border-amber-500/40 text-amber-200`,e.tone===`error`&&`border-red-500/40 text-red-300`),children:e.message}):null}function kn({filters:e,onRemove:t,suffix:n=`filter`}){return(0,L.jsx)(`fieldset`,{className:`m-0 flex min-h-7 flex-wrap gap-2 border-0 p-0`,"aria-label":`Active filters`,children:Object.entries(e).map(([e,r])=>{let i=sn[e],a=i.options.find(([e])=>e===r),o=`${i.label}: ${a?a[1]:r}`;return(0,L.jsxs)(z,{type:`button`,variant:`outline`,size:`sm`,className:`rounded-full`,"aria-label":`Remove ${o} ${n}`,onClick:()=>t(e),children:[o,` x`]},e)})})}var An=[`recruiting`,`filled`,`unknown`,`lost`,`outdated`],jn=[`suggested`,`interested`,`reviewing`,`contacted`,`accepted`,`unavailable`,`rejected`,`withdrawn`];function Mn(e){return String(e||``).replace(/[-_]+/g,` `).replace(/\s+/g,` `).trim().replace(/\b\w/g,e=>e.toUpperCase())}function Nn(e){let t=[e.last_activity_at,e.last_status_changed_at,e.posted_at,e.created_at].map(e=>e?new Date(e).getTime():NaN).filter(e=>!Number.isNaN(e));return t.length>0?new Date(Math.max(...t)).toISOString():``}function Pn(e,t){if(e.status!==`recruiting`)return null;let n=Kt(Nn(e));return n===null||ne.projects.map(e=>e.id),[e.projects]),h=(0,l.useMemo)(()=>new Set(p),[p]),g=n.filter(e=>h.has(e)),_=e.projects.length>0&&g.length===e.projects.length;(0,l.useEffect)(()=>{r(e=>e.filter(e=>h.has(e)))},[h]);function y(e,t){r(n=>t?Array.from(new Set([...n,e])):n.filter(t=>t!==e))}async function b(){let t={};i&&(t.status=i),o&&(t.project_type=o),await e.onBulkUpdate(g,t)&&(r([]),a(``),s(``),u(!1))}let x=(0,L.jsxs)(It,{className:`grid gap-3 p-4 md:grid-cols-[minmax(0,1fr)_180px_auto_auto_auto] md:items-end`,children:[(0,L.jsxs)(H,{children:[`Search projects`,(0,L.jsx)(V,{id:`projectQuery`,value:e.query,autoComplete:`off`,placeholder:`Project, customer, ERP id`,onChange:t=>e.setQuery(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,L.jsxs)(H,{children:[`Status`,(0,L.jsxs)(zt,{id:`projectStatus`,value:e.status,onChange:t=>e.setStatus(t.target.value),children:[(0,L.jsx)(`option`,{value:`Open`,children:`Open`}),(0,L.jsx)(`option`,{value:``,children:`Any status`})]})]}),(0,L.jsxs)(z,{id:`refreshProjects`,type:`button`,onClick:e.onSearch,disabled:e.loading.projects,children:[(0,L.jsx)(S,{}),`Refresh`]}),e.canSync?(0,L.jsxs)(z,{id:`syncProjects`,type:`button`,variant:`outline`,onClick:e.onSync,disabled:e.loading.syncProjects,children:[(0,L.jsx)(S,{}),`Sync ERP`]}):null,(0,L.jsxs)(z,{id:`wikiProjectMatches`,type:`button`,variant:`outline`,onClick:e.onWikiMatches,disabled:e.loading.wikiMatches,children:[(0,L.jsx)(C,{}),`Wiki match`]})]});return e.selectedProjectId&&!e.selectedProject&&e.loading.projects?(0,L.jsxs)(L.Fragment,{children:[x,(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsx)(Lt,{children:`Project detail`})}),(0,L.jsx)(Rt,{className:`text-sm text-muted-foreground`,children:`Loading project.`})]})]}):e.selectedProjectId&&!e.selectedProject?(0,L.jsxs)(L.Fragment,{children:[x,(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsx)(Lt,{children:`Project detail`})}),(0,L.jsxs)(Rt,{className:`grid gap-3`,children:[(0,L.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`This project is not in the current result set. Clear filters or refresh the project list.`}),(0,L.jsxs)(z,{type:`button`,variant:`outline`,onClick:e.onCloseProject,children:[(0,L.jsx)(m,{}),`Back to projects`]})]})]})]}):e.selectedProject?(0,L.jsxs)(L.Fragment,{children:[x,(0,L.jsx)(Vn,{project:e.selectedProject,loading:e.loading,canWrite:e.canWrite,crmContactUrl:e.crmContactUrl,onBack:e.onCloseProject,onUpdateStatus:e.onUpdateStatus,onAddUser:e.onAddUser,onRemoveUser:e.onRemoveUser,onAddHistoricalMember:e.onAddHistoricalMember,onRemoveHistoricalMember:e.onRemoveHistoricalMember})]}):(0,L.jsxs)(L.Fragment,{children:[x,(0,L.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-2`,"aria-label":`Project summary`,children:[(0,L.jsx)(Sn,{id:`projectMetricOpen`,label:`Open`,value:e.summary.open_project_count||0}),(0,L.jsx)(Sn,{id:`projectMetricTotal`,label:`Projects`,value:e.summary.project_count||0})]}),e.canWrite?(0,L.jsxs)(It,{className:`flex flex-wrap items-center justify-between gap-3 p-4`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Selected`}),(0,L.jsxs)(`strong`,{className:`block`,children:[g.length,` project(s)`]})]}),(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,L.jsxs)(z,{type:`button`,onClick:()=>f(!0),children:[(0,L.jsx)(ee,{}),`New project`]}),(0,L.jsx)(z,{type:`button`,variant:`outline`,disabled:g.length===0,onClick:()=>u(!0),children:`Bulk edit`})]})]}):null,d?(0,L.jsx)(Bn,{loading:e.loading.createProject,onClose:()=>f(!1),onSearchCustomers:e.onSearchCustomers,onSearchContacts:e.onSearchContacts,onSearchAccountManagers:e.onSearchAccountManagers,onLoadCostCenters:e.onLoadCostCenters,onCreateProject:e.onCreateProject}):null,c?(0,L.jsxs)(`div`,{className:`fixed inset-0 z-50 grid place-items-center p-4`,"aria-labelledby":`bulkProjectEditTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,L.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close bulk project edit`,onClick:()=>u(!1)}),(0,L.jsxs)(`div`,{className:`relative grid w-full max-w-lg gap-4 rounded-md border bg-background p-5 shadow-2xl`,children:[(0,L.jsxs)(`div`,{className:`flex items-start justify-between gap-3`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`strong`,{id:`bulkProjectEditTitle`,className:`block text-base`,children:`Bulk edit projects`}),(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[g.length,` selected`]})]}),(0,L.jsx)(z,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close bulk project edit`,onClick:()=>u(!1),children:(0,L.jsx)(ae,{})})]}),(0,L.jsxs)(`div`,{className:`grid gap-3`,children:[(0,L.jsx)(`strong`,{className:`text-sm`,children:`Changes`}),(0,L.jsxs)(H,{children:[`Status`,(0,L.jsxs)(zt,{value:i,onChange:e=>a(e.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`No change`}),(0,L.jsx)(`option`,{value:`Open`,children:`Open`}),(0,L.jsx)(`option`,{value:`Completed`,children:`Completed`}),(0,L.jsx)(`option`,{value:`Cancelled`,children:`Cancelled`})]})]}),(0,L.jsxs)(H,{children:[`ERP Type`,(0,L.jsxs)(zt,{value:o,onChange:e=>s(e.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`No change`}),(0,L.jsx)(`option`,{value:`Internal`,children:`Internal`}),(0,L.jsx)(`option`,{value:`External`,children:`External`})]})]})]}),(0,L.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[(0,L.jsx)(z,{type:`button`,variant:`outline`,onClick:()=>u(!1),children:`Cancel`}),(0,L.jsx)(z,{type:`button`,disabled:e.loading.projectsBulkUpdate||g.length===0||!i&&!o,onClick:()=>void b(),children:`Apply changes`})]})]})]}):null,(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`ERP projects`}),(0,L.jsx)(`span`,{id:`projectsStatus`,className:`text-sm text-muted-foreground`,children:e.loading.projects?`Loading`:`${e.projects.length} shown | synced ${Gt(e.summary.last_synced_at)}`})]}),(0,L.jsx)(Cn,{hidden:e.projects.length!==0,children:`No projects match this view. Sync ERP projects if the cache is empty.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`projectsTable`,className:I(`min-w-[1100px]`,e.projects.length===0&&`hidden`),"aria-label":`ERP projects`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[e.canWrite?(0,L.jsx)(U,{className:`w-[48px]`,children:(0,L.jsx)(`input`,{type:`checkbox`,"aria-label":`Select all visible projects`,checked:_,onChange:e=>{r(e.target.checked?p:[])}})}):null,(0,L.jsx)(xn,{className:`w-[24%]`,label:`Project`,scope:`projects`,sort:e.sort,sortKey:`display_name`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[16%]`,label:`Customer`,scope:`projects`,sort:e.sort,sortKey:`customer`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[10%]`,label:`Status`,scope:`projects`,sort:e.sort,sortKey:`status`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(U,{className:`w-[16%]`,children:`Timeline`}),(0,L.jsx)(xn,{className:`w-[10%]`,label:`Roster`,scope:`projects`,sort:e.sort,sortKey:`roster_count`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[14%]`,label:`Modified`,scope:`projects`,sort:e.sort,sortKey:`modified`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(U,{children:`ERP`})]})}),(0,L.jsx)(Ht,{id:`projectsBody`,children:e.projects.map(t=>{let n=t.roster_members||[];return(0,L.jsxs)(Ut,{children:[e.canWrite?(0,L.jsx)(W,{children:(0,L.jsx)(`input`,{type:`checkbox`,"aria-label":`Select ${t.display_name}`,checked:g.includes(t.id),onChange:e=>y(t.id,e.target.checked)})}):null,(0,L.jsxs)(W,{children:[(0,L.jsx)(`button`,{type:`button`,className:`text-left font-bold text-primary underline-offset-4 hover:underline`,onClick:()=>e.onOpenProject(t.id),children:t.display_name}),(0,L.jsxs)(`div`,{className:`mt-1 flex flex-wrap items-center gap-1.5`,children:[t.project_type?(0,L.jsx)(R,{variant:`neutral`,children:t.project_type}):null,t.linked_engagement_count?(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[t.linked_engagement_count,` linked gig`]}):null]})]}),(0,L.jsx)(W,{children:t.customer_erpnext_url?(0,L.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:t.customer_erpnext_url,target:`_blank`,rel:`noreferrer`,children:[t.customer,(0,L.jsx)(v,{className:`size-3.5`})]}):t.customer||`None`}),(0,L.jsx)(W,{children:(0,L.jsx)(R,{variant:Fn(t.source_status),children:t.source_status||`Unknown`})}),(0,L.jsx)(W,{children:[t.actual_start_date,t.actual_end_date].filter(Boolean).map(e=>In(e)).join(` to `)||`Not set`}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`grid gap-1`,children:[(0,L.jsx)(`strong`,{children:n.length}),(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[n.map(Ln).slice(0,4).join(`, `)||`No ERP roster`,n.length>4?` +${n.length-4}`:``]})]})}),(0,L.jsx)(W,{children:Gt(t.source_modified_at)}),(0,L.jsx)(W,{className:`text-xs`,children:t.erpnext_project_url?(0,L.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-mono font-semibold text-primary underline-offset-4 hover:underline`,href:t.erpnext_project_url,target:`_blank`,rel:`noreferrer`,children:[t.erpnext_project_id,(0,L.jsx)(v,{className:`size-3.5`})]}):(0,L.jsx)(`span`,{className:`font-mono`,children:`Unlinked`})})]},t.id)})})]})})]}),e.wikiMatches?(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Wiki match preview`}),(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[e.wikiMatches.document?.title||`Client & Project Info`,` |`,` `,Gt(e.wikiMatches.document?.updatedAt)]})]}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`wikiMatchesTable`,className:`min-w-[920px]`,"aria-label":`Wiki matches`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(U,{children:`ERP project`}),(0,L.jsx)(U,{children:`Best wiki row`}),(0,L.jsx)(U,{children:`Confidence`}),(0,L.jsx)(U,{children:`Section`}),(0,L.jsx)(U,{children:`Decision`})]})}),(0,L.jsx)(Ht,{children:t.map((t,n)=>{let r=t.project,i=t.best_match?.row||{},a=t.manual_match?.match_status||``,o=r?.id||i.row_key||[i.section,i.Client].filter(Boolean).join(`:`)||`wiki-match-${n}`;return(0,L.jsxs)(Ut,{children:[(0,L.jsx)(W,{children:r?.display_name||`Unknown`}),(0,L.jsxs)(W,{children:[(0,L.jsx)(`strong`,{children:i.Client||`No match`}),(0,L.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:[i.DRI,i.Members].filter(Boolean).join(` | `)})]}),(0,L.jsx)(W,{children:(0,L.jsx)(R,{variant:t.best_match?.confidence===`high`?`succeeded`:t.best_match?.confidence===`medium`?`running`:`neutral`,children:t.best_match?`${t.best_match.confidence} ${t.best_match.score}`:`none`})}),(0,L.jsx)(W,{children:i.section||``}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[a?(0,L.jsx)(R,{variant:a===`confirmed`?`succeeded`:`neutral`,children:a===`no_row`?`No wiki row`:`Confirmed`}):null,e.canWrite&&r?.id?(0,L.jsxs)(L.Fragment,{children:[i.row_key?(0,L.jsx)(z,{type:`button`,variant:`outline`,size:`sm`,disabled:e.loading[`project:${r.id}:wiki`],onClick:()=>void e.onUpdateWikiMatch(r.id,`confirmed`,i.row_key),children:`Confirm`}):null,(0,L.jsx)(z,{type:`button`,variant:`outline`,size:`sm`,disabled:e.loading[`project:${r.id}:wiki`],onClick:()=>void e.onUpdateWikiMatch(r.id,`no_row`),children:`No row`})]}):null]})})]},o)})})]})})]}):null]})}function Bn(e){let[t,n]=(0,l.useState)(``),[r,i]=(0,l.useState)(`new`),[a,o]=(0,l.useState)(``),[s,c]=(0,l.useState)(``),[u,d]=(0,l.useState)(``),[f,p]=(0,l.useState)([]),[m,h]=(0,l.useState)(``),[g,_]=(0,l.useState)(``),[v,y]=(0,l.useState)([]),[b,x]=(0,l.useState)(`USD`),[ee,S]=(0,l.useState)(``),[C,te]=(0,l.useState)(``),[ne,re]=(0,l.useState)(``),[ie,oe]=(0,l.useState)(``),[se,w]=(0,l.useState)(``),[ce,le]=(0,l.useState)(``),[ue,T]=(0,l.useState)(`United States`),[E,de]=(0,l.useState)(``),[fe,pe]=(0,l.useState)(`new`),[D,O]=(0,l.useState)(``),[k,me]=(0,l.useState)(``),[he,ge]=(0,l.useState)([]),[_e,ve]=(0,l.useState)(``),[ye,be]=(0,l.useState)(``),[xe,Se]=(0,l.useState)(``),[Ce,we]=(0,l.useState)(``),[Te,Ee]=(0,l.useState)(``),[De,Oe]=(0,l.useState)(!1),[ke,Ae]=(0,l.useState)([{name:`Projects - 5`,cost_center_name:`Projects`}]),[je,Me]=(0,l.useState)(`Projects - 5`),[Ne,Pe]=(0,l.useState)(``),[Fe,Ie]=(0,l.useState)(!1),Le=(0,l.useRef)(e.onSearchCustomers),Re=(0,l.useRef)(e.onSearchContacts),ze=(0,l.useRef)(e.onSearchAccountManagers),Be=(0,l.useRef)(e.onLoadCostCenters),Ve=(0,l.useRef)(0),He=(0,l.useRef)(0),Ue=(0,l.useRef)(0),A=(0,l.useRef)(0),We=t.trim()?`Engineering for ${t.trim()}`.slice(0,140):``,Ge=[ne,ie,se,ce,E].some(e=>e.trim()),Ke=[_e,ye,xe,Ce,Te].some(e=>e.trim()),qe=t.trim()&&(r===`new`?a.trim():u.trim())&&!e.loading;(0,l.useEffect)(()=>{Le.current=e.onSearchCustomers},[e.onSearchCustomers]),(0,l.useEffect)(()=>{Re.current=e.onSearchContacts},[e.onSearchContacts]),(0,l.useEffect)(()=>{ze.current=e.onSearchAccountManagers},[e.onSearchAccountManagers]),(0,l.useEffect)(()=>{Be.current=e.onLoadCostCenters},[e.onLoadCostCenters]),(0,l.useEffect)(()=>{let e=!0,t=Ve.current+1;return Ve.current=t,Be.current().then(n=>{!e||Ve.current!==t||(Ae(n),Me(e=>n.some(t=>t.name===e)?e:`Projects - 5`))}),()=>{e=!1}},[]),(0,l.useEffect)(()=>{if(r!==`existing`){He.current+=1,p([]);return}let e=!0,t=He.current+1;He.current=t;let n=window.setTimeout(()=>{Le.current(s).then(n=>{!e||He.current!==t||p(n)})},250);return()=>{e=!1,window.clearTimeout(n)}},[r,s]),(0,l.useEffect)(()=>{if(r!==`new`){Ue.current+=1,y([]);return}let e=!0,t=Ue.current+1;Ue.current=t;let n=window.setTimeout(()=>{ze.current(m).then(n=>{!e||Ue.current!==t||y(n)})},250);return()=>{e=!1,window.clearTimeout(n)}},[r,m]),(0,l.useEffect)(()=>{if(r!==`new`||fe!==`existing`){A.current+=1,ge([]);return}let e=!0,t=A.current+1;A.current=t;let n=window.setTimeout(()=>{Re.current(D).then(n=>{!e||A.current!==t||ge(n)})},250);return()=>{e=!1,window.clearTimeout(n)}},[r,fe,D]);async function Je(){qe&&await e.onCreateProject({project_name:t.trim(),customer_mode:r,customer_name:r===`new`?a.trim():void 0,customer:r===`existing`?u.trim():void 0,account_manager:r===`new`&&g.trim()||void 0,default_billing_currency:r===`new`?b.trim()||`USD`:void 0,default_cost_center:je.trim()||`Projects - 5`,activity_type:Fe&&Ne.trim()||void 0,customer_details:r===`new`&&ee.trim()||void 0,customer_website:r===`new`&&C.trim()||void 0,address_line1:r===`new`&&ne.trim()||void 0,address_line2:r===`new`&&ie.trim()||void 0,address_city:r===`new`&&se.trim()||void 0,address_state:r===`new`&&ce.trim()||void 0,address_country:r===`new`&&ne.trim()?ue.trim()||`United States`:void 0,address_postal_code:r===`new`&&E.trim()||void 0,contact:r===`new`&&fe===`existing`&&k.trim()||void 0,contact_first_name:r===`new`&&fe===`new`&&_e.trim()||void 0,contact_last_name:r===`new`&&fe===`new`&&ye.trim()||void 0,contact_email:r===`new`&&fe===`new`&&xe.trim()||void 0,contact_phone:r===`new`&&fe===`new`&&Ce.trim()||void 0,contact_mobile:r===`new`&&fe===`new`&&Te.trim()||void 0})&&e.onClose()}return(0,L.jsxs)(`div`,{className:`fixed inset-0 z-50 grid place-items-center p-4`,"aria-labelledby":`createProjectTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,L.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close project creation`,onClick:e.onClose}),(0,L.jsxs)(`div`,{className:`relative grid max-h-[90vh] w-full max-w-2xl gap-4 overflow-y-auto rounded-md border bg-background p-5 shadow-2xl`,children:[(0,L.jsxs)(`div`,{className:`flex items-start justify-between gap-3`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`strong`,{id:`createProjectTitle`,className:`block text-base`,children:`New ERP project`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Creates a project and links a new or existing customer.`})]}),(0,L.jsx)(z,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close project creation`,onClick:e.onClose,children:(0,L.jsx)(ae,{})})]}),(0,L.jsxs)(`div`,{className:`grid gap-3`,children:[(0,L.jsxs)(H,{children:[`Project name *`,(0,L.jsx)(V,{value:t,autoComplete:`off`,maxLength:140,placeholder:`Acme Portal`,onChange:e=>n(e.target.value)})]}),(0,L.jsxs)(`div`,{className:`grid gap-2`,children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Customer`}),(0,L.jsx)(`div`,{className:`grid grid-cols-2 gap-2`,children:[`new`,`existing`].map(e=>(0,L.jsx)(z,{type:`button`,variant:r===e?`default`:`outline`,onClick:()=>i(e),children:e===`new`?`New customer`:`Existing customer`},e))})]}),r===`new`?(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,L.jsxs)(H,{className:`md:col-span-2`,children:[`Customer name *`,(0,L.jsx)(V,{value:a,autoComplete:`off`,maxLength:140,placeholder:`Acme`,onChange:e=>o(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Account manager`,(0,L.jsx)(V,{value:m,autoComplete:`off`,placeholder:`Search @508.dev user`,onChange:e=>{h(e.target.value),_(``)}})]}),m.trim().length>=2?(0,L.jsx)(`div`,{className:`grid max-h-40 gap-2 overflow-y-auto rounded-md border p-2 md:col-span-2`,children:v.length?v.map(e=>{let t=e.email||e.name||``;return(0,L.jsxs)(`label`,{className:`flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-secondary`,children:[(0,L.jsx)(`input`,{type:`radio`,name:`erpAccountManager`,value:t,checked:g===t,onChange:()=>{_(t),h(t)}}),(0,L.jsxs)(`span`,{className:`grid gap-0.5 text-sm`,children:[(0,L.jsx)(`strong`,{children:e.full_name||t}),(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:t})]})]},t)}):(0,L.jsx)(`span`,{className:`px-2 py-3 text-sm text-muted-foreground`,children:`No enabled @508.dev users found.`})}):null]}):(0,L.jsxs)(`div`,{className:`grid gap-3`,children:[(0,L.jsxs)(H,{children:[`Find customer *`,(0,L.jsx)(V,{value:s,autoComplete:`off`,placeholder:`Search customer`,onChange:e=>c(e.target.value)})]}),(0,L.jsx)(`div`,{className:`grid max-h-48 gap-2 overflow-y-auto rounded-md border p-2`,children:f.length?f.map(e=>{let t=e.name||e.customer_name||``;return(0,L.jsxs)(`label`,{className:`flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-secondary`,children:[(0,L.jsx)(`input`,{type:`radio`,name:`erpCustomer`,value:t,checked:u===t,onChange:()=>d(t)}),(0,L.jsxs)(`span`,{className:`grid gap-0.5 text-sm`,children:[(0,L.jsx)(`strong`,{children:e.customer_name||t}),(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:[t,e.default_currency].filter(Boolean).join(` | `)})]})]},t)}):(0,L.jsx)(`span`,{className:`px-2 py-3 text-sm text-muted-foreground`,children:`Search at least two characters.`})})]}),r===`new`?(0,L.jsxs)(L.Fragment,{children:[(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,L.jsxs)(H,{className:`md:col-span-2`,children:[`Customer details`,(0,L.jsx)(`textarea`,{value:ee,className:`min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`,maxLength:2e3,placeholder:`More information`,onChange:e=>S(e.target.value)})]}),(0,L.jsxs)(H,{className:`md:col-span-2`,children:[`Website`,(0,L.jsx)(V,{value:C,autoComplete:`url`,placeholder:`https://example.com`,onChange:e=>te(e.target.value)})]})]}),(0,L.jsxs)(`div`,{className:`grid gap-3`,children:[(0,L.jsxs)(`div`,{className:`flex items-center justify-between gap-3`,children:[(0,L.jsx)(`strong`,{className:`text-sm text-foreground`,children:`Contact`}),(0,L.jsx)(`div`,{className:`grid grid-cols-2 gap-2`,children:[`new`,`existing`].map(e=>(0,L.jsx)(z,{type:`button`,size:`sm`,variant:fe===e?`default`:`outline`,onClick:()=>pe(e),children:e===`new`?`New`:`Existing`},e))})]}),fe===`new`?(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,L.jsxs)(H,{children:[`First name `,Ke?`*`:``,(0,L.jsx)(V,{value:_e,autoComplete:`given-name`,onChange:e=>ve(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Last name`,(0,L.jsx)(V,{value:ye,autoComplete:`family-name`,onChange:e=>be(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Email`,(0,L.jsx)(V,{value:xe,type:`email`,autoComplete:`email`,onChange:e=>Se(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Phone`,(0,L.jsx)(V,{value:Ce,type:`tel`,autoComplete:`tel`,onChange:e=>we(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Mobile`,(0,L.jsx)(V,{value:Te,type:`tel`,autoComplete:`tel`,onChange:e=>Ee(e.target.value)})]})]}):(0,L.jsxs)(`div`,{className:`grid gap-3`,children:[(0,L.jsxs)(H,{children:[`Find contact`,(0,L.jsx)(V,{value:D,autoComplete:`off`,placeholder:`Search name or email`,onChange:e=>O(e.target.value)})]}),(0,L.jsx)(`div`,{className:`grid max-h-48 gap-2 overflow-y-auto rounded-md border p-2`,children:he.length?he.map(e=>{let t=e.name||``,n=e.full_name||t,r=[{key:`company`,value:e.company_name},{key:`email`,value:e.email_id},{key:`phone`,value:e.phone},{key:`mobile`,value:e.mobile_no}].filter(e=>!!e.value);return(0,L.jsxs)(`label`,{className:`flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-secondary`,children:[(0,L.jsx)(`input`,{type:`radio`,name:`erpContact`,value:t,checked:k===t,onChange:()=>me(t)}),(0,L.jsxs)(`span`,{className:`grid gap-0.5 text-sm`,children:[(0,L.jsx)(`strong`,{children:(0,L.jsx)(wn,{value:n,query:D})}),r.length?(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:r.map((e,t)=>(0,L.jsxs)(`span`,{children:[t>0?` | `:``,(0,L.jsx)(wn,{value:e.value,query:D})]},e.key))}):null]})]},t)}):(0,L.jsx)(`span`,{className:`px-2 py-3 text-sm text-muted-foreground`,children:`Search at least two characters.`})})]})]}),(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,L.jsx)(`strong`,{className:`text-sm text-foreground md:col-span-2`,children:`Address`}),(0,L.jsxs)(H,{className:`md:col-span-2`,children:[`Address line 1 `,Ge?`*`:``,(0,L.jsx)(V,{value:ne,autoComplete:`address-line1`,onChange:e=>re(e.target.value)})]}),(0,L.jsxs)(H,{className:`md:col-span-2`,children:[`Address line 2`,(0,L.jsx)(V,{value:ie,autoComplete:`address-line2`,onChange:e=>oe(e.target.value)})]}),(0,L.jsxs)(H,{children:[`City`,(0,L.jsx)(V,{value:se,autoComplete:`address-level2`,onChange:e=>w(e.target.value)})]}),(0,L.jsxs)(H,{children:[`State`,(0,L.jsx)(V,{value:ce,autoComplete:`address-level1`,onChange:e=>le(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Postal code`,(0,L.jsx)(V,{value:E,autoComplete:`postal-code`,onChange:e=>de(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Country`,(0,L.jsx)(V,{value:ue,autoComplete:`country-name`,onChange:e=>T(e.target.value)})]})]})]}):null,(0,L.jsxs)(`div`,{className:`grid gap-3 rounded-md border p-3`,children:[(0,L.jsx)(z,{type:`button`,variant:`outline`,onClick:()=>Oe(e=>!e),children:De?`Hide advanced`:`Show advanced`}),De?(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[r===`new`?(0,L.jsxs)(H,{children:[`Billing currency`,(0,L.jsx)(V,{value:b,autoComplete:`off`,maxLength:3,onChange:e=>x(e.target.value.toUpperCase())})]}):null,(0,L.jsxs)(H,{children:[`Cost center`,(0,L.jsx)(zt,{value:je,onChange:e=>Me(e.target.value),children:ke.map(e=>{let t=e.name||``;return(0,L.jsx)(`option`,{value:t,children:[t,e.company].filter(Boolean).join(` | `)},t)})})]}),(0,L.jsxs)(H,{children:[`Activity type`,(0,L.jsx)(V,{value:Fe?Ne:We,autoComplete:`off`,maxLength:140,placeholder:We||`Engineering for project`,onChange:e=>{Ie(!0),Pe(e.target.value)}})]})]}):null]})]}),(0,L.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[(0,L.jsx)(z,{type:`button`,variant:`outline`,onClick:e.onClose,children:`Cancel`}),(0,L.jsx)(z,{type:`button`,disabled:!qe,onClick:()=>void Je(),children:`Create project`})]})]})]})}function Vn(e){let t=e.project,n=t.roster_members||[],[r,i]=(0,l.useState)(``),[a,o]=(0,l.useState)([]),[s,c]=(0,l.useState)(``),[u,d]=(0,l.useState)(``),[f,p]=(0,l.useState)(``),[h,g]=(0,l.useState)(``),_=[t.actual_start_date||t.expected_start_date,t.actual_end_date||t.expected_end_date].filter(Boolean).map(e=>In(e)).join(` to `)||`Not set`,y=typeof t.percent_complete==`number`?`${Math.round(t.percent_complete)}%`:`Not set`,b=a.find(e=>e.candidate_id===s),x=r.trim().includes(`@`)?r.trim().length>=5:r.trim().length>=3,ee=!!(u.trim()||f.trim()||h.trim()),S=Rn(f),C=Rn(h),te=!!((f.trim()||h.trim())&&!u.trim()),re=!!(u.trim()&&(!f.trim()||!h.trim())),ae=!!(f.trim()&&S===void 0)||!!(h.trim()&&C===void 0),oe=te||re||ae||S!==void 0&&S<0||C!==void 0&&C<0,se=ee&&!oe?{activity_type:u.trim(),billing_rate:S,costing_rate:C}:void 0;(0,l.useEffect)(()=>{if(!e.canWrite)return;let t=r.trim();if(s&&b&&t===(b.email||b.label||``))return;if(s&&c(``),!(t.includes(`@`)?t.length>=5:t.length>=3)){o([]);return}let n=new AbortController,i=window.setTimeout(()=>{G(`/dashboard/api/project-member-candidates?query=${encodeURIComponent(t)}`,{signal:n.signal}).then(e=>o(e)).catch(e=>{e instanceof DOMException&&e.name===`AbortError`||o([])})},500);return()=>{n.abort(),window.clearTimeout(i)}},[r,e.canWrite,b,s]);function w(e){c(e.candidate_id),i(e.email||e.label||e.full_name||r)}return(0,L.jsxs)(L.Fragment,{children:[(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-start`,children:[(0,L.jsxs)(z,{type:`button`,variant:`outline`,onClick:e.onBack,children:[(0,L.jsx)(m,{}),`Projects`]}),(0,L.jsxs)(`div`,{className:`min-w-0`,children:[(0,L.jsx)(Lt,{children:t.display_name}),(0,L.jsxs)(`div`,{className:`mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground`,children:[(0,L.jsx)(R,{variant:Fn(t.source_status),children:t.source_status||`Unknown`}),t.erpnext_project_id?(0,L.jsx)(`span`,{className:`font-mono`,children:t.erpnext_project_id}):null,t.last_synced_at?(0,L.jsxs)(`span`,{children:[`Synced `,Gt(t.last_synced_at)]}):null]})]}),(0,L.jsxs)(`div`,{className:`flex flex-wrap justify-start gap-2 md:justify-end`,children:[e.canWrite?(0,L.jsxs)(zt,{className:`w-[160px]`,"aria-label":`Status for ${t.display_name}`,value:t.source_status||``,disabled:e.loading[`project:${t.id}:status`],onChange:n=>e.onUpdateStatus(t.id,n.target.value),children:[(0,L.jsx)(`option`,{value:`Open`,children:`Open`}),(0,L.jsx)(`option`,{value:`Completed`,children:`Completed`}),(0,L.jsx)(`option`,{value:`Cancelled`,children:`Cancelled`})]}):null,t.erpnext_project_url?(0,L.jsxs)(`a`,{className:`inline-flex min-h-9 items-center justify-center gap-2 rounded-md border bg-secondary px-3 text-sm font-semibold`,href:t.erpnext_project_url,target:`_blank`,rel:`noreferrer`,children:[(0,L.jsx)(v,{className:`size-4`}),`ERP project`]}):null,t.customer_erpnext_url?(0,L.jsxs)(`a`,{className:`inline-flex min-h-9 items-center justify-center gap-2 rounded-md border bg-secondary px-3 text-sm font-semibold`,href:t.customer_erpnext_url,target:`_blank`,rel:`noreferrer`,children:[(0,L.jsx)(v,{className:`size-4`}),`ERP customer`]}):null]})]})}),(0,L.jsxs)(Rt,{className:`grid gap-4 md:grid-cols-2 lg:grid-cols-4`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Customer`}),(0,L.jsx)(`strong`,{className:`block`,children:t.customer||`None`})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Timeline`}),(0,L.jsx)(`strong`,{className:`block`,children:_})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Progress`}),(0,L.jsx)(`strong`,{className:`block`,children:y})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Linked Gigs`}),(0,L.jsx)(`strong`,{className:`block`,children:t.linked_engagement_count||0})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`ERP Type`}),(0,L.jsx)(`div`,{className:`mt-1`,children:t.project_type?(0,L.jsx)(R,{variant:`neutral`,children:t.project_type}):(0,L.jsx)(`strong`,{className:`block`,children:`Not set`})})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`ERP Modified`}),(0,L.jsx)(`strong`,{className:`block`,children:Gt(t.source_modified_at)})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Cache ID`}),(0,L.jsx)(`strong`,{className:`block break-all font-mono text-xs`,children:t.id})]})]})]}),(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Project roster`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:n.length?`${n.length} synced ERP user${n.length===1?``:`s`}`:`No ERP roster`})]}),e.canWrite?(0,L.jsxs)(Rt,{className:`grid gap-3 border-b md:grid-cols-[minmax(260px,1fr)_minmax(180px,.7fr)_minmax(130px,.45fr)_minmax(130px,.45fr)_auto_auto] md:items-end`,children:[(0,L.jsxs)(`div`,{className:`relative`,children:[(0,L.jsxs)(H,{children:[`Person search`,(0,L.jsx)(V,{value:r,autoComplete:`off`,placeholder:`Search @508.dev person`,onChange:e=>i(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),a.length===1&&w(a[0]))}})]}),x&&!s?(0,L.jsx)(`div`,{className:`absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border bg-background shadow-lg`,children:a.length?a.map(e=>(0,L.jsxs)(`button`,{type:`button`,className:`grid w-full gap-0.5 px-3 py-2 text-left hover:bg-secondary focus:bg-secondary focus:outline-none`,onClick:()=>w(e),children:[(0,L.jsx)(`span`,{className:`truncate text-sm font-bold`,children:e.label||e.full_name||e.email||`Person`}),(0,L.jsx)(`span`,{className:`truncate text-xs text-muted-foreground`,children:[e.email,e.sources?.join(`, `)].filter(Boolean).join(` | `)})]},e.candidate_id)):(0,L.jsx)(`div`,{className:`px-3 py-2 text-sm text-muted-foreground`,children:`No verified @508.dev results`})}):null]}),(0,L.jsxs)(H,{children:[`Activity Type`,(0,L.jsx)(V,{value:u,autoComplete:`off`,placeholder:`Optional rate step`,onChange:e=>d(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Billing rate`,(0,L.jsx)(V,{value:f,inputMode:`decimal`,autoComplete:`off`,placeholder:`USD/hr`,onChange:e=>p(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Costing rate`,(0,L.jsx)(V,{value:h,inputMode:`decimal`,autoComplete:`off`,placeholder:`USD/hr`,onChange:e=>g(e.target.value)})]}),(0,L.jsxs)(z,{type:`button`,variant:`outline`,disabled:e.loading[`project:${t.id}:user`]||!s||!b?.email||oe,onClick:()=>void e.onAddUser(t.id,b?.email||r,s,se).then(e=>{e&&(i(``),o([]),c(``),d(``),p(``),g(``))}),children:[(0,L.jsx)(ie,{}),`Add ERP user`]}),(0,L.jsxs)(z,{type:`button`,variant:`outline`,disabled:e.loading[`project:${t.id}:historical`]||!r.trim(),onClick:()=>void e.onAddHistoricalMember(t.id,r).then(e=>{e&&i(``)}),children:[(0,L.jsx)(ie,{}),`Add historical`]})]}):null,(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{className:`min-w-[860px]`,"aria-label":`Project roster`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(U,{children:`Name`}),(0,L.jsx)(U,{children:`Email`}),(0,L.jsx)(U,{children:`ERP user`}),(0,L.jsx)(U,{children:`Links`}),(0,L.jsx)(U,{children:`Source`}),(0,L.jsx)(U,{children:`Last seen`}),e.canWrite?(0,L.jsx)(U,{children:`Actions`}):null]})}),(0,L.jsx)(Ht,{children:n.length?n.map(n=>{let r=Ln(n),i=n.source_user_id||n.email||``,a=n.roster_kind===`historical`||n.source===`manual`;return(0,L.jsxs)(Ut,{children:[(0,L.jsx)(W,{children:(0,L.jsx)(`strong`,{children:n.full_name||n.email||n.source_user_id})}),(0,L.jsx)(W,{children:n.email||`None`}),(0,L.jsx)(W,{className:`font-mono text-xs`,children:n.erpnext_user_url?(0,L.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:n.erpnext_user_url,target:`_blank`,rel:`noreferrer`,children:[n.source_user_id||`ERP user`,(0,L.jsx)(v,{className:`size-3.5`})]}):n.source_user_id||`Unknown`}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[n.supplier_erpnext_url?(0,L.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:n.supplier_erpnext_url,target:`_blank`,rel:`noreferrer`,children:[`Supplier`,(0,L.jsx)(v,{className:`size-3.5`})]}):null,n.crm_contact_id&&e.crmContactUrl(n.crm_contact_id)?(0,L.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:e.crmContactUrl(n.crm_contact_id),target:`_blank`,rel:`noreferrer`,children:[`CRM`,(0,L.jsx)(v,{className:`size-3.5`})]}):null,!n.supplier_erpnext_url&&!(n.crm_contact_id&&e.crmContactUrl(n.crm_contact_id))?(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:`None`}):null]})}),(0,L.jsx)(W,{children:n.roster_kind||n.source||`ERP`}),(0,L.jsx)(W,{children:Gt(n.last_seen_at)}),e.canWrite?(0,L.jsx)(W,{children:(0,L.jsxs)(z,{type:`button`,variant:`outline`,size:`sm`,disabled:!i||e.loading[`project:${t.id}:${a?`historical`:`user`}`],onClick:()=>{window.confirm(`Remove ${r} from this project roster?`)&&(a?e.onRemoveHistoricalMember(t.id,i):e.onRemoveUser(t.id,i))},children:[(0,L.jsx)(ne,{}),`Remove`]})}):null]},`${n.source||``}:${n.source_user_id||n.email}`)}):(0,L.jsx)(Ut,{children:(0,L.jsx)(W,{colSpan:e.canWrite?7:6,className:`text-sm text-muted-foreground`,children:`No roster rows have been synced for this project.`})})})]})})]})]})}function Hn(e){let t=e.gigs.reduce((t,n)=>(t.total+=1,t.applications+=Number(n.application_count||0),t.interested+=Number(n.interested_count||0),Pn(n,e.staleDays)!==null&&(t.stale+=1),t),{total:0,applications:0,interested:0,stale:0}),n=(0,L.jsxs)(It,{className:`grid gap-3 p-4 md:grid-cols-[minmax(160px,1fr)_auto_auto_auto] md:items-end`,children:[(0,L.jsxs)(H,{children:[`Status`,(0,L.jsxs)(zt,{id:`gigStatus`,value:e.status,onChange:t=>e.setStatus(t.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`Any status`}),An.map(e=>(0,L.jsx)(`option`,{value:e,children:Mn(e)},e))]})]}),e.canIncludeHistorical?(0,L.jsxs)(`label`,{className:`flex min-h-9 items-center gap-2 text-xs font-bold text-muted-foreground`,children:[(0,L.jsx)(`input`,{type:`checkbox`,checked:e.includeHistorical,onChange:t=>e.setIncludeHistorical(t.target.checked)}),`Include historical`]}):null,(0,L.jsxs)(z,{id:`refreshGigs`,type:`button`,onClick:e.onRefresh,disabled:e.loading.gigs,children:[(0,L.jsx)(S,{}),`Refresh gigs`]}),e.gigs.length>=e.limit?(0,L.jsx)(z,{type:`button`,variant:`outline`,onClick:()=>e.setLimit(Math.min(e.limit+100,500)),disabled:e.loading.gigs||e.limit>=500,children:`Load more`}):null]}),r=e.selectedGigId?e.loading[`gig:${e.selectedGigId}:detail`]:!1;return e.selectedGigId&&!e.selectedGig&&(e.loading.gigs||r)?(0,L.jsxs)(L.Fragment,{children:[n,(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsx)(Lt,{children:`Gig detail`})}),(0,L.jsx)(Rt,{className:`text-sm text-muted-foreground`,children:`Loading gig.`})]})]}):e.selectedGigId&&!e.selectedGig?(0,L.jsxs)(L.Fragment,{children:[n,(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsx)(Lt,{children:`Gig detail`})}),(0,L.jsxs)(Rt,{className:`grid gap-3`,children:[(0,L.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`This gig is not in the current result set. Clear filters or refresh the gig list.`}),(0,L.jsxs)(z,{type:`button`,variant:`outline`,onClick:e.onCloseGig,children:[(0,L.jsx)(m,{}),`Back to gigs`]})]})]})]}):e.selectedGig?(0,L.jsxs)(L.Fragment,{children:[n,(0,L.jsx)(Gn,{gig:e.selectedGig,loading:e.loading,canWrite:e.canWrite,crmContactUrl:e.crmContactUrl,crmAttachmentUrl:e.crmAttachmentUrl,staleDays:e.staleDays,onBack:e.onCloseGig,onUpdateStatus:e.onUpdateStatus,onAddApplication:e.onAddApplication,onUpdateApplicationStatus:e.onUpdateApplicationStatus})]}):(0,L.jsxs)(L.Fragment,{children:[n,(0,L.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-4`,"aria-label":`Gig summary`,children:[(0,L.jsx)(Sn,{id:`gigMetricTotal`,label:`Gigs`,value:t.total}),(0,L.jsx)(Sn,{id:`gigMetricCandidates`,label:`Candidates`,value:t.applications}),(0,L.jsx)(Sn,{id:`gigMetricInterested`,label:`Interested`,value:t.interested}),(0,L.jsx)(Sn,{id:`gigMetricStale`,label:`Stale recruiting`,value:t.stale})]}),(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Discord gigs`}),(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center justify-end gap-2`,children:[(0,L.jsxs)(z,{type:`button`,variant:`ghost`,size:`sm`,onClick:()=>e.onSort(`activity`),"aria-label":`Sort gigs by activity`,children:[`Activity`,` `,e.sort.key===`activity`?e.sort.direction===`asc`?`↑`:`↓`:``]}),(0,L.jsxs)(z,{type:`button`,variant:`ghost`,size:`sm`,onClick:()=>e.onSort(`title`),"aria-label":`Sort gigs by title`,children:[`Title `,e.sort.key===`title`?e.sort.direction===`asc`?`↑`:`↓`:``]}),(0,L.jsx)(`span`,{id:`gigsStatus`,className:`text-sm text-muted-foreground`,children:e.loading.gigs?`Loading`:`${e.gigs.length} shown`})]})]}),(0,L.jsx)(Cn,{hidden:e.gigs.length!==0,children:`No gigs match this view.`}),(0,L.jsx)(`div`,{id:`gigsBody`,className:I(`grid gap-3 p-4`,e.gigs.length===0&&`hidden`),children:e.gigs.map(t=>(0,L.jsx)(Un,{gig:t,loading:e.loading,canWrite:e.canWrite,staleDays:e.staleDays,onOpenGig:e.onOpenGig,onUpdateStatus:e.onUpdateStatus},t.id))})]})]})}function Un({gig:e,loading:t,canWrite:n,onOpenGig:r,onUpdateStatus:i,staleDays:a}){let o=Array.isArray(e.applications)?e.applications:[],s=e.status===`recruiting`,c=e.discord_guild_id&&e.discord_thread_id?`https://discord.com/channels/${encodeURIComponent(e.discord_guild_id)}/${encodeURIComponent(e.discord_thread_id)}`:``,l=Pn(e,a);return(0,L.jsxs)(`article`,{className:I(`grid gap-4 rounded-md border bg-background p-4 lg:grid-cols-[minmax(0,1fr)_220px_180px] lg:items-start`,!s&&`border-l-4 border-l-muted-foreground/60 bg-secondary/45`),children:[(0,L.jsxs)(`div`,{className:`min-w-0`,children:[(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[(0,L.jsx)(`a`,{className:`text-base font-extrabold text-primary`,href:`/dashboard/gigs/${encodeURIComponent(e.id)}`,onClick:t=>{t.preventDefault(),r(e.id)},children:e.title||`Untitled gig`}),(0,L.jsx)(R,{variant:e.status===`filled`?`succeeded`:e.status===`lost`?`failed`:s?`queued`:`neutral`,children:e.status_label||Mn(e.status)}),s?null:(0,L.jsx)(R,{variant:`neutral`,children:`Not recruiting`}),l===null?null:(0,L.jsxs)(R,{variant:`running`,children:[l,`d stale`]})]}),(0,L.jsxs)(`div`,{className:`mt-2 flex flex-wrap gap-1.5`,children:[e.posting_type?(0,L.jsx)(R,{variant:`neutral`,children:Mn(e.posting_type)}):null,e.discord_channel_name?(0,L.jsxs)(R,{variant:`neutral`,children:[`#`,e.discord_channel_name]}):null,(e.required_skills||[]).slice(0,5).map(e=>(0,L.jsx)(R,{variant:`queued`,children:e},e)),(e.preferred_skills||[]).slice(0,3).map(e=>(0,L.jsx)(R,{variant:`neutral`,children:e},e))]}),(0,L.jsxs)(`div`,{className:`mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground`,children:[(0,L.jsxs)(`span`,{children:[`Activity `,Gt(Nn(e))||`unknown`]}),(0,L.jsxs)(`span`,{children:[`Posted `,Gt(e.posted_at)||`unknown`]}),c?(0,L.jsx)(`a`,{className:`font-extrabold text-primary`,href:c,target:`_blank`,rel:`noreferrer`,children:`Open Discord thread`}):null]})]}),(0,L.jsxs)(`div`,{className:`grid grid-cols-2 gap-2 text-sm lg:grid-cols-1`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`block text-xs font-bold text-muted-foreground`,children:`People`}),(0,L.jsx)(`strong`,{children:e.application_count||o.length}),(0,L.jsxs)(`span`,{className:`ml-2 text-muted-foreground`,children:[Number(e.interested_count||0),` interested`]})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`block text-xs font-bold text-muted-foreground`,children:`Top candidates`}),(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:o.slice(0,3).map(e=>Wn(e)).join(`, `)||`None yet`})]})]}),(0,L.jsxs)(`div`,{className:`grid gap-2`,children:[n?(0,L.jsx)(zt,{"aria-label":`Status for ${e.title||`gig`}`,value:e.status,disabled:t[`gig:${e.id}:status`],onChange:t=>i(e.id,t.target.value),children:An.map(e=>(0,L.jsx)(`option`,{value:e,children:Mn(e)},e))}):null,(0,L.jsx)(z,{type:`button`,onClick:()=>r(e.id),children:`Manage people`})]})]})}function Wn(e){return e.name||e.email_508||e.discord_username||(typeof e.evaluation?.discord_username==`string`?e.evaluation.discord_username:``)||`Candidate`}function Gn({gig:e,loading:t,canWrite:n,crmContactUrl:r,crmAttachmentUrl:i,staleDays:a,onBack:o,onUpdateStatus:s,onAddApplication:c,onUpdateApplicationStatus:u}){let[d,f]=(0,l.useState)(``),p=Array.isArray(e.applications)?e.applications:[],h=e.status===`recruiting`,g=e.discord_guild_id&&e.discord_thread_id?`https://discord.com/channels/${encodeURIComponent(e.discord_guild_id)}/${encodeURIComponent(e.discord_thread_id)}`:``,_=Pn(e,a);return(0,L.jsxs)(`div`,{className:`grid gap-5`,children:[(0,L.jsxs)(It,{className:I(!h&&`border-l-4 border-l-muted-foreground/60 bg-secondary/35`),children:[(0,L.jsxs)(B,{className:`items-start`,children:[(0,L.jsxs)(`div`,{className:`grid gap-2`,children:[(0,L.jsxs)(z,{type:`button`,variant:`ghost`,size:`sm`,className:`w-fit`,onClick:o,children:[(0,L.jsx)(m,{}),`Back to gigs`]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(Lt,{className:`text-xl`,children:e.title||`Untitled gig`}),(0,L.jsxs)(`div`,{className:`mt-2 flex flex-wrap gap-1.5`,children:[(0,L.jsx)(R,{variant:e.status===`filled`?`succeeded`:e.status===`lost`?`failed`:h?`queued`:`neutral`,children:e.status_label||Mn(e.status)}),h?null:(0,L.jsx)(R,{variant:`neutral`,children:`Not recruiting`}),_===null?null:(0,L.jsxs)(R,{variant:`running`,children:[_,`d stale`]}),e.posting_type?(0,L.jsx)(R,{variant:`neutral`,children:Mn(e.posting_type)}):null,e.discord_channel_name?(0,L.jsxs)(R,{variant:`neutral`,children:[`#`,e.discord_channel_name]}):null,(e.required_skills||[]).map(e=>(0,L.jsx)(R,{variant:`queued`,children:e},e)),(e.preferred_skills||[]).map(e=>(0,L.jsx)(R,{variant:`neutral`,children:e},e))]})]})]}),(0,L.jsxs)(`div`,{className:`grid min-w-[190px] gap-2`,children:[n?(0,L.jsxs)(H,{children:[`Gig status`,(0,L.jsx)(zt,{"aria-label":`Status for ${e.title||`gig`}`,value:e.status,disabled:t[`gig:${e.id}:status`],onChange:t=>s(e.id,t.target.value),children:An.map(e=>(0,L.jsx)(`option`,{value:e,children:Mn(e)},e))})]}):null,g?(0,L.jsxs)(`a`,{className:`inline-flex min-h-9 items-center justify-center gap-2 rounded-md border bg-secondary px-3 text-sm font-semibold`,href:g,target:`_blank`,rel:`noreferrer`,children:[(0,L.jsx)(v,{className:`size-4`}),`Discord thread`]}):null]})]}),(0,L.jsxs)(Rt,{className:`grid gap-4 lg:grid-cols-[1fr_1fr_1fr]`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Activity`}),(0,L.jsx)(`strong`,{className:`block`,children:Gt(Nn(e))||`unknown`}),(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[`Posted `,Gt(e.posted_at)||`unknown`]})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`People`}),(0,L.jsx)(`strong`,{className:`block`,children:e.application_count||p.length}),(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[Number(e.interested_count||0),` interested`]})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Discord`}),(0,L.jsx)(`strong`,{className:`block`,children:e.discord_channel_name||`No channel`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.discord_thread_id?`Thread ${e.discord_thread_id}`:`No thread`})]})]})]}),(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`People`}),(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[p.length,` candidate`,p.length===1?``:`s`]})]}),n?(0,L.jsxs)(`form`,{className:`grid gap-2 border-t p-4 md:grid-cols-[minmax(220px,1fr)_auto]`,onSubmit:t=>{t.preventDefault(),c(e.id,d).then(e=>{e&&f(``)})},children:[(0,L.jsxs)(H,{className:`min-w-0`,children:[`CRM profile`,(0,L.jsx)(V,{value:d,onChange:e=>f(e.target.value),placeholder:`https://crm.508.dev/#Contact/view/...`,"aria-label":`CRM profile for candidate`})]}),(0,L.jsxs)(z,{type:`submit`,className:`self-end`,disabled:t[`gig:${e.id}:addCandidate`]||!d.trim(),children:[(0,L.jsx)(re,{}),`Add candidate`]})]}):null,(0,L.jsx)(Cn,{hidden:p.length!==0,children:`No suggested or interested people yet.`}),(0,L.jsx)(`div`,{className:I(`grid gap-3 p-4`,p.length===0&&`hidden`),children:p.map(a=>(0,L.jsx)(Kn,{gigId:e.id,application:a,loading:t,canWrite:n,crmContactUrl:r,crmAttachmentUrl:i,onUpdateApplicationStatus:u},a.id))})]})]})}function Kn({gigId:e,application:t,loading:n,canWrite:r,crmContactUrl:i,crmAttachmentUrl:a,onUpdateApplicationStatus:o}){let s=Wn(t),c=i(t.crm_contact_id),l=a(t.latest_resume_id),u=typeof t.fit_score==`number`?`${Math.round(t.fit_score)}/100`:typeof t.match_score==`number`?t.match_score.toFixed(1):``,d=typeof t.evaluation?.llm_summary==`string`?t.evaluation.llm_summary:``;return(0,L.jsxs)(`div`,{className:`grid gap-2 rounded-md border bg-background p-2`,children:[(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[c?(0,L.jsx)(`a`,{className:`font-extrabold text-primary`,href:c,target:`_blank`,rel:`noopener noreferrer`,children:s}):(0,L.jsx)(`strong`,{children:s}),(0,L.jsx)(R,{variant:t.status===`interested`?`succeeded`:`neutral`,children:Mn(t.status)}),(0,L.jsx)(R,{variant:`neutral`,children:Mn(t.source||`manual_add`)}),u?(0,L.jsxs)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:[`Fit `,u]}):null,c?(0,L.jsx)(`a`,{className:`text-xs font-extrabold text-primary`,href:c,target:`_blank`,rel:`noopener noreferrer`,"aria-label":`Open ${s} CRM profile`,children:`CRM profile`}):null,l?(0,L.jsx)(`a`,{className:`text-xs font-extrabold text-primary`,href:l,target:`_blank`,rel:`noopener noreferrer`,children:`Resume`}):null]}),d?(0,L.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:d}):null,r?(0,L.jsx)(zt,{"aria-label":`Candidate status for ${s}`,value:t.status||`suggested`,disabled:n[`application:${t.id}:status`],onChange:n=>o(e,t.id,n.target.value),children:jn.map(e=>(0,L.jsx)(`option`,{value:e,children:Mn(e)},e))}):null]})}function qn(e){let t=sn[e.peopleFilterKind]?.options||[];return(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`People lookup`}),(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center justify-end gap-2`,children:[e.canSync?(0,L.jsxs)(z,{id:`syncPeople`,"data-permission":`people:sync`,type:`button`,onClick:e.onSync,disabled:e.loading.syncPeople,children:[(0,L.jsx)(S,{}),`Sync people`]}):null,e.crmBaseUrl?(0,L.jsx)(`a`,{id:`crmHomeLink`,className:`text-sm font-extrabold text-primary`,href:e.crmBaseUrl,target:`_blank`,rel:`noreferrer`,children:`Open CRM`}):null,(0,L.jsx)(`span`,{id:`peopleStatus`,className:`text-sm text-muted-foreground`,children:e.loading.people?`Loading`:`${e.people.length} shown`})]})]}),(0,L.jsxs)(`div`,{className:`grid gap-3 border-b p-4 md:grid-cols-[minmax(0,1fr)_auto]`,children:[(0,L.jsxs)(H,{children:[`Search CRM people cache`,(0,L.jsx)(V,{id:`peopleQuery`,value:e.peopleQuery,autoComplete:`off`,placeholder:`Name, email, CRM id, Discord, resume`,onChange:t=>e.setPeopleQuery(t.target.value),onKeyDown:t=>{t.key===`Enter`&&e.onSearch()}})]}),(0,L.jsxs)(z,{id:`searchPeople`,type:`button`,onClick:e.onSearch,disabled:e.loading.people,children:[(0,L.jsx)(C,{}),`Search`]})]}),(0,L.jsxs)(`div`,{className:`grid gap-3 border-b bg-background p-4 md:grid-cols-[minmax(120px,.7fr)_minmax(150px,1fr)_minmax(150px,1fr)_auto]`,children:[(0,L.jsxs)(H,{children:[`Member`,(0,L.jsxs)(zt,{id:`peopleMember`,value:e.peopleMember,onChange:t=>e.setPeopleMember(t.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`Any`}),(0,L.jsx)(`option`,{value:`true`,children:`Member`}),(0,L.jsx)(`option`,{value:`false`,children:`Not member`})]})]}),(0,L.jsxs)(H,{children:[`Add filter`,(0,L.jsx)(zt,{id:`peopleFilterKind`,value:e.peopleFilterKind,disabled:e.peopleFilterKeys.length===0,onChange:t=>e.setPeopleFilterKind(t.target.value),children:e.peopleFilterKeys.map(e=>(0,L.jsx)(`option`,{value:e,children:sn[e].label},e))})]}),(0,L.jsxs)(H,{children:[`Value`,(0,L.jsx)(zt,{id:`peopleFilterValue`,value:e.peopleFilterValue,onChange:t=>e.setPeopleFilterValue(t.target.value),children:t.map(([e,t])=>(0,L.jsx)(`option`,{value:e,children:t},e))})]}),(0,L.jsx)(z,{id:`addPeopleFilter`,type:`button`,onClick:e.addFilter,disabled:e.peopleFilterKeys.length===0,children:`Add filter`}),(0,L.jsx)(`div`,{id:`activePeopleFilters`,className:`md:col-span-4`,children:(0,L.jsx)(kn,{filters:e.peopleFilters,onRemove:e.removeFilter})})]}),(0,L.jsx)(Cn,{hidden:e.people.length!==0,children:`No people match this lookup.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`peopleTable`,className:I(`min-w-[900px]`,e.people.length===0&&`hidden`),"aria-label":`People lookup results`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(xn,{className:`w-[27%]`,label:`Name`,scope:`people`,sort:e.sort,sortKey:`name`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[28%]`,label:`Status`,scope:`people`,sort:e.sort,sortKey:`status`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[20%]`,label:`Discord`,scope:`people`,sort:e.sort,sortKey:`discord`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[25%]`,label:`Resume / skills`,scope:`people`,sort:e.sort,sortKey:`resume`,onSort:(t,n)=>e.onSort(n)})]})}),(0,L.jsx)(Ht,{id:`peopleBody`,children:e.people.map(t=>{let n=t.name||t.email_508||t.email||`CRM contact`,r=e.crmContactUrl(t.crm_contact_id),i=t.profile_status||{},a=Number(i.skills_count||0),o=e.crmAttachmentUrl(t.latest_resume_id);return(0,L.jsxs)(Ut,{children:[(0,L.jsxs)(W,{children:[r?(0,L.jsx)(`a`,{className:`font-extrabold text-primary`,href:r,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${n} in CRM`,children:n}):(0,L.jsx)(`strong`,{children:n}),(0,L.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:[t.email_508||t.email,t.contact_type].filter(Boolean).join(` | `)})]}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[i.crm_active?null:(0,L.jsx)(R,{variant:`missing`,children:t.sync_status||`CRM sync issue`}),(0,L.jsx)(R,{variant:i.is_member?`succeeded`:`missing`,children:i.is_member?`Member`:`Missing Member`}),(0,L.jsx)(R,{variant:i.discord_linked?`succeeded`:`missing`,children:i.discord_linked?`Discord`:`Missing Discord`}),(0,L.jsx)(R,{variant:i.email_508?`succeeded`:`missing`,children:i.email_508?`508 email`:`Missing 508 email`}),i.latest_resume?null:(0,L.jsx)(R,{variant:`missing`,children:`Missing Resume`})]})}),(0,L.jsx)(W,{children:[t.discord_username,t.discord_user_id].filter(Boolean).join(` | `)||`Not linked`}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5`,children:[o?(0,L.jsx)(`a`,{className:`inline-flex min-h-7 items-center rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:o,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${n} resume`,children:`Resume`}):(0,L.jsx)(`span`,{children:t.latest_resume_name||t.latest_resume_id||`No resume`}),(0,L.jsx)(R,{variant:a>0?`succeeded`:`missing`,children:a>0?`Skills parsed`:`Skills not parsed`})]})})]},t.crm_contact_id||n)})})]})})]})}function Jn(e){let t=sn[e.onboardingFilterKind]?.options||[];return(0,L.jsxs)(L.Fragment,{children:[e.canWrite?(0,L.jsx)(er,{loading:e.loading.engineerSetup,onSetup:e.onSetupEngineer}):null,(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Onboarding queue`}),(0,L.jsx)(`span`,{id:`onboardingStatus`,className:`text-sm text-muted-foreground`,children:e.loading.onboarding?`Loading`:`${e.people.length} shown`})]}),(0,L.jsxs)(`div`,{className:`grid gap-3 border-b p-4 md:grid-cols-[minmax(0,1fr)_auto]`,children:[(0,L.jsxs)(H,{children:[`Search prospects`,(0,L.jsx)(V,{id:`onboardingQuery`,value:e.onboardingQuery,autoComplete:`off`,placeholder:`Name, email, Discord, onboarder`,onChange:t=>e.setOnboardingQuery(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,L.jsxs)(z,{id:`searchOnboarding`,type:`button`,onClick:e.onSearch,disabled:e.loading.onboarding,children:[(0,L.jsx)(C,{}),`Search`]})]}),(0,L.jsxs)(`div`,{className:`grid gap-3 border-b bg-background p-4 md:grid-cols-[minmax(140px,.8fr)_minmax(150px,1fr)_minmax(150px,1fr)_minmax(120px,.7fr)_auto]`,children:[(0,L.jsxs)(H,{children:[`Status`,(0,L.jsxs)(zt,{id:`onboardingState`,value:e.onboardingState,onChange:t=>e.setOnboardingState(t.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`Any state`}),ln.map(([e,t])=>(0,L.jsx)(`option`,{value:e,children:t},e))]})]}),(0,L.jsxs)(H,{children:[`Onboarder`,(0,L.jsx)(V,{id:`onboarderFilter`,value:e.onboarderFilter,autoComplete:`off`,placeholder:`Any onboarder`,onChange:t=>e.setOnboarderFilter(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,L.jsxs)(H,{children:[`Add filter`,(0,L.jsx)(zt,{id:`onboardingFilterKind`,value:e.onboardingFilterKind,disabled:e.onboardingFilterKeys.length===0,onChange:t=>e.setOnboardingFilterKind(t.target.value),children:e.onboardingFilterKeys.map(e=>(0,L.jsx)(`option`,{value:e,children:sn[e].label},e))})]}),(0,L.jsxs)(H,{children:[`Value`,(0,L.jsx)(zt,{id:`onboardingFilterValue`,value:e.onboardingFilterValue,onChange:t=>e.setOnboardingFilterValue(t.target.value),children:t.map(([e,t])=>(0,L.jsx)(`option`,{value:e,children:t},e))})]}),(0,L.jsx)(z,{id:`addOnboardingFilter`,type:`button`,onClick:e.addFilter,disabled:e.onboardingFilterKeys.length===0,children:`Add filter`}),(0,L.jsx)(`div`,{id:`activeOnboardingFilters`,className:`md:col-span-5`,children:(0,L.jsx)(kn,{filters:e.onboardingFilters,onRemove:e.removeFilter,suffix:`onboarding filter`})})]}),(0,L.jsx)(Cn,{hidden:e.people.length!==0,children:`No prospects match this queue view.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`onboardingTable`,className:I(`min-w-[1180px]`,e.people.length===0&&`hidden`),"aria-label":`Onboarding queue`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(xn,{className:`w-[20%]`,label:`Name`,scope:`onboarding`,sort:e.sort,sortKey:`name`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[13%]`,label:`Status`,scope:`onboarding`,sort:e.sort,sortKey:`onboarding_state`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[22%]`,label:`Onboarder`,scope:`onboarding`,sort:e.sort,sortKey:`onboarder`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[13%]`,label:`Updated`,scope:`onboarding`,sort:e.sort,sortKey:`updated`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(U,{className:`w-[15%]`,children:`Links`}),(0,L.jsx)(xn,{className:`w-[17%]`,label:`Needs`,scope:`onboarding`,sort:e.sort,sortKey:`profile_gaps`,onSort:(t,n)=>e.onSort(n)})]})}),(0,L.jsx)(Ht,{id:`onboardingBody`,children:e.people.map(t=>(0,L.jsx)(tr,{person:t,loading:e.loading,canWrite:e.canWrite,onAssign:e.onAssign,onStatusChange:e.onStatusChange,crmContactUrl:e.crmContactUrl,crmAttachmentUrl:e.crmAttachmentUrl},t.crm_contact_id||t.name))})]})})]})]})}var Yn=[`Female`,`Genderqueer`,`Male`,`Non-Conforming`,`Other`,`Prefer not to say`,`Transgender`],Xn=[`Company Email`,`Personal Email`,`User ID`];function Zn(e){let t=(e||``).trim().split(/\s+/).filter(Boolean);return t.length===0?{first:``,middle:``,last:``}:t.length===1?{first:t[0],middle:``,last:``}:t.length===2?{first:t[0],middle:``,last:t[1]}:{first:t[0],middle:t.slice(1,-1).join(` `),last:t[t.length-1]}}function Qn(e){let t=(e.email||``).trim();return!t||t.toLowerCase().endsWith(`@508.dev`)?``:t}function $n(e){let t=(e.email_508||``).trim();if(t)return t;let n=(e.email||``).trim();return n.toLowerCase().endsWith(`@508.dev`)?n:``}function er({loading:e,onSetup:t}){let[n,r]=(0,l.useState)(``),[i,a]=(0,l.useState)([]),[o,s]=(0,l.useState)(!1),[c,u]=(0,l.useState)(``),[d,f]=(0,l.useState)(``),[p,m]=(0,l.useState)(``),[h,g]=(0,l.useState)(``),[_,v]=(0,l.useState)(``),[y,b]=(0,l.useState)(``),[x,ee]=(0,l.useState)(``),[S,te]=(0,l.useState)(``),[ne,ie]=(0,l.useState)(``),[ae,oe]=(0,l.useState)(``),[se,w]=(0,l.useState)(``);function ce(e){let t=Zn(e.name);m(t.first),g(t.middle),v(t.last),f($n(e)),oe(Qn(e)),b(e.address_country||``),r(e.name||e.email_508||e.email||``),a([]),u(``)}async function le(){let e=n.trim();if(e){s(!0),u(``);try{a(await G(`/dashboard/api/people?${new URLSearchParams({limit:`8`,query:e}).toString()}`))}catch(e){u(mn(e,`Unable to search people`)),a([])}finally{s(!1)}}}async function ue(){let e={email:d,first_name:p,middle_name:h,last_name:_,country:y,personal_email:ae};x.trim()&&(e.gender=x),S.trim()&&(e.date_of_birth=S),ne.trim()&&(e.date_of_joining=ne),se.trim()&&(e.prefered_email=se),await t(e)&&(r(``),a([]),f(``),m(``),g(``),v(``),b(``),ee(``),te(``),ie(``),oe(``),w(``))}return(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsx)(Lt,{children:`Engineer setup`})}),(0,L.jsx)(Rt,{children:(0,L.jsxs)(`form`,{className:`grid gap-3`,onSubmit:e=>{e.preventDefault(),ue()},children:[(0,L.jsxs)(`div`,{className:`grid gap-3 border-b pb-3 md:grid-cols-[minmax(0,1fr)_auto]`,children:[(0,L.jsxs)(H,{children:[`CRM person`,(0,L.jsx)(V,{value:n,autoComplete:`off`,placeholder:`Search name or email`,onChange:e=>r(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),le())}})]}),(0,L.jsxs)(z,{type:`button`,onClick:le,disabled:o||!n.trim(),children:[(0,L.jsx)(C,{}),`Search`]}),c?(0,L.jsx)(`span`,{className:`text-sm font-semibold text-destructive`,children:c}):null,i.length>0?(0,L.jsx)(`div`,{className:`grid gap-2 md:col-span-2`,children:i.map(e=>{let t=e.name||e.email_508||e.email||e.crm_contact_id,n=[e.email_508||e.email,e.contact_type].filter(Boolean).join(` | `);return(0,L.jsxs)(`button`,{type:`button`,className:`grid rounded-md border bg-background px-3 py-2 text-left text-sm hover:border-primary`,onClick:()=>ce(e),children:[(0,L.jsx)(`strong`,{children:t}),n?(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:n}):null]},e.crm_contact_id||t)})}):null]}),(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(130px,.6fr)]`,children:[(0,L.jsxs)(H,{children:[`Company email`,(0,L.jsx)(V,{value:d,autoComplete:`off`,placeholder:`engineer@508.dev`,onChange:e=>f(e.target.value)})]}),(0,L.jsxs)(H,{children:[`First name`,(0,L.jsx)(V,{value:p,autoComplete:`off`,placeholder:`First`,onChange:e=>m(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Middle name`,(0,L.jsx)(V,{value:h,autoComplete:`off`,placeholder:`Optional`,onChange:e=>g(e.target.value)})]})]}),(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(130px,.6fr)]`,children:[(0,L.jsxs)(H,{children:[`Last name`,(0,L.jsx)(V,{value:_,autoComplete:`off`,placeholder:`Last`,onChange:e=>v(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Country`,(0,L.jsx)(V,{value:y,autoComplete:`off`,placeholder:`Taiwan`,onChange:e=>b(e.target.value)})]})]}),(0,L.jsxs)(`details`,{className:`rounded-md border bg-background p-3`,children:[(0,L.jsx)(`summary`,{className:`cursor-pointer text-sm font-extrabold`,children:`Advanced options`}),(0,L.jsxs)(`div`,{className:`mt-3 grid gap-3 md:grid-cols-2`,children:[(0,L.jsxs)(H,{children:[`Gender`,(0,L.jsxs)(zt,{value:x,onChange:e=>ee(e.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`Default`}),Yn.map(e=>(0,L.jsx)(`option`,{value:e,children:e},e))]})]}),(0,L.jsxs)(H,{children:[`Date of birth`,(0,L.jsx)(V,{value:S,type:`date`,autoComplete:`off`,onChange:e=>te(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Date of joining`,(0,L.jsx)(V,{value:ne,type:`date`,autoComplete:`off`,onChange:e=>ie(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Personal email`,(0,L.jsx)(V,{value:ae,type:`email`,autoComplete:`off`,placeholder:`Optional`,onChange:e=>oe(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Preferred contact email`,(0,L.jsxs)(zt,{value:se,onChange:e=>w(e.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`Default`}),Xn.map(e=>(0,L.jsx)(`option`,{value:e,children:e},e))]})]})]})]}),(0,L.jsx)(`div`,{className:`flex flex-wrap items-center justify-between gap-3`,children:(0,L.jsxs)(z,{id:`setupEngineer`,type:`submit`,disabled:e||!d.trim()||!p.trim(),children:[(0,L.jsx)(re,{}),`Set up engineer`]})})]})})]})}function tr({person:e,loading:t,canWrite:n,onAssign:r,onStatusChange:i,crmContactUrl:a,crmAttachmentUrl:o}){let s=e.name||e.email_508||e.email||`CRM contact`,[c,u]=(0,l.useState)(Zt(e.onboarder));(0,l.useEffect)(()=>u(Zt(e.onboarder)),[e.onboarder]);let d=dn(Jt(e)),f=e.profile_status||{},p=[[`Discord`,f.discord_linked],[`Resume`,f.latest_resume],[`Skills`,Number(f.skills_count||0)>0]].filter(([,e])=>!e),m=a(e.crm_contact_id),h=o(e.latest_resume_id);return(0,L.jsxs)(Ut,{children:[(0,L.jsxs)(W,{children:[m?(0,L.jsx)(`a`,{className:`font-extrabold text-primary`,href:m,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${s} in CRM`,children:s}):(0,L.jsx)(`strong`,{children:s}),(0,L.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:e.email_508||e.email||``})]}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`grid max-w-56 gap-2`,children:[(0,L.jsx)(R,{variant:Xt(Jt(e)),children:e.onboarding_status_label||Yt(Jt(e))}),n?(0,L.jsxs)(zt,{"aria-label":`Onboarding status for ${s}`,value:d,disabled:t[`onboarding-status:${e.crm_contact_id}`],onChange:t=>i(e.crm_contact_id,t.target.value),children:[d?null:(0,L.jsx)(`option`,{value:``,disabled:!0,children:`No status`}),cn.map(([e,t])=>(0,L.jsx)(`option`,{value:e,children:t},e))]}):null]})}),(0,L.jsx)(W,{children:(0,L.jsxs)(`form`,{className:`grid max-w-64 grid-cols-[minmax(100px,1fr)_auto] items-center gap-2`,onSubmit:t=>{t.preventDefault(),r(e.crm_contact_id,c)},children:[(0,L.jsx)(V,{"aria-label":`Onboarder for ${s}`,value:c,placeholder:`508 username`,onChange:e=>u(e.target.value)}),(0,L.jsx)(z,{type:`submit`,size:`sm`,"aria-label":`Save onboarder for ${s}`,disabled:t[`onboarder:${e.crm_contact_id}`],children:`Save`})]})}),(0,L.jsx)(W,{children:Gt(e.onboarding_updated_at)}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[h?(0,L.jsx)(`a`,{className:`inline-flex min-h-7 items-center rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:h,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${s} resume`,children:`Resume`}):null,nn(e.linkedin)?(0,L.jsx)(`a`,{className:`inline-flex min-h-7 items-center rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:nn(e.linkedin),target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${s} LinkedIn`,children:`LinkedIn`}):null,rn(e.github_username)?(0,L.jsx)(`a`,{className:`inline-flex min-h-7 items-center rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:rn(e.github_username),target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${s} GitHub`,children:e.github_username||`GitHub`}):null,!h&&!nn(e.linkedin)&&!rn(e.github_username)?`None`:null]})}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[p.map(([e])=>(0,L.jsxs)(R,{variant:`missing`,children:[`Missing `,e]},String(e))),p.length===0?`None`:null]})})]})}function nr(e){return(0,L.jsxs)(L.Fragment,{children:[(0,L.jsxs)(It,{className:`grid gap-3 p-4 md:grid-cols-4 md:items-end`,children:[(0,L.jsxs)(H,{children:[`Window`,(0,L.jsxs)(zt,{id:`minutes`,value:e.minutes,onChange:t=>e.setMinutes(t.target.value),children:[(0,L.jsx)(`option`,{value:`15`,children:`15 minutes`}),(0,L.jsx)(`option`,{value:`60`,children:`1 hour`}),(0,L.jsx)(`option`,{value:`360`,children:`6 hours`}),(0,L.jsx)(`option`,{value:`1440`,children:`24 hours`})]})]}),(0,L.jsxs)(H,{children:[`Status`,(0,L.jsxs)(zt,{id:`status`,value:e.status,onChange:t=>e.setStatus(t.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`Any status`}),(0,L.jsx)(`option`,{value:`queued`,children:`Queued`}),(0,L.jsx)(`option`,{value:`running`,children:`Running`}),(0,L.jsx)(`option`,{value:`succeeded`,children:`Succeeded`}),(0,L.jsx)(`option`,{value:`failed`,children:`Failed`}),(0,L.jsx)(`option`,{value:`dead`,children:`Dead`}),(0,L.jsx)(`option`,{value:`canceled`,children:`Canceled`})]})]}),(0,L.jsxs)(H,{children:[`Type`,(0,L.jsx)(V,{id:`jobType`,value:e.jobType,autoComplete:`off`,placeholder:`Any type`,onChange:t=>e.setJobType(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,L.jsxs)(z,{id:`refreshJobs`,type:`button`,onClick:e.onSearch,disabled:e.loading.jobs,children:[(0,L.jsx)(S,{}),`Refresh jobs`]})]}),(0,L.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-4`,"aria-label":`Job summary`,children:[(0,L.jsx)(Sn,{id:`metricTotal`,label:`Total`,value:e.jobs.length}),(0,L.jsx)(Sn,{id:`metricQueued`,label:`Queued`,value:e.jobCounts.queued||0}),(0,L.jsx)(Sn,{id:`metricRunning`,label:`Running`,value:e.jobCounts.running||0}),(0,L.jsx)(Sn,{id:`metricFailed`,label:`Failed`,value:(e.jobCounts.failed||0)+(e.jobCounts.dead||0)})]}),(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsx)(Lt,{children:`Recent jobs`})}),(0,L.jsx)(Cn,{hidden:e.jobs.length!==0,children:`No jobs match these filters.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`jobsTable`,className:I(`min-w-[980px]`,e.jobs.length===0&&`hidden`),"aria-label":`Recent jobs`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(xn,{className:`w-[22%]`,label:`Job id`,scope:`jobs`,sort:e.sort,sortKey:`job_id`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[24%]`,label:`Type`,scope:`jobs`,sort:e.sort,sortKey:`type`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[12%]`,label:`Status`,scope:`jobs`,sort:e.sort,sortKey:`status`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[12%]`,label:`Attempts`,scope:`jobs`,sort:e.sort,sortKey:`attempts`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[18%]`,label:`Updated`,scope:`jobs`,sort:e.sort,sortKey:`updated_at`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(U,{children:`Actions`})]})}),(0,L.jsx)(Ht,{id:`jobsBody`,children:e.jobs.map(t=>(0,L.jsxs)(Ut,{children:[(0,L.jsx)(W,{className:`font-mono`,children:t.job_id}),(0,L.jsx)(W,{children:t.type}),(0,L.jsx)(W,{children:(0,L.jsx)(R,{variant:t.status||`neutral`,children:t.status})}),(0,L.jsxs)(W,{children:[t.attempts,`/`,t.max_attempts]}),(0,L.jsx)(W,{children:Gt(t.updated_at)}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[(0,L.jsx)(z,{type:`button`,size:`sm`,variant:`outline`,"aria-label":`View details for ${t.type} job ${t.job_id}`,onClick:()=>e.onDetail(t.job_id),disabled:e.loading[`detail:${t.job_id}`],children:`Details`}),e.canWrite?(0,L.jsx)(z,{type:`button`,size:`sm`,"aria-label":`Rerun ${t.type} job ${t.job_id}`,onClick:()=>e.onRerun(t.job_id),disabled:e.loading[`rerun:${t.job_id}`],children:`Rerun`}):null]})})]},t.job_id))})]})})]}),e.jobDetail?(0,L.jsxs)(It,{id:`jobDetailPanel`,children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Job detail`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.jobDetail.job_id})]}),(0,L.jsxs)(Rt,{className:`grid gap-4`,children:[(0,L.jsx)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[[`Type`,e.jobDetail.type],[`Status`,e.jobDetail.status],[`Attempts`,`${e.jobDetail.attempts}/${e.jobDetail.max_attempts}`],[`Updated`,Gt(e.jobDetail.updated_at)],[`Created`,Gt(e.jobDetail.created_at)],[`Run after`,Gt(e.jobDetail.run_after)],[`Locked by`,e.jobDetail.locked_by||`None`],[`Last error`,e.jobDetail.last_error||`None`]].map(([e,t])=>(0,L.jsxs)(`div`,{className:`grid gap-1 rounded-md border bg-background p-3`,children:[(0,L.jsx)(`span`,{className:`text-[11px] font-extrabold uppercase text-muted-foreground`,children:e}),(0,L.jsx)(`strong`,{className:`break-words text-sm`,children:t})]},e))}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`h2`,{className:`mb-2 text-[15px] font-bold`,children:`Payload`}),(0,L.jsx)(`pre`,{className:`max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border bg-background p-3 font-mono text-xs`,children:qt(e.jobDetail.payload)||`No payload`})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`h2`,{className:`mb-2 text-[15px] font-bold`,children:`Result`}),(0,L.jsx)(`pre`,{className:`max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border bg-background p-3 font-mono text-xs`,children:qt(e.jobDetail.result)||`No result`})]})]})]}):null]})}function rr(e){return(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Recent audit`}),(0,L.jsxs)(z,{id:`refreshAudit`,type:`button`,variant:`outline`,onClick:e.onRefresh,disabled:e.loading.audit,children:[(0,L.jsx)(S,{}),`Refresh`]})]}),(0,L.jsx)(Cn,{hidden:e.events.length!==0,children:`No audit events found.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`auditTable`,className:I(`min-w-[760px]`,e.events.length===0&&`hidden`),"aria-label":`Recent audit events`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(xn,{className:`w-[24%]`,label:`Time`,scope:`audit`,sort:e.sort,sortKey:`occurred_at`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[28%]`,label:`Actor`,scope:`audit`,sort:e.sort,sortKey:`actor`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[28%]`,label:`Action`,scope:`audit`,sort:e.sort,sortKey:`action`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[20%]`,label:`Result`,scope:`audit`,sort:e.sort,sortKey:`result`,onSort:(t,n)=>e.onSort(n)})]})}),(0,L.jsx)(Ht,{id:`auditBody`,children:e.events.map(e=>(0,L.jsxs)(Ut,{children:[(0,L.jsx)(W,{children:Gt(e.occurred_at)}),(0,L.jsx)(W,{children:e.actor_display_name||e.actor_subject||e.actor_provider}),(0,L.jsx)(W,{children:e.action}),(0,L.jsx)(W,{children:(0,L.jsx)(R,{variant:e.result===`success`?`succeeded`:`failed`,children:e.result})})]},e.id||`${e.occurred_at||``}-${e.actor_subject||``}-${e.action||``}`))})]})})]})}function ir({report:e,loading:t,onRefresh:n}){let r=e?.summary||{},i=[[`Status`,e?.status_counts||{}],[`Intent`,e?.intent_counts||{}],[`Planner`,e?.planner_counts||{}]].flatMap(([e,t])=>Object.entries(t).map(([t,n])=>({label:e,value:t,count:n}))).sort((e,t)=>t.count-e.count||e.label.localeCompare(t.label)),a=Array.isArray(e?.recent_unsupported)?e.recent_unsupported:[];return(0,L.jsxs)(L.Fragment,{children:[(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Agent requests`}),(0,L.jsxs)(z,{id:`refreshAgent`,type:`button`,variant:`outline`,onClick:n,disabled:t.agent,children:[(0,L.jsx)(S,{}),`Refresh`]})]}),(0,L.jsxs)(Rt,{className:`grid gap-3 md:grid-cols-5`,children:[(0,L.jsx)(Sn,{id:`agentMetricTotal`,label:`Total`,value:r.total||0}),(0,L.jsx)(Sn,{id:`agentMetricHandled`,label:`Handled`,value:r.handled||0}),(0,L.jsx)(Sn,{id:`agentMetricConfirmations`,label:`Confirmations`,value:r.requires_confirmation||0}),(0,L.jsx)(Sn,{id:`agentMetricClarifications`,label:`Clarifications`,value:r.needs_clarification||0}),(0,L.jsx)(Sn,{id:`agentMetricUnsupported`,label:`Not understood`,value:r.unsupported||0})]})]}),(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Request mix`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Recent agent.request audit events.`})]}),(0,L.jsx)(Cn,{hidden:i.length!==0,children:`No agent request data found.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`agentBreakdownTable`,className:I(`min-w-[860px]`,i.length===0&&`hidden`),"aria-label":`Agent request breakdown`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(U,{children:`Dimension`}),(0,L.jsx)(U,{children:`Value`}),(0,L.jsx)(U,{children:`Count`})]})}),(0,L.jsx)(Ht,{id:`agentBreakdownBody`,children:i.map(e=>(0,L.jsxs)(Ut,{children:[(0,L.jsx)(W,{children:e.label}),(0,L.jsx)(W,{children:e.value}),(0,L.jsx)(W,{children:e.count})]},`${e.label}-${e.value}`))})]})})]}),(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Not understood`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Sanitized request text only.`})]}),(0,L.jsx)(Cn,{hidden:a.length!==0,children:`No unsupported agent requests found.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`agentUnsupportedTable`,className:I(`min-w-[860px]`,a.length===0&&`hidden`),"aria-label":`Unsupported agent requests`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(U,{children:`Time`}),(0,L.jsx)(U,{children:`Actor`}),(0,L.jsx)(U,{children:`Message`}),(0,L.jsx)(U,{children:`Result`})]})}),(0,L.jsx)(Ht,{id:`agentUnsupportedBody`,children:a.map(e=>(0,L.jsxs)(Ut,{children:[(0,L.jsx)(W,{children:Gt(e.occurred_at)}),(0,L.jsx)(W,{children:e.actor}),(0,L.jsx)(W,{children:e.message_sanitized}),(0,L.jsx)(W,{children:(0,L.jsx)(R,{variant:e.result===`success`?`succeeded`:`failed`,children:e.result||`unknown`})})]},`${e.occurred_at||``}-${e.actor||``}-${e.message_sanitized||``}`))})]})})]})]})}var ar=document.getElementById(`root`);if(!ar)throw Error(`Missing #root container`);(0,ue.createRoot)(ar).render((0,L.jsx)(l.StrictMode,{children:(0,L.jsx)(Tn,{})})); \ No newline at end of file +`).replace(kd,``)}function jd(e,t){return t=Ad(t),Ad(e)===t}function Md(e,t,n,r,i,o){switch(n){case`children`:typeof r==`string`?t===`body`||t===`textarea`&&r===``||Vt(e,r):(typeof r==`number`||typeof r==`bigint`)&&t!==`body`&&Vt(e,``+r);break;case`className`:Mt(e,`class`,r);break;case`tabIndex`:Mt(e,`tabindex`,r);break;case`dir`:case`role`:case`viewBox`:case`width`:case`height`:Mt(e,n,r);break;case`style`:U(e,r,o);break;case`data`:if(t!==`object`){Mt(e,`data`,r);break}case`src`:case`href`:if(r===``&&(t!==`a`||n!==`href`)){e.removeAttribute(n);break}if(r==null||typeof r==`function`||typeof r==`symbol`||typeof r==`boolean`){e.removeAttribute(n);break}r=Kt(``+r),e.setAttribute(n,r);break;case`action`:case`formAction`:if(typeof r==`function`){e.setAttribute(n,`javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')`);break}else typeof o==`function`&&(n===`formAction`?(t!==`input`&&Md(e,t,`name`,i.name,i,null),Md(e,t,`formEncType`,i.formEncType,i,null),Md(e,t,`formMethod`,i.formMethod,i,null),Md(e,t,`formTarget`,i.formTarget,i,null)):(Md(e,t,`encType`,i.encType,i,null),Md(e,t,`method`,i.method,i,null),Md(e,t,`target`,i.target,i,null)));if(r==null||typeof r==`symbol`||typeof r==`boolean`){e.removeAttribute(n);break}r=Kt(``+r),e.setAttribute(n,r);break;case`onClick`:r!=null&&(e.onclick=qt);break;case`onScroll`:r!=null&&$(`scroll`,e);break;case`onScrollEnd`:r!=null&&$(`scrollend`,e);break;case`dangerouslySetInnerHTML`:if(r!=null){if(typeof r!=`object`||!(`__html`in r))throw Error(a(61));if(n=r.__html,n!=null){if(i.children!=null)throw Error(a(60));e.innerHTML=n}}break;case`multiple`:e.multiple=r&&typeof r!=`function`&&typeof r!=`symbol`;break;case`muted`:e.muted=r&&typeof r!=`function`&&typeof r!=`symbol`;break;case`suppressContentEditableWarning`:case`suppressHydrationWarning`:case`defaultValue`:case`defaultChecked`:case`innerHTML`:case`ref`:break;case`autoFocus`:break;case`xlinkHref`:if(r==null||typeof r==`function`||typeof r==`boolean`||typeof r==`symbol`){e.removeAttribute(`xlink:href`);break}n=Kt(``+r),e.setAttributeNS(`http://www.w3.org/1999/xlink`,`xlink:href`,n);break;case`contentEditable`:case`spellCheck`:case`draggable`:case`value`:case`autoReverse`:case`externalResourcesRequired`:case`focusable`:case`preserveAlpha`:r!=null&&typeof r!=`function`&&typeof r!=`symbol`?e.setAttribute(n,``+r):e.removeAttribute(n);break;case`inert`:case`allowFullScreen`:case`async`:case`autoPlay`:case`controls`:case`default`:case`defer`:case`disabled`:case`disablePictureInPicture`:case`disableRemotePlayback`:case`formNoValidate`:case`hidden`:case`loop`:case`noModule`:case`noValidate`:case`open`:case`playsInline`:case`readOnly`:case`required`:case`reversed`:case`scoped`:case`seamless`:case`itemScope`:r&&typeof r!=`function`&&typeof r!=`symbol`?e.setAttribute(n,``):e.removeAttribute(n);break;case`capture`:case`download`:!0===r?e.setAttribute(n,``):!1!==r&&r!=null&&typeof r!=`function`&&typeof r!=`symbol`?e.setAttribute(n,r):e.removeAttribute(n);break;case`cols`:case`rows`:case`size`:case`span`:r!=null&&typeof r!=`function`&&typeof r!=`symbol`&&!isNaN(r)&&1<=r?e.setAttribute(n,r):e.removeAttribute(n);break;case`rowSpan`:case`start`:r==null||typeof r==`function`||typeof r==`symbol`||isNaN(r)?e.removeAttribute(n):e.setAttribute(n,r);break;case`popover`:$(`beforetoggle`,e),$(`toggle`,e),jt(e,`popover`,r);break;case`xlinkActuate`:I(e,`http://www.w3.org/1999/xlink`,`xlink:actuate`,r);break;case`xlinkArcrole`:I(e,`http://www.w3.org/1999/xlink`,`xlink:arcrole`,r);break;case`xlinkRole`:I(e,`http://www.w3.org/1999/xlink`,`xlink:role`,r);break;case`xlinkShow`:I(e,`http://www.w3.org/1999/xlink`,`xlink:show`,r);break;case`xlinkTitle`:I(e,`http://www.w3.org/1999/xlink`,`xlink:title`,r);break;case`xlinkType`:I(e,`http://www.w3.org/1999/xlink`,`xlink:type`,r);break;case`xmlBase`:I(e,`http://www.w3.org/XML/1998/namespace`,`xml:base`,r);break;case`xmlLang`:I(e,`http://www.w3.org/XML/1998/namespace`,`xml:lang`,r);break;case`xmlSpace`:I(e,`http://www.w3.org/XML/1998/namespace`,`xml:space`,r);break;case`is`:jt(e,`is`,r);break;case`innerText`:case`textContent`:break;default:(!(2s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=B(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),St(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+B(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+B(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+B(n.imageSizes)+`"]`)):i+=`[href="`+B(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=p({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),St(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+B(r)+`"][href="`+B(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=p({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),St(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=xt(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=p({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);St(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=xt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=p({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),St(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=xt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=p({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),St(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var i=(i=ge.current)?gf(i):null;if(!i)throw Error(a(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=xt(i).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=xt(i).hoistableStyles,s=o.get(e);if(s||(i=i.ownerDocument||i,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=i.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(i,e,n,s.state))),t&&r===null)throw Error(a(528,``));return s}if(t&&r!==null)throw Error(a(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=xt(i).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(a(444,e))}}function Af(e){return`href="`+B(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return p({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),St(t),e.head.appendChild(t))}function Pf(e){return`[src="`+B(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+B(n.href)+`"]`);if(r)return t.instance=r,St(r),r;var i=p({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),St(r),Pd(r,`style`,i),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:i=Af(n.href);var o=e.querySelector(jf(i));if(o)return t.state.loading|=4,t.instance=o,St(o),o;r=Mf(n),(i=mf.get(i))&&Rf(r,i),o=(e.ownerDocument||e).createElement(`link`),St(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(i=e.querySelector(Ff(o)))?(t.instance=i,St(i),i):(r=n,(i=mf.get(o))&&(r=p({},n),zf(r,i)),e=e.ownerDocument||e,i=e.createElement(`script`),St(i),Pd(i,`link`,r),e.head.appendChild(i),t.instance=i);case`void`:return null;default:throw Error(a(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,St(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),St(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=le()}))();function T(e){var t,n,r=``;if(typeof e==`string`||typeof e==`number`)r+=e;else if(typeof e==`object`)if(Array.isArray(e)){var i=e.length;for(t=0;ttypeof e==`boolean`?`${e}`:e===0?`0`:e,fe=E,pe=(e,t)=>n=>{if(t?.variants==null)return fe(e,n?.class,n?.className);let{variants:r,defaultVariants:i}=t,a=Object.keys(r).map(e=>{let t=n?.[e],a=i?.[e];if(t===null)return null;let o=de(t)||de(a);return r[e][o]}),o=n&&Object.entries(n).reduce((e,t)=>{let[n,r]=t;return r===void 0||(e[n]=r),e},{});return fe(e,a,t?.compoundVariants?.reduce((e,t)=>{let{class:n,className:r,...a}=t;return Object.entries(a).every(e=>{let[t,n]=e;return Array.isArray(n)?n.includes({...i,...o}[t]):{...i,...o}[t]===n})?[...e,n,r]:e},[]),n?.class,n?.className)},D=(e,t)=>{let n=Array(e.length+t.length);for(let t=0;t({classGroupId:e,validator:t}),k=(e=new Map,t=null,n)=>({nextPart:e,validators:t,classGroupId:n}),me=`-`,he=[],ge=`arbitrary..`,_e=e=>{let t=be(e),{conflictingClassGroups:n,conflictingClassGroupModifiers:r}=e;return{getClassGroupId:e=>{if(e.startsWith(`[`)&&e.endsWith(`]`))return ye(e);let n=e.split(me);return ve(n,+(n[0]===``&&n.length>1),t)},getConflictingClassGroupIds:(e,t)=>{if(t){let t=r[e],i=n[e];return t?i?D(i,t):t:i||he}return n[e]||he}}},ve=(e,t,n)=>{if(e.length-t===0)return n.classGroupId;let r=e[t],i=n.nextPart.get(r);if(i){let n=ve(e,t+1,i);if(n)return n}let a=n.validators;if(a===null)return;let o=t===0?e.join(me):e.slice(t).join(me),s=a.length;for(let e=0;ee.slice(1,-1).indexOf(`:`)===-1?void 0:(()=>{let t=e.slice(1,-1),n=t.indexOf(`:`),r=t.slice(0,n);return r?ge+r:void 0})(),be=e=>{let{theme:t,classGroups:n}=e;return xe(n,t)},xe=(e,t)=>{let n=k();for(let r in e){let i=e[r];Se(i,n,r,t)}return n},Se=(e,t,n,r)=>{let i=e.length;for(let a=0;a{if(typeof e==`string`){we(e,t,n);return}if(typeof e==`function`){Te(e,t,n,r);return}Ee(e,t,n,r)},we=(e,t,n)=>{let r=e===``?t:De(t,e);r.classGroupId=n},Te=(e,t,n,r)=>{if(Oe(e)){Se(e(r),t,n,r);return}t.validators===null&&(t.validators=[]),t.validators.push(O(n,e))},Ee=(e,t,n,r)=>{let i=Object.entries(e),a=i.length;for(let e=0;e{let n=e,r=t.split(me),i=r.length;for(let e=0;e`isThemeGetter`in e&&e.isThemeGetter===!0,ke=e=>{if(e<1)return{get:()=>void 0,set:()=>{}};let t=0,n=Object.create(null),r=Object.create(null),i=(i,a)=>{n[i]=a,t++,t>e&&(t=0,r=n,n=Object.create(null))};return{get(e){let t=n[e];if(t!==void 0)return t;if((t=r[e])!==void 0)return i(e,t),t},set(e,t){e in n?n[e]=t:i(e,t)}}},Ae=`!`,je=`:`,Me=[],Ne=(e,t,n,r,i)=>({modifiers:e,hasImportantModifier:t,baseClassName:n,maybePostfixModifierPosition:r,isExternal:i}),Pe=e=>{let{prefix:t,experimentalParseClassName:n}=e,r=e=>{let t=[],n=0,r=0,i=0,a,o=e.length;for(let s=0;si?a-i:void 0;return Ne(t,l,c,u)};if(t){let e=t+je,n=r;r=t=>t.startsWith(e)?n(t.slice(e.length)):Ne(Me,!1,t,void 0,!0)}if(n){let e=r;r=t=>n({className:t,parseClassName:e})}return r},Fe=e=>{let t=new Map;return e.orderSensitiveModifiers.forEach((e,n)=>{t.set(e,1e6+n)}),e=>{let n=[],r=[];for(let i=0;i0&&(r.sort(),n.push(...r),r=[]),n.push(a)):r.push(a)}return r.length>0&&(r.sort(),n.push(...r)),n}},Ie=e=>({cache:ke(e.cacheSize),parseClassName:Pe(e),sortModifiers:Fe(e),postfixLookupClassGroupIds:Le(e),..._e(e)}),Le=e=>{let t=Object.create(null),n=e.postfixLookupClassGroups;if(n)for(let e=0;e{let{parseClassName:n,getClassGroupId:r,getConflictingClassGroupIds:i,sortModifiers:a,postfixLookupClassGroupIds:o}=t,s=[],c=e.trim().split(Re),l=``;for(let e=c.length-1;e>=0;--e){let t=c[e],{isExternal:u,modifiers:d,hasImportantModifier:f,baseClassName:p,maybePostfixModifierPosition:m}=n(t);if(u){l=t+(l.length>0?` `+l:l);continue}let h=!!m,g;if(h){g=r(p.substring(0,m));let e=g&&o[g]?r(p):void 0;e&&e!==g&&(g=e,h=!1)}else g=r(p);if(!g){if(!h){l=t+(l.length>0?` `+l:l);continue}if(g=r(p),!g){l=t+(l.length>0?` `+l:l);continue}h=!1}let _=d.length===0?``:d.length===1?d[0]:a(d).join(`:`),v=f?_+Ae:_,y=v+g;if(s.indexOf(y)>-1)continue;s.push(y);let b=i(g,h);for(let e=0;e0?` `+l:l)}return l},Be=(...e)=>{let t=0,n,r,i=``;for(;t{if(typeof e==`string`)return e;let t,n=``;for(let r=0;r{let n,r,i,a,o=o=>(n=Ie(t.reduce((e,t)=>t(e),e())),r=n.cache.get,i=n.cache.set,a=s,s(o)),s=e=>{let t=r(e);if(t)return t;let a=ze(e,n);return i(e,a),a};return a=o,(...e)=>a(Be(...e))},Ue=[],A=e=>{let t=t=>t[e]||Ue;return t.isThemeGetter=!0,t},We=/^\[(?:(\w[\w-]*):)?(.+)\]$/i,Ge=/^\((?:(\w[\w-]*):)?(.+)\)$/i,Ke=/^\d+(?:\.\d+)?\/\d+(?:\.\d+)?$/,qe=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,Je=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,Ye=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,Xe=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,Ze=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,Qe=e=>Ke.test(e),j=e=>!!e&&!Number.isNaN(Number(e)),$e=e=>!!e&&Number.isInteger(Number(e)),et=e=>e.endsWith(`%`)&&j(e.slice(0,-1)),tt=e=>qe.test(e),nt=()=>!0,rt=e=>Je.test(e)&&!Ye.test(e),it=()=>!1,at=e=>Xe.test(e),ot=e=>Ze.test(e),st=e=>!M(e)&&!N(e),ct=e=>e.startsWith(`@container`)&&(e[10]===`/`&&e[11]!==void 0||e[11]===`s`&&e[16]!==void 0&&e.startsWith(`-size/`,10)||e[11]===`n`&&e[18]!==void 0&&e.startsWith(`-normal/`,10)),lt=e=>St(e,Et,it),M=e=>We.test(e),ut=e=>St(e,Dt,rt),dt=e=>St(e,Ot,j),ft=e=>St(e,At,nt),pt=e=>St(e,kt,it),mt=e=>St(e,wt,it),ht=e=>St(e,Tt,ot),gt=e=>St(e,jt,at),N=e=>Ge.test(e),P=e=>Ct(e,Dt),F=e=>Ct(e,kt),_t=e=>Ct(e,wt),vt=e=>Ct(e,Et),yt=e=>Ct(e,Tt),bt=e=>Ct(e,jt,!0),xt=e=>Ct(e,At,!0),St=(e,t,n)=>{let r=We.exec(e);return r?r[1]?t(r[1]):n(r[2]):!1},Ct=(e,t,n=!1)=>{let r=Ge.exec(e);return r?r[1]?t(r[1]):n:!1},wt=e=>e===`position`||e===`percentage`,Tt=e=>e===`image`||e===`url`,Et=e=>e===`length`||e===`size`||e===`bg-size`,Dt=e=>e===`length`,Ot=e=>e===`number`,kt=e=>e===`family-name`,At=e=>e===`number`||e===`weight`,jt=e=>e===`shadow`,Mt=He(()=>{let e=A(`color`),t=A(`font`),n=A(`text`),r=A(`font-weight`),i=A(`tracking`),a=A(`leading`),o=A(`breakpoint`),s=A(`container`),c=A(`spacing`),l=A(`radius`),u=A(`shadow`),d=A(`inset-shadow`),f=A(`text-shadow`),p=A(`drop-shadow`),m=A(`blur`),h=A(`perspective`),g=A(`aspect`),_=A(`ease`),v=A(`animate`),y=()=>[`auto`,`avoid`,`all`,`avoid-page`,`page`,`left`,`right`,`column`],b=()=>[`center`,`top`,`bottom`,`left`,`right`,`top-left`,`left-top`,`top-right`,`right-top`,`bottom-right`,`right-bottom`,`bottom-left`,`left-bottom`],x=()=>[...b(),N,M],ee=()=>[`auto`,`hidden`,`clip`,`visible`,`scroll`],S=()=>[`auto`,`contain`,`none`],C=()=>[N,M,c],te=()=>[Qe,`full`,`auto`,...C()],ne=()=>[$e,`none`,`subgrid`,N,M],re=()=>[`auto`,{span:[`full`,$e,N,M]},$e,N,M],ie=()=>[$e,`auto`,N,M],ae=()=>[`auto`,`min`,`max`,`fr`,N,M],oe=()=>[`start`,`end`,`center`,`between`,`around`,`evenly`,`stretch`,`baseline`,`center-safe`,`end-safe`],se=()=>[`start`,`end`,`center`,`stretch`,`center-safe`,`end-safe`],w=()=>[`auto`,...C()],ce=()=>[Qe,`auto`,`full`,`dvw`,`dvh`,`lvw`,`lvh`,`svw`,`svh`,`min`,`max`,`fit`,...C()],le=()=>[Qe,`screen`,`full`,`dvw`,`lvw`,`svw`,`min`,`max`,`fit`,...C()],ue=()=>[Qe,`screen`,`full`,`lh`,`dvh`,`lvh`,`svh`,`min`,`max`,`fit`,...C()],T=()=>[e,N,M],E=()=>[...b(),_t,mt,{position:[N,M]}],de=()=>[`no-repeat`,{repeat:[``,`x`,`y`,`space`,`round`]}],fe=()=>[`auto`,`cover`,`contain`,vt,lt,{size:[N,M]}],pe=()=>[et,P,ut],D=()=>[``,`none`,`full`,l,N,M],O=()=>[``,j,P,ut],k=()=>[`solid`,`dashed`,`dotted`,`double`],me=()=>[`normal`,`multiply`,`screen`,`overlay`,`darken`,`lighten`,`color-dodge`,`color-burn`,`hard-light`,`soft-light`,`difference`,`exclusion`,`hue`,`saturation`,`color`,`luminosity`],he=()=>[j,et,_t,mt],ge=()=>[``,`none`,m,N,M],_e=()=>[`none`,j,N,M],ve=()=>[`none`,j,N,M],ye=()=>[j,N,M],be=()=>[Qe,`full`,...C()];return{cacheSize:500,theme:{animate:[`spin`,`ping`,`pulse`,`bounce`],aspect:[`video`],blur:[tt],breakpoint:[tt],color:[nt],container:[tt],"drop-shadow":[tt],ease:[`in`,`out`,`in-out`],font:[st],"font-weight":[`thin`,`extralight`,`light`,`normal`,`medium`,`semibold`,`bold`,`extrabold`,`black`],"inset-shadow":[tt],leading:[`none`,`tight`,`snug`,`normal`,`relaxed`,`loose`],perspective:[`dramatic`,`near`,`normal`,`midrange`,`distant`,`none`],radius:[tt],shadow:[tt],spacing:[`px`,j],text:[tt],"text-shadow":[tt],tracking:[`tighter`,`tight`,`normal`,`wide`,`wider`,`widest`]},classGroups:{aspect:[{aspect:[`auto`,`square`,Qe,M,N,g]}],container:[`container`],"container-type":[{"@container":[``,`normal`,`size`,N,M]}],"container-named":[ct],columns:[{columns:[j,M,N,s]}],"break-after":[{"break-after":y()}],"break-before":[{"break-before":y()}],"break-inside":[{"break-inside":[`auto`,`avoid`,`avoid-page`,`avoid-column`]}],"box-decoration":[{"box-decoration":[`slice`,`clone`]}],box:[{box:[`border`,`content`]}],display:[`block`,`inline-block`,`inline`,`flex`,`inline-flex`,`table`,`inline-table`,`table-caption`,`table-cell`,`table-column`,`table-column-group`,`table-footer-group`,`table-header-group`,`table-row-group`,`table-row`,`flow-root`,`grid`,`inline-grid`,`contents`,`list-item`,`hidden`],sr:[`sr-only`,`not-sr-only`],float:[{float:[`right`,`left`,`none`,`start`,`end`]}],clear:[{clear:[`left`,`right`,`both`,`none`,`start`,`end`]}],isolation:[`isolate`,`isolation-auto`],"object-fit":[{object:[`contain`,`cover`,`fill`,`none`,`scale-down`]}],"object-position":[{object:x()}],overflow:[{overflow:ee()}],"overflow-x":[{"overflow-x":ee()}],"overflow-y":[{"overflow-y":ee()}],overscroll:[{overscroll:S()}],"overscroll-x":[{"overscroll-x":S()}],"overscroll-y":[{"overscroll-y":S()}],position:[`static`,`fixed`,`absolute`,`relative`,`sticky`],inset:[{inset:te()}],"inset-x":[{"inset-x":te()}],"inset-y":[{"inset-y":te()}],start:[{"inset-s":te(),start:te()}],end:[{"inset-e":te(),end:te()}],"inset-bs":[{"inset-bs":te()}],"inset-be":[{"inset-be":te()}],top:[{top:te()}],right:[{right:te()}],bottom:[{bottom:te()}],left:[{left:te()}],visibility:[`visible`,`invisible`,`collapse`],z:[{z:[$e,`auto`,N,M]}],basis:[{basis:[Qe,`full`,`auto`,s,...C()]}],"flex-direction":[{flex:[`row`,`row-reverse`,`col`,`col-reverse`]}],"flex-wrap":[{flex:[`nowrap`,`wrap`,`wrap-reverse`]}],flex:[{flex:[j,Qe,`auto`,`initial`,`none`,M]}],grow:[{grow:[``,j,N,M]}],shrink:[{shrink:[``,j,N,M]}],order:[{order:[$e,`first`,`last`,`none`,N,M]}],"grid-cols":[{"grid-cols":ne()}],"col-start-end":[{col:re()}],"col-start":[{"col-start":ie()}],"col-end":[{"col-end":ie()}],"grid-rows":[{"grid-rows":ne()}],"row-start-end":[{row:re()}],"row-start":[{"row-start":ie()}],"row-end":[{"row-end":ie()}],"grid-flow":[{"grid-flow":[`row`,`col`,`dense`,`row-dense`,`col-dense`]}],"auto-cols":[{"auto-cols":ae()}],"auto-rows":[{"auto-rows":ae()}],gap:[{gap:C()}],"gap-x":[{"gap-x":C()}],"gap-y":[{"gap-y":C()}],"justify-content":[{justify:[...oe(),`normal`]}],"justify-items":[{"justify-items":[...se(),`normal`]}],"justify-self":[{"justify-self":[`auto`,...se()]}],"align-content":[{content:[`normal`,...oe()]}],"align-items":[{items:[...se(),{baseline:[``,`last`]}]}],"align-self":[{self:[`auto`,...se(),{baseline:[``,`last`]}]}],"place-content":[{"place-content":oe()}],"place-items":[{"place-items":[...se(),`baseline`]}],"place-self":[{"place-self":[`auto`,...se()]}],p:[{p:C()}],px:[{px:C()}],py:[{py:C()}],ps:[{ps:C()}],pe:[{pe:C()}],pbs:[{pbs:C()}],pbe:[{pbe:C()}],pt:[{pt:C()}],pr:[{pr:C()}],pb:[{pb:C()}],pl:[{pl:C()}],m:[{m:w()}],mx:[{mx:w()}],my:[{my:w()}],ms:[{ms:w()}],me:[{me:w()}],mbs:[{mbs:w()}],mbe:[{mbe:w()}],mt:[{mt:w()}],mr:[{mr:w()}],mb:[{mb:w()}],ml:[{ml:w()}],"space-x":[{"space-x":C()}],"space-x-reverse":[`space-x-reverse`],"space-y":[{"space-y":C()}],"space-y-reverse":[`space-y-reverse`],size:[{size:ce()}],"inline-size":[{inline:[`auto`,...le()]}],"min-inline-size":[{"min-inline":[`auto`,...le()]}],"max-inline-size":[{"max-inline":[`none`,...le()]}],"block-size":[{block:[`auto`,...ue()]}],"min-block-size":[{"min-block":[`auto`,...ue()]}],"max-block-size":[{"max-block":[`none`,...ue()]}],w:[{w:[s,`screen`,...ce()]}],"min-w":[{"min-w":[s,`screen`,`none`,...ce()]}],"max-w":[{"max-w":[s,`screen`,`none`,`prose`,{screen:[o]},...ce()]}],h:[{h:[`screen`,`lh`,...ce()]}],"min-h":[{"min-h":[`screen`,`lh`,`none`,...ce()]}],"max-h":[{"max-h":[`screen`,`lh`,...ce()]}],"font-size":[{text:[`base`,n,P,ut]}],"font-smoothing":[`antialiased`,`subpixel-antialiased`],"font-style":[`italic`,`not-italic`],"font-weight":[{font:[r,xt,ft]}],"font-stretch":[{"font-stretch":[`ultra-condensed`,`extra-condensed`,`condensed`,`semi-condensed`,`normal`,`semi-expanded`,`expanded`,`extra-expanded`,`ultra-expanded`,et,M]}],"font-family":[{font:[F,pt,t]}],"font-features":[{"font-features":[M]}],"fvn-normal":[`normal-nums`],"fvn-ordinal":[`ordinal`],"fvn-slashed-zero":[`slashed-zero`],"fvn-figure":[`lining-nums`,`oldstyle-nums`],"fvn-spacing":[`proportional-nums`,`tabular-nums`],"fvn-fraction":[`diagonal-fractions`,`stacked-fractions`],tracking:[{tracking:[i,N,M]}],"line-clamp":[{"line-clamp":[j,`none`,N,dt]}],leading:[{leading:[a,...C()]}],"list-image":[{"list-image":[`none`,N,M]}],"list-style-position":[{list:[`inside`,`outside`]}],"list-style-type":[{list:[`disc`,`decimal`,`none`,N,M]}],"text-alignment":[{text:[`left`,`center`,`right`,`justify`,`start`,`end`]}],"placeholder-color":[{placeholder:T()}],"text-color":[{text:T()}],"text-decoration":[`underline`,`overline`,`line-through`,`no-underline`],"text-decoration-style":[{decoration:[...k(),`wavy`]}],"text-decoration-thickness":[{decoration:[j,`from-font`,`auto`,N,ut]}],"text-decoration-color":[{decoration:T()}],"underline-offset":[{"underline-offset":[j,`auto`,N,M]}],"text-transform":[`uppercase`,`lowercase`,`capitalize`,`normal-case`],"text-overflow":[`truncate`,`text-ellipsis`,`text-clip`],"text-wrap":[{text:[`wrap`,`nowrap`,`balance`,`pretty`]}],indent:[{indent:C()}],"tab-size":[{tab:[$e,N,M]}],"vertical-align":[{align:[`baseline`,`top`,`middle`,`bottom`,`text-top`,`text-bottom`,`sub`,`super`,N,M]}],whitespace:[{whitespace:[`normal`,`nowrap`,`pre`,`pre-line`,`pre-wrap`,`break-spaces`]}],break:[{break:[`normal`,`words`,`all`,`keep`]}],wrap:[{wrap:[`break-word`,`anywhere`,`normal`]}],hyphens:[{hyphens:[`none`,`manual`,`auto`]}],content:[{content:[`none`,N,M]}],"bg-attachment":[{bg:[`fixed`,`local`,`scroll`]}],"bg-clip":[{"bg-clip":[`border`,`padding`,`content`,`text`]}],"bg-origin":[{"bg-origin":[`border`,`padding`,`content`]}],"bg-position":[{bg:E()}],"bg-repeat":[{bg:de()}],"bg-size":[{bg:fe()}],"bg-image":[{bg:[`none`,{linear:[{to:[`t`,`tr`,`r`,`br`,`b`,`bl`,`l`,`tl`]},$e,N,M],radial:[``,N,M],conic:[$e,N,M]},yt,ht]}],"bg-color":[{bg:T()}],"gradient-from-pos":[{from:pe()}],"gradient-via-pos":[{via:pe()}],"gradient-to-pos":[{to:pe()}],"gradient-from":[{from:T()}],"gradient-via":[{via:T()}],"gradient-to":[{to:T()}],rounded:[{rounded:D()}],"rounded-s":[{"rounded-s":D()}],"rounded-e":[{"rounded-e":D()}],"rounded-t":[{"rounded-t":D()}],"rounded-r":[{"rounded-r":D()}],"rounded-b":[{"rounded-b":D()}],"rounded-l":[{"rounded-l":D()}],"rounded-ss":[{"rounded-ss":D()}],"rounded-se":[{"rounded-se":D()}],"rounded-ee":[{"rounded-ee":D()}],"rounded-es":[{"rounded-es":D()}],"rounded-tl":[{"rounded-tl":D()}],"rounded-tr":[{"rounded-tr":D()}],"rounded-br":[{"rounded-br":D()}],"rounded-bl":[{"rounded-bl":D()}],"border-w":[{border:O()}],"border-w-x":[{"border-x":O()}],"border-w-y":[{"border-y":O()}],"border-w-s":[{"border-s":O()}],"border-w-e":[{"border-e":O()}],"border-w-bs":[{"border-bs":O()}],"border-w-be":[{"border-be":O()}],"border-w-t":[{"border-t":O()}],"border-w-r":[{"border-r":O()}],"border-w-b":[{"border-b":O()}],"border-w-l":[{"border-l":O()}],"divide-x":[{"divide-x":O()}],"divide-x-reverse":[`divide-x-reverse`],"divide-y":[{"divide-y":O()}],"divide-y-reverse":[`divide-y-reverse`],"border-style":[{border:[...k(),`hidden`,`none`]}],"divide-style":[{divide:[...k(),`hidden`,`none`]}],"border-color":[{border:T()}],"border-color-x":[{"border-x":T()}],"border-color-y":[{"border-y":T()}],"border-color-s":[{"border-s":T()}],"border-color-e":[{"border-e":T()}],"border-color-bs":[{"border-bs":T()}],"border-color-be":[{"border-be":T()}],"border-color-t":[{"border-t":T()}],"border-color-r":[{"border-r":T()}],"border-color-b":[{"border-b":T()}],"border-color-l":[{"border-l":T()}],"divide-color":[{divide:T()}],"outline-style":[{outline:[...k(),`none`,`hidden`]}],"outline-offset":[{"outline-offset":[j,N,M]}],"outline-w":[{outline:[``,j,P,ut]}],"outline-color":[{outline:T()}],shadow:[{shadow:[``,`none`,u,bt,gt]}],"shadow-color":[{shadow:T()}],"inset-shadow":[{"inset-shadow":[`none`,d,bt,gt]}],"inset-shadow-color":[{"inset-shadow":T()}],"ring-w":[{ring:O()}],"ring-w-inset":[`ring-inset`],"ring-color":[{ring:T()}],"ring-offset-w":[{"ring-offset":[j,ut]}],"ring-offset-color":[{"ring-offset":T()}],"inset-ring-w":[{"inset-ring":O()}],"inset-ring-color":[{"inset-ring":T()}],"text-shadow":[{"text-shadow":[`none`,f,bt,gt]}],"text-shadow-color":[{"text-shadow":T()}],opacity:[{opacity:[j,N,M]}],"mix-blend":[{"mix-blend":[...me(),`plus-darker`,`plus-lighter`]}],"bg-blend":[{"bg-blend":me()}],"mask-clip":[{"mask-clip":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]},`mask-no-clip`],"mask-composite":[{mask:[`add`,`subtract`,`intersect`,`exclude`]}],"mask-image-linear-pos":[{"mask-linear":[j]}],"mask-image-linear-from-pos":[{"mask-linear-from":he()}],"mask-image-linear-to-pos":[{"mask-linear-to":he()}],"mask-image-linear-from-color":[{"mask-linear-from":T()}],"mask-image-linear-to-color":[{"mask-linear-to":T()}],"mask-image-t-from-pos":[{"mask-t-from":he()}],"mask-image-t-to-pos":[{"mask-t-to":he()}],"mask-image-t-from-color":[{"mask-t-from":T()}],"mask-image-t-to-color":[{"mask-t-to":T()}],"mask-image-r-from-pos":[{"mask-r-from":he()}],"mask-image-r-to-pos":[{"mask-r-to":he()}],"mask-image-r-from-color":[{"mask-r-from":T()}],"mask-image-r-to-color":[{"mask-r-to":T()}],"mask-image-b-from-pos":[{"mask-b-from":he()}],"mask-image-b-to-pos":[{"mask-b-to":he()}],"mask-image-b-from-color":[{"mask-b-from":T()}],"mask-image-b-to-color":[{"mask-b-to":T()}],"mask-image-l-from-pos":[{"mask-l-from":he()}],"mask-image-l-to-pos":[{"mask-l-to":he()}],"mask-image-l-from-color":[{"mask-l-from":T()}],"mask-image-l-to-color":[{"mask-l-to":T()}],"mask-image-x-from-pos":[{"mask-x-from":he()}],"mask-image-x-to-pos":[{"mask-x-to":he()}],"mask-image-x-from-color":[{"mask-x-from":T()}],"mask-image-x-to-color":[{"mask-x-to":T()}],"mask-image-y-from-pos":[{"mask-y-from":he()}],"mask-image-y-to-pos":[{"mask-y-to":he()}],"mask-image-y-from-color":[{"mask-y-from":T()}],"mask-image-y-to-color":[{"mask-y-to":T()}],"mask-image-radial":[{"mask-radial":[N,M]}],"mask-image-radial-from-pos":[{"mask-radial-from":he()}],"mask-image-radial-to-pos":[{"mask-radial-to":he()}],"mask-image-radial-from-color":[{"mask-radial-from":T()}],"mask-image-radial-to-color":[{"mask-radial-to":T()}],"mask-image-radial-shape":[{"mask-radial":[`circle`,`ellipse`]}],"mask-image-radial-size":[{"mask-radial":[{closest:[`side`,`corner`],farthest:[`side`,`corner`]}]}],"mask-image-radial-pos":[{"mask-radial-at":b()}],"mask-image-conic-pos":[{"mask-conic":[j]}],"mask-image-conic-from-pos":[{"mask-conic-from":he()}],"mask-image-conic-to-pos":[{"mask-conic-to":he()}],"mask-image-conic-from-color":[{"mask-conic-from":T()}],"mask-image-conic-to-color":[{"mask-conic-to":T()}],"mask-mode":[{mask:[`alpha`,`luminance`,`match`]}],"mask-origin":[{"mask-origin":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]}],"mask-position":[{mask:E()}],"mask-repeat":[{mask:de()}],"mask-size":[{mask:fe()}],"mask-type":[{"mask-type":[`alpha`,`luminance`]}],"mask-image":[{mask:[`none`,N,M]}],filter:[{filter:[``,`none`,N,M]}],blur:[{blur:ge()}],brightness:[{brightness:[j,N,M]}],contrast:[{contrast:[j,N,M]}],"drop-shadow":[{"drop-shadow":[``,`none`,p,bt,gt]}],"drop-shadow-color":[{"drop-shadow":T()}],grayscale:[{grayscale:[``,j,N,M]}],"hue-rotate":[{"hue-rotate":[j,N,M]}],invert:[{invert:[``,j,N,M]}],saturate:[{saturate:[j,N,M]}],sepia:[{sepia:[``,j,N,M]}],"backdrop-filter":[{"backdrop-filter":[``,`none`,N,M]}],"backdrop-blur":[{"backdrop-blur":ge()}],"backdrop-brightness":[{"backdrop-brightness":[j,N,M]}],"backdrop-contrast":[{"backdrop-contrast":[j,N,M]}],"backdrop-grayscale":[{"backdrop-grayscale":[``,j,N,M]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[j,N,M]}],"backdrop-invert":[{"backdrop-invert":[``,j,N,M]}],"backdrop-opacity":[{"backdrop-opacity":[j,N,M]}],"backdrop-saturate":[{"backdrop-saturate":[j,N,M]}],"backdrop-sepia":[{"backdrop-sepia":[``,j,N,M]}],"border-collapse":[{border:[`collapse`,`separate`]}],"border-spacing":[{"border-spacing":C()}],"border-spacing-x":[{"border-spacing-x":C()}],"border-spacing-y":[{"border-spacing-y":C()}],"table-layout":[{table:[`auto`,`fixed`]}],caption:[{caption:[`top`,`bottom`]}],transition:[{transition:[``,`all`,`colors`,`opacity`,`shadow`,`transform`,`none`,N,M]}],"transition-behavior":[{transition:[`normal`,`discrete`]}],duration:[{duration:[j,`initial`,N,M]}],ease:[{ease:[`linear`,`initial`,_,N,M]}],delay:[{delay:[j,N,M]}],animate:[{animate:[`none`,v,N,M]}],backface:[{backface:[`hidden`,`visible`]}],perspective:[{perspective:[h,N,M]}],"perspective-origin":[{"perspective-origin":x()}],rotate:[{rotate:_e()}],"rotate-x":[{"rotate-x":_e()}],"rotate-y":[{"rotate-y":_e()}],"rotate-z":[{"rotate-z":_e()}],scale:[{scale:ve()}],"scale-x":[{"scale-x":ve()}],"scale-y":[{"scale-y":ve()}],"scale-z":[{"scale-z":ve()}],"scale-3d":[`scale-3d`],skew:[{skew:ye()}],"skew-x":[{"skew-x":ye()}],"skew-y":[{"skew-y":ye()}],transform:[{transform:[N,M,``,`none`,`gpu`,`cpu`]}],"transform-origin":[{origin:x()}],"transform-style":[{transform:[`3d`,`flat`]}],translate:[{translate:be()}],"translate-x":[{"translate-x":be()}],"translate-y":[{"translate-y":be()}],"translate-z":[{"translate-z":be()}],"translate-none":[`translate-none`],zoom:[{zoom:[$e,N,M]}],accent:[{accent:T()}],appearance:[{appearance:[`none`,`auto`]}],"caret-color":[{caret:T()}],"color-scheme":[{scheme:[`normal`,`dark`,`light`,`light-dark`,`only-dark`,`only-light`]}],cursor:[{cursor:[`auto`,`default`,`pointer`,`wait`,`text`,`move`,`help`,`not-allowed`,`none`,`context-menu`,`progress`,`cell`,`crosshair`,`vertical-text`,`alias`,`copy`,`no-drop`,`grab`,`grabbing`,`all-scroll`,`col-resize`,`row-resize`,`n-resize`,`e-resize`,`s-resize`,`w-resize`,`ne-resize`,`nw-resize`,`se-resize`,`sw-resize`,`ew-resize`,`ns-resize`,`nesw-resize`,`nwse-resize`,`zoom-in`,`zoom-out`,N,M]}],"field-sizing":[{"field-sizing":[`fixed`,`content`]}],"pointer-events":[{"pointer-events":[`auto`,`none`]}],resize:[{resize:[`none`,``,`y`,`x`]}],"scroll-behavior":[{scroll:[`auto`,`smooth`]}],"scrollbar-thumb-color":[{"scrollbar-thumb":T()}],"scrollbar-track-color":[{"scrollbar-track":T()}],"scrollbar-gutter":[{"scrollbar-gutter":[`auto`,`stable`,`both`]}],"scrollbar-w":[{scrollbar:[`auto`,`thin`,`none`]}],"scroll-m":[{"scroll-m":C()}],"scroll-mx":[{"scroll-mx":C()}],"scroll-my":[{"scroll-my":C()}],"scroll-ms":[{"scroll-ms":C()}],"scroll-me":[{"scroll-me":C()}],"scroll-mbs":[{"scroll-mbs":C()}],"scroll-mbe":[{"scroll-mbe":C()}],"scroll-mt":[{"scroll-mt":C()}],"scroll-mr":[{"scroll-mr":C()}],"scroll-mb":[{"scroll-mb":C()}],"scroll-ml":[{"scroll-ml":C()}],"scroll-p":[{"scroll-p":C()}],"scroll-px":[{"scroll-px":C()}],"scroll-py":[{"scroll-py":C()}],"scroll-ps":[{"scroll-ps":C()}],"scroll-pe":[{"scroll-pe":C()}],"scroll-pbs":[{"scroll-pbs":C()}],"scroll-pbe":[{"scroll-pbe":C()}],"scroll-pt":[{"scroll-pt":C()}],"scroll-pr":[{"scroll-pr":C()}],"scroll-pb":[{"scroll-pb":C()}],"scroll-pl":[{"scroll-pl":C()}],"snap-align":[{snap:[`start`,`end`,`center`,`align-none`]}],"snap-stop":[{snap:[`normal`,`always`]}],"snap-type":[{snap:[`none`,`x`,`y`,`both`]}],"snap-strictness":[{snap:[`mandatory`,`proximity`]}],touch:[{touch:[`auto`,`none`,`manipulation`]}],"touch-x":[{"touch-pan":[`x`,`left`,`right`]}],"touch-y":[{"touch-pan":[`y`,`up`,`down`]}],"touch-pz":[`touch-pinch-zoom`],select:[{select:[`none`,`text`,`all`,`auto`]}],"will-change":[{"will-change":[`auto`,`scroll`,`contents`,`transform`,N,M]}],fill:[{fill:[`none`,...T()]}],"stroke-w":[{stroke:[j,P,ut,dt]}],stroke:[{stroke:[`none`,...T()]}],"forced-color-adjust":[{"forced-color-adjust":[`auto`,`none`]}]},conflictingClassGroups:{"container-named":[`container-type`],overflow:[`overflow-x`,`overflow-y`],overscroll:[`overscroll-x`,`overscroll-y`],inset:[`inset-x`,`inset-y`,`inset-bs`,`inset-be`,`start`,`end`,`top`,`right`,`bottom`,`left`],"inset-x":[`right`,`left`],"inset-y":[`top`,`bottom`],flex:[`basis`,`grow`,`shrink`],gap:[`gap-x`,`gap-y`],p:[`px`,`py`,`ps`,`pe`,`pbs`,`pbe`,`pt`,`pr`,`pb`,`pl`],px:[`pr`,`pl`],py:[`pt`,`pb`],m:[`mx`,`my`,`ms`,`me`,`mbs`,`mbe`,`mt`,`mr`,`mb`,`ml`],mx:[`mr`,`ml`],my:[`mt`,`mb`],size:[`w`,`h`],"font-size":[`leading`],"fvn-normal":[`fvn-ordinal`,`fvn-slashed-zero`,`fvn-figure`,`fvn-spacing`,`fvn-fraction`],"fvn-ordinal":[`fvn-normal`],"fvn-slashed-zero":[`fvn-normal`],"fvn-figure":[`fvn-normal`],"fvn-spacing":[`fvn-normal`],"fvn-fraction":[`fvn-normal`],"line-clamp":[`display`,`overflow`],rounded:[`rounded-s`,`rounded-e`,`rounded-t`,`rounded-r`,`rounded-b`,`rounded-l`,`rounded-ss`,`rounded-se`,`rounded-ee`,`rounded-es`,`rounded-tl`,`rounded-tr`,`rounded-br`,`rounded-bl`],"rounded-s":[`rounded-ss`,`rounded-es`],"rounded-e":[`rounded-se`,`rounded-ee`],"rounded-t":[`rounded-tl`,`rounded-tr`],"rounded-r":[`rounded-tr`,`rounded-br`],"rounded-b":[`rounded-br`,`rounded-bl`],"rounded-l":[`rounded-tl`,`rounded-bl`],"border-spacing":[`border-spacing-x`,`border-spacing-y`],"border-w":[`border-w-x`,`border-w-y`,`border-w-s`,`border-w-e`,`border-w-bs`,`border-w-be`,`border-w-t`,`border-w-r`,`border-w-b`,`border-w-l`],"border-w-x":[`border-w-r`,`border-w-l`],"border-w-y":[`border-w-t`,`border-w-b`],"border-color":[`border-color-x`,`border-color-y`,`border-color-s`,`border-color-e`,`border-color-bs`,`border-color-be`,`border-color-t`,`border-color-r`,`border-color-b`,`border-color-l`],"border-color-x":[`border-color-r`,`border-color-l`],"border-color-y":[`border-color-t`,`border-color-b`],translate:[`translate-x`,`translate-y`,`translate-none`],"translate-none":[`translate`,`translate-x`,`translate-y`,`translate-z`],"scroll-m":[`scroll-mx`,`scroll-my`,`scroll-ms`,`scroll-me`,`scroll-mbs`,`scroll-mbe`,`scroll-mt`,`scroll-mr`,`scroll-mb`,`scroll-ml`],"scroll-mx":[`scroll-mr`,`scroll-ml`],"scroll-my":[`scroll-mt`,`scroll-mb`],"scroll-p":[`scroll-px`,`scroll-py`,`scroll-ps`,`scroll-pe`,`scroll-pbs`,`scroll-pbe`,`scroll-pt`,`scroll-pr`,`scroll-pb`,`scroll-pl`],"scroll-px":[`scroll-pr`,`scroll-pl`],"scroll-py":[`scroll-pt`,`scroll-pb`],touch:[`touch-x`,`touch-y`,`touch-pz`],"touch-x":[`touch`],"touch-y":[`touch`],"touch-pz":[`touch`]},conflictingClassGroupModifiers:{"font-size":[`leading`]},postfixLookupClassGroups:[`container-type`],orderSensitiveModifiers:[`*`,`**`,`after`,`backdrop`,`before`,`details-content`,`file`,`first-letter`,`first-line`,`marker`,`placeholder`,`selection`]}});function I(...e){return Mt(E(e))}var Nt=e((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),L=e(((e,t)=>{t.exports=Nt()}))(),Pt=pe(`inline-flex min-h-[22px] items-center rounded-full border px-2 py-0.5 text-[11px] font-extrabold uppercase leading-tight`,{variants:{variant:{neutral:`border-border bg-secondary text-muted-foreground`,succeeded:`border-emerald-400/35 bg-emerald-500/15 text-emerald-300`,failed:`border-red-400/40 bg-red-500/15 text-red-300`,dead:`border-red-400/40 bg-red-500/15 text-red-300`,missing:`border-red-400/40 bg-red-500/15 text-red-300`,running:`border-amber-400/40 bg-amber-500/15 text-amber-300`,queued:`border-teal-400/40 bg-teal-500/15 text-teal-200`,canceled:`border-border bg-secondary text-muted-foreground`}},defaultVariants:{variant:`neutral`}});function R({className:e,variant:t,...n}){return(0,L.jsx)(`span`,{"data-slot":`badge`,className:I(Pt({variant:t,className:e})),...n})}var Ft=pe(`inline-flex min-h-9 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md border text-sm font-semibold transition-colors focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0`,{variants:{variant:{default:`border-primary bg-primary text-primary-foreground hover:bg-primary/90`,secondary:`border-border bg-secondary text-secondary-foreground hover:bg-secondary/80`,outline:`border-border bg-background hover:bg-accent hover:text-accent-foreground`,ghost:`border-transparent hover:bg-accent hover:text-accent-foreground`,destructive:`border-destructive bg-destructive text-white hover:bg-destructive/90`},size:{default:`h-9 px-4 py-2`,sm:`h-8 rounded-md px-3 text-xs`,icon:`size-9`}},defaultVariants:{variant:`secondary`,size:`default`}});function z({className:e,variant:t,size:n,type:r=`button`,...i}){return(0,L.jsx)(`button`,{"data-slot":`button`,type:r,className:I(Ft({variant:t,size:n,className:e})),...i})}function It({className:e,...t}){return(0,L.jsx)(`div`,{"data-slot":`card`,className:I(`rounded-lg border bg-card text-card-foreground shadow-[0_18px_44px_rgb(0_0_0/0.22)]`,e),...t})}function B({className:e,...t}){return(0,L.jsx)(`div`,{"data-slot":`card-header`,className:I(`flex items-center justify-between gap-3 border-b px-4 py-3`,e),...t})}function Lt({className:e,...t}){return(0,L.jsx)(`h2`,{"data-slot":`card-title`,className:I(`text-[15px] font-bold`,e),...t})}function Rt({className:e,...t}){return(0,L.jsx)(`div`,{"data-slot":`card-content`,className:I(`p-4`,e),...t})}function V({className:e,type:t,...n}){return(0,L.jsx)(`input`,{"data-slot":`input`,type:t,className:I(`flex min-h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50`,e),...n})}function H({className:e,...t}){return(0,L.jsx)(`label`,{"data-slot":`label`,className:I(`grid gap-1.5 text-xs font-bold text-muted-foreground`,e),...t})}function zt({className:e,...t}){return(0,L.jsx)(`select`,{"data-slot":`select`,className:I(`flex min-h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs transition-colors focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50`,e),...t})}function Bt({className:e,...t}){return(0,L.jsx)(`table`,{"data-slot":`table`,className:I(`w-full border-collapse text-sm`,e),...t})}function Vt({className:e,...t}){return(0,L.jsx)(`thead`,{"data-slot":`table-header`,className:e,...t})}function Ht({className:e,...t}){return(0,L.jsx)(`tbody`,{"data-slot":`table-body`,className:e,...t})}function Ut({className:e,...t}){return(0,L.jsx)(`tr`,{"data-slot":`table-row`,className:I(`border-b transition-colors hover:bg-muted/45`,e),...t})}function U({className:e,...t}){return(0,L.jsx)(`th`,{"data-slot":`table-head`,className:I(`bg-secondary px-3 py-3 text-left align-middle text-xs font-extrabold text-muted-foreground`,e),...t})}function W({className:e,...t}){return(0,L.jsx)(`td`,{"data-slot":`table-cell`,className:I(`px-3 py-3 align-middle text-sm`,e),...t})}var Wt={pending:`Needs review`,selected:`Assigned to onboarder`,reachingout:`Reaching out`,awaitingcontribution:`Awaiting contribution`,onboarded:`Onboarded`,waitlist:`Waitlist`,rejected:`Rejected`};function Gt(e){if(!e)return``;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString(void 0,{year:`numeric`,month:`short`,day:`numeric`,hour:`2-digit`,minute:`2-digit`})}function Kt(e,t=new Date){if(!e)return null;let n=new Date(e);if(Number.isNaN(n.getTime()))return null;let r=t.getTime()-n.getTime();return r<0?0:Math.floor(r/864e5)}function qt(e){return e==null?``:JSON.stringify(e,null,2)}function Jt(e){return e.onboarding_state||e.onboardingState||e.cOnboardingState||``}function Yt(e){let t=String(e||``).trim();if(!t)return`No status`;let n=t.toLowerCase();return Wt[n]?Wt[n]:t.replace(/[-_]+/g,` `).replace(/\s+/g,` `).trim().replace(/\b\w/g,e=>e.toUpperCase())}function Xt(e){let t=String(e||``).trim().toLowerCase();return!t||t===`pending`?`neutral`:t===`selected`?`queued`:t===`rejected`?`failed`:t===`onboarded`?`succeeded`:t===`waitlist`?`running`:`queued`}function Zt(e){let t=String(e||``).trim();return!t||t.toLowerCase()===`none`?``:t}function Qt(e){let t=String(e||``).trim();return t?/^https?:\/\//i.test(t)?t:`https://${t.replace(/^\/+/,``)}`:``}function $t(e){try{return new URL(Qt(e))}catch{return null}}function en(e,t){let n=e.toLowerCase();return n===t||n.endsWith(`.${t}`)}function tn(e){return e.split(`/`).filter(Boolean).map(e=>encodeURIComponent(e)).join(`/`)}function nn(e){let t=String(e||``).trim();if(!t)return``;let n=$t(t);if(n&&en(n.hostname,`linkedin.com`))return n.href;if(/^https?:\/\//i.test(t))return``;let r=t.replace(/^@/,``).replace(/^\/+|\/+$/g,``).replace(/^in\//i,``);return r?`https://www.linkedin.com/in/${tn(r)}`:``}function rn(e){let t=String(e||``).trim().replace(/^@/,``);if(!t)return``;let n=$t(t);if(n&&en(n.hostname,`github.com`))return n.href;if(/^https?:\/\//i.test(t))return``;let r=t.replace(/^\/+|\/+$/g,``);return r?`https://github.com/${tn(r)}`:``}var an={people:`/dashboard/people`,gigs:`/dashboard/gigs`,projects:`/dashboard/projects`,onboarding:`/dashboard/onboarding`,jobs:`/dashboard/jobs`,agent:`/dashboard/agent`,audit:`/dashboard/audit`},on={people:`people:read`,gigs:`gigs:read`,projects:`projects:read`,onboarding:`onboarding:read`,jobs:`jobs:read`,agent:`audit:read`,audit:`audit:read`},sn={discord:{label:`Discord`,options:[[`linked`,`Linked`],[`missing`,`Missing`]]},email_508:{label:`508 email`,options:[[`present`,`Present`],[`missing`,`Missing`]]},resume:{label:`Resume`,options:[[`present`,`Present`],[`missing`,`Missing`]]},skills:{label:`Skills`,options:[[`present`,`Parsed`],[`missing`,`Not parsed`]]},sync_status:{label:`Sync status`,options:[[`active`,`Active`],[`conflict`,`Conflict`],[`missing_in_crm`,`Missing in CRM`]]}},cn=[[`pending`,`Needs review`],[`selected`,`Assigned to onboarder`],[`reachingout`,`Reaching out`],[`awaitingcontribution`,`Awaiting contribution`],[`onboarded`,`Onboarded`],[`waitlist`,`Waitlist`],[`rejected`,`Rejected`]],ln=cn.slice(0,4),un=new Set([`onboarded`,`waitlist`,`rejected`]);function dn(e){return String(e||``).trim().toLowerCase().replace(/[-_\s]+/g,``)}var fn=class extends Error{status;statusText;payload;url;method;constructor(e,t,n,r,i,a){super(e),this.name=`ApiRequestError`,this.status=t,this.statusText=n,this.payload=r,this.url=i,this.method=a}};function pn(e,t){let n=e.detail;if(typeof n==`string`&&n.trim())return n;let r=e.error;return typeof r==`string`?r===`person_not_found`?`No CRM person, ERPNext user, or ERPNext supplier matched "${typeof e.person==`string`&&e.person.trim()?e.person:`that person`}". Try an email address or an exact name from CRM/ERPNext.`:r===`candidate_not_found`?`The selected person record is no longer available. Search again and choose one of the current matches.`:r===`invalid_crm_profile`?`Paste a valid CRM Contact profile URL or Contact id.`:r===`crm_profile_not_found`?`That CRM Contact profile was not found.`:r===`crm_profile_mismatch`?`CRM returned a different Contact than the profile requested. Check the profile URL and try again.`:r===`crm_profile_lookup_failed`?`CRM profile lookup failed. Try again after CRM is reachable.`:r===`ambiguous_person`?`Multiple people matched. Choose the matching person record.`:r||t:t}function mn(e,t){return typeof e==`string`&&e.trim()?e:e instanceof Error&&e.message.trim()?e.message:t}function hn(){return window.location.pathname.split(`/`).filter(Boolean)[1]||``}function gn(){let e=hn();return Object.hasOwn(an,e)?e:`people`}function _n(e=`gigs`){let[,t,n]=window.location.pathname.split(`/`).filter(Boolean);if(t!==e||!n)return``;try{return decodeURIComponent(n)}catch{return``}}async function G(e,t={}){let n=String(t.method||`GET`).toUpperCase(),r=new Headers(t.headers);r.set(`Accept`,`application/json`);let i;try{i=await fetch(e,{credentials:`same-origin`,...t,headers:r})}catch(t){throw new fn(mn(t,`Network request failed`),0,`Network request failed`,null,e,n)}if(i.status===401){let t=`${window.location.pathname}${window.location.search}`||`/dashboard`;throw window.location.assign(`/auth/login?next=${encodeURIComponent(t)}`),new fn(`Session expired`,i.status,i.statusText,null,e,n)}if(!i.ok){let t=i.statusText,r=null;try{r=await i.json(),r&&typeof r==`object`&&(t=pn(r,String(t||`Request failed`)))}catch{t=i.statusText}throw new fn(typeof t==`string`?t:JSON.stringify(t),i.status,i.statusText,r,e,n)}return i.json()}function vn(e,t,n){if(e===`gigs`){let e=t;if(n===`title`)return e.title||``;if(n===`status`)return e.status||``;if(n===`applications`)return Number(e.application_count||0);if(n===`activity`)return Nn(e)}if(e===`projects`){let e=t;if(n===`display_name`)return e.display_name||``;if(n===`customer`)return e.customer||``;if(n===`status`)return e.source_status||``;if(n===`roster_count`)return Number(e.roster_count||0);if(n===`modified`)return e.source_modified_at||e.last_synced_at||``}if(e===`onboarding`){let e=t,r=e.profile_status||{};if(n===`name`)return e.name||e.email_508||e.email||``;if(n===`onboarding_state`){let t=Jt(e);return t.toLowerCase()===`pending`?`zzz-${t}`:t}if(n===`onboarder`)return e.onboarder||``;if(n===`updated`)return e.onboarding_updated_at||``;if(n===`profile_gaps`)return[!r.discord_linked,!r.latest_resume,Number(r.skills_count||0)<=0].filter(Boolean).length}if(e===`people`){let e=t,r=e.profile_status||{};if(n===`name`)return e.name||e.email_508||e.email||``;if(n===`status`)return[r.crm_active,r.is_member,r.discord_linked,r.email_508,r.latest_resume].filter(Boolean).length;if(n===`discord`)return e.discord_username||e.discord_user_id||``;if(n===`resume`)return e.latest_resume_name||e.latest_resume_id||``}if(e===`audit`){let e=t;if(n===`actor`)return e.actor_display_name||e.actor_subject||e.actor_provider||``}return t[n]??``}function yn(e,t,n){let r=n.direction===`asc`?1:-1;return[...t].sort((t,i)=>{let a=vn(e,t,n.key),o=vn(e,i,n.key);return typeof a==`number`&&typeof o==`number`?(a-o)*r:String(a).localeCompare(String(o),void 0,{numeric:!0})*r})}function bn({label:e,scope:t,sort:n,sortKey:r,onSort:i}){let a=n.key===r,o=n.direction===`asc`?`↑`:`↓`;return(0,L.jsx)(`button`,{type:`button`,"data-sort-scope":t,"data-sort-key":r,className:`text-left font-[inherit] text-inherit hover:text-foreground`,onClick:()=>i(t,r),children:a?`${e} ${o}`:e})}function xn({className:e,label:t,scope:n,sort:r,sortKey:i,onSort:a}){return(0,L.jsx)(U,{className:e,"aria-sort":r.key===i?r.direction===`asc`?`ascending`:`descending`:`none`,children:(0,L.jsx)(bn,{label:t,scope:n,sort:r,sortKey:i,onSort:a})})}function Sn({label:e,value:t,id:n}){return(0,L.jsxs)(It,{className:`p-4`,children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:e}),(0,L.jsx)(`strong`,{id:n,className:`block text-2xl`,children:t})]})}function Cn({children:e,hidden:t}){return t?null:(0,L.jsx)(`div`,{className:`px-4 py-7 text-center text-sm text-muted-foreground`,children:e})}function wn({value:e,query:t}){let n=t.trim().toLowerCase();if(!n)return(0,L.jsx)(L.Fragment,{children:e});let r=e.toLowerCase(),i=[],a=0,o=r.indexOf(n);for(;o>=0;){o>a&&i.push(e.slice(a,o));let t=o+n.length;i.push((0,L.jsx)(`mark`,{className:`rounded-sm bg-amber-200 px-0.5 text-inherit dark:bg-amber-500/35`,children:e.slice(o,t)},`${o}-${t}`)),a=t,o=r.indexOf(n,a)}return avoid 0);function ft(e){return s.includes(e)}function pt(e){return s.includes(`${e}:dry_run`)}function mt(e){return ft(e)||pt(e)}function ht(e){return ft(on[e])}function gt(){return Object.keys(an).find(e=>ht(e))||`people`}function N(e,t){o({message:e,tone:t})}function P(e,t){N(mn(e,t),`error`)}function F(e,t){xe(n=>({...n,[e]:t}))}function _t(e,t=!1){let n=e;ht(n)||(N(`${n[0].toUpperCase()}${n.slice(1)} requires SSO validation`,`error`),n=gt()),n!==`gigs`&&ce(``),n!==`projects`&&ue(``),n===`gigs`&&t&&ce(``),n===`projects`&&t&&ue(``),i(n),t?window.history.pushState({view:n},``,an[n]):(!Object.hasOwn(an,hn())||n!==e)&&window.history.replaceState({view:n},``,an[n])}dt.current=_t;function vt(e){return!u||!e?``:`${u}/#Contact/view/${encodeURIComponent(e)}`}function yt(e){return!u||!e?``:`${u}/api/v1/Attachment/file/${encodeURIComponent(e)}`}function bt(e,t){De(n=>{let r=n[e];return{...n,[e]:{key:t,direction:r.key===t&&r.direction===`asc`?`desc`:`asc`}}})}function xt(e){ce(e),se(m.find(t=>t.id===e)||null),i(`gigs`),window.history.pushState({view:`gigs`,gigId:e},``,`/dashboard/gigs/${encodeURIComponent(e)}`)}function St(){ce(``),se(null),window.history.replaceState({view:`gigs`},``,an.gigs)}function Ct(e){ue(e),i(`projects`),window.history.pushState({view:`projects`,projectId:e},``,`/dashboard/projects/${encodeURIComponent(e)}`)}function wt(){ue(``),window.history.replaceState({view:`projects`},``,an.projects)}async function Tt(){let e=await G(`/dashboard/api/me`);n(e);let t=Array.isArray(e.permissions)?e.permissions:[];return c(t),d((e.crm_base_url||``).replace(/\/+$/,``)),t}function Et(){let e=new URLSearchParams({minutes:Oe,limit:`100`});return Ae&&e.set(`status`,Ae),Me.trim()&&e.set(`type`,Me.trim()),`/dashboard/api/jobs?${e.toString()}`}function Dt(){let e=new URLSearchParams({limit:String(Re)});return Pe&&e.set(`status`,Pe),Ie&&e.set(`include_historical`,`true`),`/dashboard/api/gigs?${e.toString()}`}function Ot(){let e=new URLSearchParams({limit:`100`,status:He});return Be.trim()&&e.set(`query`,Be.trim()),`/dashboard/api/projects?${e.toString()}`}async function kt(){F(`jobs`,!0),N(`Loading jobs`);try{let e=await G(Et());p(e),N(`Loaded ${e.length} jobs`,`ok`)}catch(e){P(e,`Unable to load jobs`)}finally{F(`jobs`,!1)}}async function At(){F(`gigs`,!0);try{let e=await G(Dt());v(e),N(`Loaded ${e.length} gig${e.length===1?``:`s`}`,`ok`),U()}catch(e){P(e,`Unable to load gigs`)}finally{F(`gigs`,!1)}}async function jt(){F(`projects`,!0);try{let e=await G(Ot());S(e.projects||[]),ne(e.summary||{}),N(`Loaded ${(e.projects||[]).length} project${(e.projects||[]).length===1?``:`s`}`,`ok`)}catch(e){P(e,`Unable to load projects`)}finally{F(`projects`,!1)}}async function Mt(){F(`syncProjects`,!0),N(`Queueing project sync`);try{let e=await G(`/dashboard/api/sync/projects`,{method:`POST`});e.dry_run?N(`Dry run only: would queue ${e.would_enqueue?.job_type||`project sync`}`,`warning`):N(`Queued project sync ${e.job_id}`,`ok`)}catch(e){P(e,`Unable to queue project sync`)}finally{F(`syncProjects`,!1)}}async function Nt(e){let t=e.trim();if(t.length<2)return[];try{return(await G(`/dashboard/api/erpnext/customers?${new URLSearchParams({query:t}).toString()}`)).customers||[]}catch(e){return N(e instanceof Error?e.message:`Unable to search customers`,`error`),[]}}async function Pt(e){let t=e.trim();if(t.length<2)return[];try{return(await G(`/dashboard/api/erpnext/contacts?${new URLSearchParams({query:t}).toString()}`)).contacts||[]}catch(e){return N(e instanceof Error?e.message:`Unable to search contacts`,`error`),[]}}async function R(e){let t=e.trim();if(t.length<2)return[];try{return(await G(`/dashboard/api/erpnext/account-managers?${new URLSearchParams({query:t}).toString()}`)).users||[]}catch(e){return N(e instanceof Error?e.message:`Unable to search account managers`,`error`),[]}}async function Ft(){try{let e=(await G(`/dashboard/api/erpnext/cost-centers`)).cost_centers||[];return e.length?e:[{name:`Projects - 5`,cost_center_name:`Projects`}]}catch(e){return N(e instanceof Error?e.message:`Unable to load cost centers`,`error`),[{name:`Projects - 5`,cost_center_name:`Projects`}]}}async function It(e){F(`createProject`,!0);try{let t=await G(`/dashboard/api/projects/create`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(e)});return t.project.id?(S(e=>e.some(e=>e.id===t.project.id)?e.map(e=>e.id===t.project.id?t.project:e):[t.project,...e]),N(t.setup_warnings?.length?t.setup_warning_message||`Created ERP project setup; account manager setup needs follow-up`:`Created ERP project setup`,t.setup_warnings?.length?`warning`:`ok`),Ct(t.project.id)):(N([t.cache_refresh_message||`Created ERP project in ERPNext; local sync is pending`,t.setup_warnings?.length?t.setup_warning_message||`Account manager setup needs follow-up`:``].filter(Boolean).join(` `),t.setup_warnings?.length?`warning`:`ok`),jt()),!0}catch(e){return N(e instanceof Error?e.message:`Unable to create project`,`error`),!1}finally{F(`createProject`,!1)}}async function B(e,t){F(`project:${e}:status`,!0);try{let n=await G(`/dashboard/api/projects/${encodeURIComponent(e)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t})});S(t=>t.map(t=>t.id===e?n.project:t)),N(`Updated project status`,`ok`)}catch(e){P(e,`Unable to update project`)}finally{F(`project:${e}:status`,!1)}}async function Lt(e,t){if(e.length===0)return!1;F(`projectsBulkUpdate`,!0);try{let n=await G(`/dashboard/api/projects/bulk`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({project_ids:e,...t})}),r=n.projects||[];S(e=>e.map(e=>r.find(t=>t.id===e.id)||e));let i=n.failures||[];return N(i.length?`Updated ${r.length}; ${i.length} failed`:`Updated ${r.length} project${r.length===1?``:`s`}`,i.length?`error`:`ok`),i.length===0}catch(e){return P(e,`Unable to bulk update projects`),!1}finally{F(`projectsBulkUpdate`,!1)}}async function Rt(e,t,n,r){let i=t.trim(),a=n.trim();if(!i||!a)return!1;F(`project:${e}:user`,!0);try{let t=await G(`/dashboard/api/projects/${encodeURIComponent(e)}/users`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({user:i,candidate_id:a,...r||{}})});return S(n=>n.map(n=>n.id===e?t.project:n)),N(t.activity_cost_error?`Added project user; rate failed`:t.activity_cost?`Added project user and rate`:`Added project user`,t.activity_cost_error?`error`:`ok`),!0}catch(e){return P(e,`Unable to add project user`),!1}finally{F(`project:${e}:user`,!1)}}async function V(e,t){let n=t.trim();if(!n)return!1;F(`project:${e}:user`,!0);try{let t=await G(`/dashboard/api/projects/${encodeURIComponent(e)}/users/remove`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({user:n})});return S(n=>n.map(n=>n.id===e?t.project:n)),N(`Removed project user`,`ok`),!0}catch(e){return N(e instanceof Error?e.message:`Unable to remove project user`,`error`),!1}finally{F(`project:${e}:user`,!1)}}async function H(e,t,n){let r=t.trim();if(!r)return!1;F(`project:${e}:historical`,!0);try{let t=await G(`/dashboard/api/projects/${encodeURIComponent(e)}/historical-members`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({person:r,candidate_id:n})});return S(n=>n.map(n=>n.id===e?t.project:n)),Te(null),N(`Added historical project member`,`ok`),!0}catch(t){if(t instanceof fn&&t.status===409){let n=t.payload?.candidates||[];if(n.length>0)return Te({projectId:e,person:r,candidates:n}),N(`Choose the matching person record`,`error`),!1}return P(t,`Unable to add historical member`),!1}finally{F(`project:${e}:historical`,!1)}}async function zt(e,t){let n=t.trim();if(!n)return!1;F(`project:${e}:historical`,!0);try{let t=await G(`/dashboard/api/projects/${encodeURIComponent(e)}/historical-members/remove`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({source_user_id:n})});return S(n=>n.map(n=>n.id===e?t.project:n)),N(`Removed historical project member`,`ok`),!0}catch(e){return N(e instanceof Error?e.message:`Unable to remove historical member`,`error`),!1}finally{F(`project:${e}:historical`,!1)}}async function Bt(e,t,n){F(`project:${e}:wiki`,!0);try{await G(`/dashboard/api/projects/${encodeURIComponent(e)}/wiki-match`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t,row_key:n})}),N(t===`no_row`?`Marked as no wiki row`:`Confirmed wiki match`,`ok`),await Vt()}catch(e){P(e,`Unable to save wiki match`)}finally{F(`project:${e}:wiki`,!1)}}async function Vt(){F(`wikiMatches`,!0);try{ae(await G(`/dashboard/api/projects/wiki-matches`)),N(`Loaded wiki match preview`,`ok`)}catch(e){P(e,`Unable to load wiki matches`)}finally{F(`wikiMatches`,!1)}}async function Ht(e){F(`gig:${e}:detail`,!0);try{se(await G(`/dashboard/api/gigs/${encodeURIComponent(e)}`))}catch(e){se(null),P(e,`Unable to load gig`)}finally{F(`gig:${e}:detail`,!1)}}async function Ut(){await At(),w&&await Ht(w)}async function U(){if(ft(`gigs:read`)){F(`notifications`,!0);try{let e=await G(`/dashboard/api/notifications?limit=20`);We(e.stale_days||7),E(e.notifications||[])}catch(e){P(e,`Unable to load notifications`)}finally{F(`notifications`,!1)}}}async function W(e,t){F(`gig:${e}:status`,!0);try{let n=(await G(`/dashboard/api/gigs/${encodeURIComponent(e)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t})})).discord_title_sync?.status;N(n===`error`?`Updated gig status; Discord title sync failed`:`Updated gig status`,n===`error`?`error`:`ok`),await At(),w===e&&await Ht(e)}catch(e){P(e,`Unable to update gig`)}finally{F(`gig:${e}:status`,!1)}}async function Wt(e,t,n){F(`application:${t}:status`,!0);try{await G(`/dashboard/api/gigs/${encodeURIComponent(e)}/applications/${encodeURIComponent(t)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:n})}),N(`Updated candidate status`,`ok`),await At(),w===e&&await Ht(e)}catch(e){P(e,`Unable to update candidate`)}finally{F(`application:${t}:status`,!1)}}async function Gt(e,t){let n=t.trim();if(!n)return N(`Paste a CRM Contact profile first`,`warning`),!1;F(`gig:${e}:addCandidate`,!0);try{return await G(`/dashboard/api/gigs/${encodeURIComponent(e)}/applications`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({crm_profile:n})}),N(`Added candidate`,`ok`),await At(),w===e&&await Ht(e),!0}catch(e){return P(e,`Unable to add candidate`),!1}finally{F(`gig:${e}:addCandidate`,!1)}}function Kt(){let e=new URLSearchParams({limit:`25`});Ge.trim()&&e.set(`query`,Ge.trim()),qe&&e.set(`is_member`,qe);for(let[t,n]of Object.entries(Ye))n&&e.set(t,n);return`/dashboard/api/people?${e.toString()}`}async function qt(){F(`people`,!0);try{D(await G(Kt()))}catch(e){P(e,`Unable to load people`)}finally{F(`people`,!1)}}function Jt(){let e=new URLSearchParams({limit:`25`});et.trim()&&e.set(`query`,et.trim()),nt&&e.set(`onboarding_state`,nt),it.trim()&&e.set(`onboarder`,it.trim());for(let[t,n]of Object.entries(ot))n&&e.set(t,n);return`/dashboard/api/onboarding?${e.toString()}`}async function Xt(){F(`onboarding`,!0);try{k(await G(Jt()))}catch(e){P(e,`Unable to load onboarding`)}finally{F(`onboarding`,!1)}}async function Zt(){F(`audit`,!0);try{he(await G(`/dashboard/api/audit-events?limit=25`))}catch(e){P(e,`Unable to load audit events`)}finally{F(`audit`,!1)}}async function Qt(){F(`agent`,!0);try{_e(await G(`/dashboard/api/agent?limit=100`))}catch(e){P(e,`Unable to load agent report`)}finally{F(`agent`,!1)}}async function $t(e){F(`detail:${e}`,!0),N(`Loading ${e}`);try{ye(await G(`/dashboard/api/jobs/${encodeURIComponent(e)}`)),N(`Loaded ${e}`,`ok`)}catch(e){P(e,`Unable to load job detail`)}finally{F(`detail:${e}`,!1)}}async function en(e){F(`rerun:${e}`,!0),N(`Rerunning ${e}`);try{let t=await G(`/dashboard/api/jobs/${encodeURIComponent(e)}/rerun`,{method:`POST`});t.dry_run?N(`Dry run only: would rerun ${t.would_enqueue?.job_type||e}`,`warning`):(N(`Queued rerun ${t.job_id}`,`ok`),await kt())}catch(e){P(e,`Unable to rerun job`)}finally{F(`rerun:${e}`,!1)}}async function tn(){F(`syncPeople`,!0),N(`Queueing people sync`);try{let e=await G(`/dashboard/api/sync/people`,{method:`POST`});e.dry_run?N(`Dry run only: would queue ${e.would_enqueue?.job_type||`people sync`}`,`warning`):N(`Queued people sync ${e.job_id}`,`ok`)}catch(e){P(e,`Unable to queue people sync`)}finally{F(`syncPeople`,!1)}}async function nn(){F(`syncNewsletters`,!0),N(`Queueing newsletter sync`);try{let e=await G(`/dashboard/api/sync/newsletters`,{method:`POST`});e.dry_run?N(`Dry run only: would queue ${e.would_enqueue?.job_type||`newsletter sync`}`,`warning`):N(`Queued newsletter sync ${e.job_id}`,`ok`)}catch(e){P(e,`Unable to queue newsletter sync`)}finally{F(`syncNewsletters`,!1)}}async function rn(e,t){let n=String(e||``).trim(),r=t.trim();if(!n){N(`Missing CRM contact id`,`error`);return}if(!r){N(`Enter a 508 username`,`error`);return}F(`onboarder:${n}`,!0),N(`Assigning ${r}`);try{let e=await G(`/dashboard/api/onboarding/${encodeURIComponent(n)}/onboarder`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({onboarder:r})});k(t=>t.map(t=>t.crm_contact_id===e.contact_id?{...t,onboarder:e.onboarder,onboarding_state:e.state_updated&&e.onboarding_state?e.onboarding_state:t.onboarding_state,onboarding_status_label:e.onboarding_status_label||(e.state_updated?void 0:t.onboarding_status_label)}:t)),N(`Assigned ${e.onboarder}`,`ok`)}catch(e){P(e,`Unable to assign onboarder`)}finally{F(`onboarder:${n}`,!1)}}async function cn(e,t){let n=String(e||``).trim(),r=t.trim();if(!n){N(`Missing CRM contact id`,`error`);return}if(!r){N(`Choose an onboarding status`,`error`);return}F(`onboarding-status:${n}`,!0),N(`Updating onboarding status`);try{let e=await G(`/dashboard/api/onboarding/${encodeURIComponent(n)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:r})}),t=dn(e.onboarding_state),i=e.onboarding_status_label||Yt(t);k(n=>n.map(n=>n.crm_contact_id===e.contact_id?{...n,onboarding_state:t,onboarding_status_label:i}:n).filter(n=>n.crm_contact_id!==e.contact_id||!un.has(t))),N(`Status set to ${i}`,`ok`)}catch(e){P(e,`Unable to update onboarding status`)}finally{F(`onboarding-status:${n}`,!1)}}async function ln(e){let t=e.email.trim().toLowerCase(),n=e.first_name.trim();if(!t?.endsWith(`@508.dev`))return N(`Enter the engineer's @508.dev email`,`error`),null;if(!n)return N(`Enter the engineer name`,`error`),null;F(`engineerSetup`,!0);try{let r=await G(`/dashboard/api/onboarding/engineers`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({...e,email:t,first_name:n})});return N(`Set up ${r.employee_name||r.user||t}`,`ok`),r}catch(e){if(e instanceof fn&&e.status===409){let t=e.payload&&typeof e.payload==`object`?e.payload:null,n=(Array.isArray(t?.matches)?t.matches:[]).map(e=>e?.label||e?.email).filter(Boolean).slice(0,2).join(`, `);N(n?`Similar account exists: ${n}`:`Similar account exists; confirm before creating`,`error`)}else N(e instanceof Error?e.message:`Unable to set up engineer`,`error`);return null}finally{F(`engineerSetup`,!1)}}async function pn(){F(`logout`,!0);try{let e=await G(`/auth/logout`,{method:`POST`});window.location.assign(e.end_session_url||`/dashboard`)}catch(e){P(e,`Unable to log out`),F(`logout`,!1)}}(0,l.useEffect)(()=>{Tt().then(e=>{let t=gn(),n=e.includes(on[t])?t:Object.keys(an).find(t=>e.includes(on[t]))||`people`;ce(n===`gigs`?_n():``),ue(n===`projects`?_n(`projects`):``),i(n),(!Object.hasOwn(an,hn())||n!==t)&&window.history.replaceState({view:n},``,an[n])}).catch(e=>{P(e,`Dashboard failed to load`)})},[]),(0,l.useEffect)(()=>{let e=()=>{ce(_n()),ue(_n(`projects`)),dt.current(gn(),!1)};return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),(0,l.useEffect)(()=>{if(!a.message)return;let e=window.setTimeout(()=>o({message:``}),4500);return()=>window.clearTimeout(e)},[a.message]),(0,l.useEffect)(()=>{},[]),(0,l.useEffect)(()=>{s.length!==0&&(ft(`gigs:read`)&&U(),r===`people`&&qt(),r===`gigs`&&At(),r===`projects`&&jt(),r===`onboarding`&&Xt(),r===`jobs`&&kt(),r===`agent`&&Qt(),r===`audit`&&Zt())},[r]),(0,l.useEffect)(()=>{s.length!==0&&(ft(`gigs:read`)&&U(),r===`people`&&qt(),r===`gigs`&&At(),r===`projects`&&jt(),r===`onboarding`&&Xt(),r===`jobs`&&kt(),r===`agent`&&Qt(),r===`audit`&&Zt())},[s]),(0,l.useEffect)(()=>{r===`jobs`&&s.length>0&&kt()},[Oe,Ae]),(0,l.useEffect)(()=>{r===`gigs`&&s.length>0&&At()},[Pe,Ie,Re]),(0,l.useEffect)(()=>{r===`projects`&&s.length>0&&jt()},[He]),(0,l.useEffect)(()=>{r===`gigs`&&w&&s.length>0&&Ht(w)},[r,w,s]),(0,l.useEffect)(()=>{r===`people`&&s.length>0&&qt()},[qe]),(0,l.useEffect)(()=>{r===`people`&&s.length>0&&qt()},[Ye]),(0,l.useEffect)(()=>{r===`onboarding`&&s.length>0&&Xt()},[nt]),(0,l.useEffect)(()=>{r===`onboarding`&&s.length>0&&Xt()},[ot]);let vn=(0,l.useMemo)(()=>yn(`jobs`,f,Ee.jobs),[f,Ee.jobs]),bn=(0,l.useMemo)(()=>yn(`people`,pe,Ee.people),[pe,Ee.people]),xn=(0,l.useMemo)(()=>yn(`onboarding`,O,Ee.onboarding),[O,Ee.onboarding]),Sn=(0,l.useMemo)(()=>yn(`gigs`,m,Ee.gigs),[m,Ee.gigs]),Cn=(0,l.useMemo)(()=>yn(`projects`,ee,Ee.projects),[ee,Ee.projects]),wn=(0,l.useMemo)(()=>oe?.id===w?oe:Sn.find(e=>e.id===w)||null,[oe,w,Sn]),Tn=(0,l.useMemo)(()=>Cn.find(e=>e.id===le)||null,[le,Cn]),kn=(0,l.useMemo)(()=>yn(`audit`,me,Ee.audit),[me,Ee.audit]),An=(0,l.useMemo)(()=>f.reduce((e,t)=>(e[t.status]=(e[t.status]||0)+1,e),{}),[f]),jn=Object.keys(sn).filter(e=>!Ye[e]),Mn=Object.keys(sn).filter(e=>e!==`sync_status`&&e!==`email_508`&&!ot[e]);function Nn(e){if(e.type===`stale_recruiting_gig`){let t=e.engagement_id||(e.id.startsWith(`stale-recruiting:`)?e.id.slice(17):``);t?xt(t):(Fe(`recruiting`),_t(`gigs`,!0))}fe(!1)}(0,l.useEffect)(()=>{!jn.includes(Ze)&&jn[0]&&Qe(jn[0])},[jn,Ze]),(0,l.useEffect)(()=>{let e=sn[Ze]?.options;e?.[0]&&!e.some(([e])=>e===j)&&$e(e[0][0])},[Ze,j]),(0,l.useEffect)(()=>{!Mn.includes(ct)&&Mn[0]&<(Mn[0])},[Mn,ct]),(0,l.useEffect)(()=>{let e=sn[ct]?.options;e?.[0]&&!e.some(([e])=>e===M)&&ut(e[0][0])},[ct,M]);let Pn=[t?.email,t?.crm_contact_id?`CRM ${t.crm_contact_id}`:``,t?.actor_provider].filter(Boolean).join(` | `);return(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`header`,{className:`sticky top-0 z-20 border-b bg-background/90 backdrop-blur`,children:(0,L.jsxs)(`div`,{className:`mx-auto flex max-w-7xl flex-col gap-4 px-5 py-4 md:flex-row md:items-center md:justify-between`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`h1`,{className:`text-xl font-bold`,children:`508 Operations Dashboard`}),(0,L.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Operations view for authenticated 508 operators.`})]}),(0,L.jsxs)(`div`,{className:`flex min-w-0 items-center gap-3`,children:[ft(`gigs:read`)?(0,L.jsx)(`div`,{className:`relative`,children:(0,L.jsxs)(z,{id:`notifications`,type:`button`,variant:`outline`,size:`icon`,"aria-label":`Notifications`,"aria-expanded":de,onClick:()=>fe(e=>!e),children:[(0,L.jsx)(h,{}),T.length>0?(0,L.jsx)(`span`,{className:`absolute -right-1 -top-1 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold text-white`,children:T.length}):null]})}):null,(0,L.jsxs)(`div`,{className:`grid min-w-0 gap-0.5 text-right text-sm text-muted-foreground`,children:[(0,L.jsx)(`strong`,{id:`userName`,className:`truncate text-foreground`,children:t?.display_name||t?.email||t?.subject||`Loading user`}),(0,L.jsx)(`span`,{id:`userMeta`,className:`truncate`,children:Pn||`Checking session`})]}),(0,L.jsxs)(z,{id:`logout`,type:`button`,variant:`outline`,onClick:pn,disabled:be.logout,children:[(0,L.jsx)(x,{}),`Log out`]})]})]})}),(0,L.jsx)(En,{open:de,notifications:T,loading:be.notifications,onClose:()=>fe(!1),onRefresh:U,onOpenNotification:Nn}),(0,L.jsx)(On,{toast:a}),null,(0,L.jsx)(Dn,{choice:we,loading:!!(we&&be[`project:${we.projectId}:historical`]),crmContactUrl:vt,onClose:()=>Te(null),onChoose:e=>{we&&H(we.projectId,we.person,e)}}),(0,L.jsxs)(`main`,{className:`mx-auto grid max-w-7xl grid-cols-1 gap-5 px-5 py-5 md:grid-cols-[190px_minmax(0,1fr)]`,children:[(0,L.jsx)(`nav`,{className:`grid content-start gap-1 md:sticky md:top-24`,"aria-label":`Dashboard sections`,children:[[`people`,`People`,ie],[`gigs`,`Gigs`,g],[`projects`,`Projects`,b],[`onboarding`,`Onboarding`,_],[`jobs`,`Jobs`,g],[`agent`,`Agent`,te],[`audit`,`Audit`,y]].filter(([e])=>ht(e)).map(([e,t,n])=>(0,L.jsxs)(`a`,{className:I(`flex min-h-10 items-center gap-2 rounded-md border border-transparent px-3 text-sm font-extrabold text-muted-foreground hover:border-border hover:bg-secondary hover:text-foreground`,r===e&&`border-primary bg-accent text-accent-foreground`),"data-view-link":e,"data-permission":on[e],href:an[e],"aria-current":r===e?`page`:void 0,onClick:t=>{t.preventDefault(),_t(e,!0)},children:[(0,L.jsx)(n,{className:`size-4`}),t]},e))}),(0,L.jsxs)(`div`,{className:`grid min-w-0 gap-5`,children:[r===`people`?(0,L.jsx)(qn,{crmBaseUrl:u,people:bn,sort:Ee.people,canSync:mt(`people:sync`),canSyncNewsletters:mt(`people:sync`),loading:be,peopleQuery:Ge,peopleMember:qe,peopleFilters:Ye,peopleFilterKind:Ze,peopleFilterValue:j,peopleFilterKeys:jn,onSearch:qt,onSync:tn,onSyncNewsletters:nn,onSort:e=>bt(`people`,e),setPeopleQuery:Ke,setPeopleMember:Je,setPeopleFilterKind:Qe,setPeopleFilterValue:$e,addFilter:()=>{Xe(e=>({...e,[Ze]:j}))},removeFilter:e=>{Xe(t=>{let n={...t};return delete n[e],n})},crmContactUrl:vt,crmAttachmentUrl:yt}):null,r===`gigs`?(0,L.jsx)(Hn,{gigs:Sn,selectedGig:wn,selectedGigId:w,sort:Ee.gigs,loading:be,status:Pe,includeHistorical:Ie,limit:Re,staleDays:A,canWrite:ft(`gigs:write`),canIncludeHistorical:ft(`people:read`),crmContactUrl:vt,crmAttachmentUrl:yt,setStatus:Fe,setIncludeHistorical:Le,setLimit:ze,onRefresh:Ut,onSort:e=>bt(`gigs`,e),onOpenGig:xt,onCloseGig:St,onUpdateStatus:W,onAddApplication:Gt,onUpdateApplicationStatus:Wt}):null,r===`projects`?(0,L.jsx)(zn,{projects:Cn,selectedProject:Tn,selectedProjectId:le,summary:C,wikiMatches:re,sort:Ee.projects,loading:be,query:Be,status:He,canSync:mt(`projects:sync`),canWrite:ft(`projects:write`),crmContactUrl:vt,setQuery:Ve,setStatus:Ue,onSearch:jt,onSync:Mt,onSearchCustomers:Nt,onSearchContacts:Pt,onSearchAccountManagers:R,onLoadCostCenters:Ft,onCreateProject:It,onUpdateStatus:B,onBulkUpdate:Lt,onAddUser:Rt,onRemoveUser:V,onAddHistoricalMember:H,onRemoveHistoricalMember:zt,onUpdateWikiMatch:Bt,onWikiMatches:Vt,onOpenProject:Ct,onCloseProject:wt,onSort:e=>bt(`projects`,e)}):null,r===`onboarding`?(0,L.jsx)(Jn,{people:xn,sort:Ee.onboarding,loading:be,onboardingQuery:et,onboardingState:nt,onboarderFilter:it,onboardingFilters:ot,onboardingFilterKind:ct,onboardingFilterValue:M,onboardingFilterKeys:Mn,onSearch:Xt,onSort:e=>bt(`onboarding`,e),onAssign:rn,onStatusChange:cn,onSetupEngineer:ln,setOnboardingQuery:tt,setOnboardingState:rt,setOnboarderFilter:at,setOnboardingFilterKind:lt,setOnboardingFilterValue:ut,addFilter:()=>{st(e=>({...e,[ct]:M}))},removeFilter:e=>{st(t=>{let n={...t};return delete n[e],n})},crmContactUrl:vt,crmAttachmentUrl:yt,canWrite:ft(`onboarding:write`)}):null,r===`jobs`?(0,L.jsx)(nr,{jobs:vn,jobDetail:ve,sort:Ee.jobs,loading:be,minutes:Oe,status:Ae,jobType:Me,jobCounts:An,canWrite:mt(`jobs:write`),setMinutes:ke,setStatus:je,setJobType:Ne,onSearch:kt,onSort:e=>bt(`jobs`,e),onDetail:$t,onRerun:en}):null,r===`audit`?(0,L.jsx)(rr,{events:kn,sort:Ee.audit,loading:be,onRefresh:Zt,onSort:e=>bt(`audit`,e)}):null,r===`agent`?(0,L.jsx)(ir,{report:ge,loading:be,onRefresh:Qt}):null]})]})]})}function En({open:e,notifications:t,loading:n,onClose:r,onRefresh:i,onOpenNotification:a}){return e?(0,L.jsxs)(`div`,{className:`fixed inset-0 z-40`,"aria-labelledby":`notificationsTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,L.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close notifications`,onClick:r}),(0,L.jsxs)(`aside`,{className:`absolute right-0 top-0 grid h-full w-full max-w-md grid-rows-[auto_minmax(0,1fr)] border-l bg-background shadow-2xl`,children:[(0,L.jsxs)(`div`,{className:`flex items-center justify-between gap-3 border-b p-4`,children:[(0,L.jsxs)(`div`,{className:`grid gap-0.5`,children:[(0,L.jsx)(`strong`,{id:`notificationsTitle`,className:`text-base`,children:`Notifications`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:t.length===0?`No active notifications`:`${t.length} active`})]}),(0,L.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,L.jsxs)(z,{type:`button`,variant:`outline`,size:`sm`,onClick:i,disabled:n,children:[(0,L.jsx)(S,{}),`Refresh`]}),(0,L.jsx)(z,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close`,onClick:r,children:(0,L.jsx)(ae,{})})]})]}),(0,L.jsx)(`div`,{className:`min-h-0 overflow-auto p-4`,children:t.length===0?(0,L.jsx)(`div`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:`No active notifications.`}):(0,L.jsx)(`div`,{className:`grid gap-3`,children:t.map(e=>(0,L.jsxs)(`button`,{type:`button`,className:`grid gap-2 rounded-md border p-3 text-left hover:bg-secondary`,onClick:()=>a(e),children:[(0,L.jsx)(`span`,{className:`text-sm font-bold`,children:e.title}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.message})]},e.id))})})]})]}):null}function Dn({choice:e,loading:t,crmContactUrl:n,onClose:r,onChoose:i}){return e?(0,L.jsxs)(`div`,{className:`fixed inset-0 z-50 grid place-items-center p-4`,"aria-labelledby":`historicalPersonChoiceTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,L.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close person selection`,onClick:r}),(0,L.jsxs)(`div`,{className:`relative grid w-full max-w-2xl gap-4 rounded-md border bg-background p-5 shadow-2xl`,children:[(0,L.jsxs)(`div`,{className:`flex items-start justify-between gap-3`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`strong`,{id:`historicalPersonChoiceTitle`,className:`block text-base`,children:`Choose person record`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.person})]}),(0,L.jsx)(z,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close person selection`,onClick:r,children:(0,L.jsx)(ae,{})})]}),(0,L.jsx)(`div`,{className:`grid gap-2`,children:e.candidates.map(e=>(0,L.jsxs)(`div`,{className:`grid gap-3 rounded-md border p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center`,children:[(0,L.jsxs)(`div`,{className:`min-w-0`,children:[(0,L.jsx)(`strong`,{className:`block truncate`,children:e.label||e.full_name||e.email||`Person`}),(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground`,children:[e.email?(0,L.jsx)(`span`,{children:e.email}):null,e.sources?.length?(0,L.jsx)(`span`,{children:e.sources.join(`, `)}):null,e.erpnext_user_id?(0,L.jsxs)(`span`,{children:[`ERP `,e.erpnext_user_id]}):null,e.supplier_erpnext_id?(0,L.jsxs)(`span`,{children:[`Supplier `,e.supplier_erpnext_id]}):null,e.crm_contact_id&&n(e.crm_contact_id)?(0,L.jsx)(`a`,{className:`font-semibold text-primary underline-offset-4 hover:underline`,href:n(e.crm_contact_id),target:`_blank`,rel:`noreferrer`,children:`CRM`}):null]})]}),(0,L.jsx)(z,{type:`button`,disabled:t,onClick:()=>i(e.candidate_id),children:`Select`})]},e.candidate_id))})]})]}):null}function On({toast:e}){return e.message?(0,L.jsx)(`div`,{id:`toast`,role:`status`,className:I(`fixed bottom-5 right-5 z-50 max-w-sm rounded-md border bg-background px-4 py-3 text-sm font-semibold shadow-lg`,e.tone===`ok`&&`border-emerald-500/40 text-emerald-300`,e.tone===`warning`&&`border-amber-500/40 text-amber-200`,e.tone===`error`&&`border-red-500/40 text-red-300`),children:e.message}):null}function kn({filters:e,onRemove:t,suffix:n=`filter`}){return(0,L.jsx)(`fieldset`,{className:`m-0 flex min-h-7 flex-wrap gap-2 border-0 p-0`,"aria-label":`Active filters`,children:Object.entries(e).map(([e,r])=>{let i=sn[e],a=i.options.find(([e])=>e===r),o=`${i.label}: ${a?a[1]:r}`;return(0,L.jsxs)(z,{type:`button`,variant:`outline`,size:`sm`,className:`rounded-full`,"aria-label":`Remove ${o} ${n}`,onClick:()=>t(e),children:[o,` x`]},e)})})}var An=[`recruiting`,`filled`,`unknown`,`lost`,`outdated`],jn=[`suggested`,`interested`,`reviewing`,`contacted`,`accepted`,`unavailable`,`rejected`,`withdrawn`];function Mn(e){return String(e||``).replace(/[-_]+/g,` `).replace(/\s+/g,` `).trim().replace(/\b\w/g,e=>e.toUpperCase())}function Nn(e){let t=[e.last_activity_at,e.last_status_changed_at,e.posted_at,e.created_at].map(e=>e?new Date(e).getTime():NaN).filter(e=>!Number.isNaN(e));return t.length>0?new Date(Math.max(...t)).toISOString():``}function Pn(e,t){if(e.status!==`recruiting`)return null;let n=Kt(Nn(e));return n===null||ne.projects.map(e=>e.id),[e.projects]),h=(0,l.useMemo)(()=>new Set(p),[p]),g=n.filter(e=>h.has(e)),_=e.projects.length>0&&g.length===e.projects.length;(0,l.useEffect)(()=>{r(e=>e.filter(e=>h.has(e)))},[h]);function y(e,t){r(n=>t?Array.from(new Set([...n,e])):n.filter(t=>t!==e))}async function b(){let t={};i&&(t.status=i),o&&(t.project_type=o),await e.onBulkUpdate(g,t)&&(r([]),a(``),s(``),u(!1))}let x=(0,L.jsxs)(It,{className:`grid gap-3 p-4 md:grid-cols-[minmax(0,1fr)_180px_auto_auto_auto] md:items-end`,children:[(0,L.jsxs)(H,{children:[`Search projects`,(0,L.jsx)(V,{id:`projectQuery`,value:e.query,autoComplete:`off`,placeholder:`Project, customer, ERP id`,onChange:t=>e.setQuery(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,L.jsxs)(H,{children:[`Status`,(0,L.jsxs)(zt,{id:`projectStatus`,value:e.status,onChange:t=>e.setStatus(t.target.value),children:[(0,L.jsx)(`option`,{value:`Open`,children:`Open`}),(0,L.jsx)(`option`,{value:``,children:`Any status`})]})]}),(0,L.jsxs)(z,{id:`refreshProjects`,type:`button`,onClick:e.onSearch,disabled:e.loading.projects,children:[(0,L.jsx)(S,{}),`Refresh`]}),e.canSync?(0,L.jsxs)(z,{id:`syncProjects`,type:`button`,variant:`outline`,onClick:e.onSync,disabled:e.loading.syncProjects,children:[(0,L.jsx)(S,{}),`Sync ERP`]}):null,(0,L.jsxs)(z,{id:`wikiProjectMatches`,type:`button`,variant:`outline`,onClick:e.onWikiMatches,disabled:e.loading.wikiMatches,children:[(0,L.jsx)(C,{}),`Wiki match`]})]});return e.selectedProjectId&&!e.selectedProject&&e.loading.projects?(0,L.jsxs)(L.Fragment,{children:[x,(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsx)(Lt,{children:`Project detail`})}),(0,L.jsx)(Rt,{className:`text-sm text-muted-foreground`,children:`Loading project.`})]})]}):e.selectedProjectId&&!e.selectedProject?(0,L.jsxs)(L.Fragment,{children:[x,(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsx)(Lt,{children:`Project detail`})}),(0,L.jsxs)(Rt,{className:`grid gap-3`,children:[(0,L.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`This project is not in the current result set. Clear filters or refresh the project list.`}),(0,L.jsxs)(z,{type:`button`,variant:`outline`,onClick:e.onCloseProject,children:[(0,L.jsx)(m,{}),`Back to projects`]})]})]})]}):e.selectedProject?(0,L.jsxs)(L.Fragment,{children:[x,(0,L.jsx)(Vn,{project:e.selectedProject,loading:e.loading,canWrite:e.canWrite,crmContactUrl:e.crmContactUrl,onBack:e.onCloseProject,onUpdateStatus:e.onUpdateStatus,onAddUser:e.onAddUser,onRemoveUser:e.onRemoveUser,onAddHistoricalMember:e.onAddHistoricalMember,onRemoveHistoricalMember:e.onRemoveHistoricalMember})]}):(0,L.jsxs)(L.Fragment,{children:[x,(0,L.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-2`,"aria-label":`Project summary`,children:[(0,L.jsx)(Sn,{id:`projectMetricOpen`,label:`Open`,value:e.summary.open_project_count||0}),(0,L.jsx)(Sn,{id:`projectMetricTotal`,label:`Projects`,value:e.summary.project_count||0})]}),e.canWrite?(0,L.jsxs)(It,{className:`flex flex-wrap items-center justify-between gap-3 p-4`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Selected`}),(0,L.jsxs)(`strong`,{className:`block`,children:[g.length,` project(s)`]})]}),(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,L.jsxs)(z,{type:`button`,onClick:()=>f(!0),children:[(0,L.jsx)(ee,{}),`New project`]}),(0,L.jsx)(z,{type:`button`,variant:`outline`,disabled:g.length===0,onClick:()=>u(!0),children:`Bulk edit`})]})]}):null,d?(0,L.jsx)(Bn,{loading:e.loading.createProject,onClose:()=>f(!1),onSearchCustomers:e.onSearchCustomers,onSearchContacts:e.onSearchContacts,onSearchAccountManagers:e.onSearchAccountManagers,onLoadCostCenters:e.onLoadCostCenters,onCreateProject:e.onCreateProject}):null,c?(0,L.jsxs)(`div`,{className:`fixed inset-0 z-50 grid place-items-center p-4`,"aria-labelledby":`bulkProjectEditTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,L.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close bulk project edit`,onClick:()=>u(!1)}),(0,L.jsxs)(`div`,{className:`relative grid w-full max-w-lg gap-4 rounded-md border bg-background p-5 shadow-2xl`,children:[(0,L.jsxs)(`div`,{className:`flex items-start justify-between gap-3`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`strong`,{id:`bulkProjectEditTitle`,className:`block text-base`,children:`Bulk edit projects`}),(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[g.length,` selected`]})]}),(0,L.jsx)(z,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close bulk project edit`,onClick:()=>u(!1),children:(0,L.jsx)(ae,{})})]}),(0,L.jsxs)(`div`,{className:`grid gap-3`,children:[(0,L.jsx)(`strong`,{className:`text-sm`,children:`Changes`}),(0,L.jsxs)(H,{children:[`Status`,(0,L.jsxs)(zt,{value:i,onChange:e=>a(e.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`No change`}),(0,L.jsx)(`option`,{value:`Open`,children:`Open`}),(0,L.jsx)(`option`,{value:`Completed`,children:`Completed`}),(0,L.jsx)(`option`,{value:`Cancelled`,children:`Cancelled`})]})]}),(0,L.jsxs)(H,{children:[`ERP Type`,(0,L.jsxs)(zt,{value:o,onChange:e=>s(e.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`No change`}),(0,L.jsx)(`option`,{value:`Internal`,children:`Internal`}),(0,L.jsx)(`option`,{value:`External`,children:`External`})]})]})]}),(0,L.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[(0,L.jsx)(z,{type:`button`,variant:`outline`,onClick:()=>u(!1),children:`Cancel`}),(0,L.jsx)(z,{type:`button`,disabled:e.loading.projectsBulkUpdate||g.length===0||!i&&!o,onClick:()=>void b(),children:`Apply changes`})]})]})]}):null,(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`ERP projects`}),(0,L.jsx)(`span`,{id:`projectsStatus`,className:`text-sm text-muted-foreground`,children:e.loading.projects?`Loading`:`${e.projects.length} shown | synced ${Gt(e.summary.last_synced_at)}`})]}),(0,L.jsx)(Cn,{hidden:e.projects.length!==0,children:`No projects match this view. Sync ERP projects if the cache is empty.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`projectsTable`,className:I(`min-w-[1100px]`,e.projects.length===0&&`hidden`),"aria-label":`ERP projects`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[e.canWrite?(0,L.jsx)(U,{className:`w-[48px]`,children:(0,L.jsx)(`input`,{type:`checkbox`,"aria-label":`Select all visible projects`,checked:_,onChange:e=>{r(e.target.checked?p:[])}})}):null,(0,L.jsx)(xn,{className:`w-[24%]`,label:`Project`,scope:`projects`,sort:e.sort,sortKey:`display_name`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[16%]`,label:`Customer`,scope:`projects`,sort:e.sort,sortKey:`customer`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[10%]`,label:`Status`,scope:`projects`,sort:e.sort,sortKey:`status`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(U,{className:`w-[16%]`,children:`Timeline`}),(0,L.jsx)(xn,{className:`w-[10%]`,label:`Roster`,scope:`projects`,sort:e.sort,sortKey:`roster_count`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[14%]`,label:`Modified`,scope:`projects`,sort:e.sort,sortKey:`modified`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(U,{children:`ERP`})]})}),(0,L.jsx)(Ht,{id:`projectsBody`,children:e.projects.map(t=>{let n=t.roster_members||[];return(0,L.jsxs)(Ut,{children:[e.canWrite?(0,L.jsx)(W,{children:(0,L.jsx)(`input`,{type:`checkbox`,"aria-label":`Select ${t.display_name}`,checked:g.includes(t.id),onChange:e=>y(t.id,e.target.checked)})}):null,(0,L.jsxs)(W,{children:[(0,L.jsx)(`button`,{type:`button`,className:`text-left font-bold text-primary underline-offset-4 hover:underline`,onClick:()=>e.onOpenProject(t.id),children:t.display_name}),(0,L.jsxs)(`div`,{className:`mt-1 flex flex-wrap items-center gap-1.5`,children:[t.project_type?(0,L.jsx)(R,{variant:`neutral`,children:t.project_type}):null,t.linked_engagement_count?(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[t.linked_engagement_count,` linked gig`]}):null]})]}),(0,L.jsx)(W,{children:t.customer_erpnext_url?(0,L.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:t.customer_erpnext_url,target:`_blank`,rel:`noreferrer`,children:[t.customer,(0,L.jsx)(v,{className:`size-3.5`})]}):t.customer||`None`}),(0,L.jsx)(W,{children:(0,L.jsx)(R,{variant:Fn(t.source_status),children:t.source_status||`Unknown`})}),(0,L.jsx)(W,{children:[t.actual_start_date,t.actual_end_date].filter(Boolean).map(e=>In(e)).join(` to `)||`Not set`}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`grid gap-1`,children:[(0,L.jsx)(`strong`,{children:n.length}),(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[n.map(Ln).slice(0,4).join(`, `)||`No ERP roster`,n.length>4?` +${n.length-4}`:``]})]})}),(0,L.jsx)(W,{children:Gt(t.source_modified_at)}),(0,L.jsx)(W,{className:`text-xs`,children:t.erpnext_project_url?(0,L.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-mono font-semibold text-primary underline-offset-4 hover:underline`,href:t.erpnext_project_url,target:`_blank`,rel:`noreferrer`,children:[t.erpnext_project_id,(0,L.jsx)(v,{className:`size-3.5`})]}):(0,L.jsx)(`span`,{className:`font-mono`,children:`Unlinked`})})]},t.id)})})]})})]}),e.wikiMatches?(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Wiki match preview`}),(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[e.wikiMatches.document?.title||`Client & Project Info`,` |`,` `,Gt(e.wikiMatches.document?.updatedAt)]})]}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`wikiMatchesTable`,className:`min-w-[920px]`,"aria-label":`Wiki matches`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(U,{children:`ERP project`}),(0,L.jsx)(U,{children:`Best wiki row`}),(0,L.jsx)(U,{children:`Confidence`}),(0,L.jsx)(U,{children:`Section`}),(0,L.jsx)(U,{children:`Decision`})]})}),(0,L.jsx)(Ht,{children:t.map((t,n)=>{let r=t.project,i=t.best_match?.row||{},a=t.manual_match?.match_status||``,o=r?.id||i.row_key||[i.section,i.Client].filter(Boolean).join(`:`)||`wiki-match-${n}`;return(0,L.jsxs)(Ut,{children:[(0,L.jsx)(W,{children:r?.display_name||`Unknown`}),(0,L.jsxs)(W,{children:[(0,L.jsx)(`strong`,{children:i.Client||`No match`}),(0,L.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:[i.DRI,i.Members].filter(Boolean).join(` | `)})]}),(0,L.jsx)(W,{children:(0,L.jsx)(R,{variant:t.best_match?.confidence===`high`?`succeeded`:t.best_match?.confidence===`medium`?`running`:`neutral`,children:t.best_match?`${t.best_match.confidence} ${t.best_match.score}`:`none`})}),(0,L.jsx)(W,{children:i.section||``}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[a?(0,L.jsx)(R,{variant:a===`confirmed`?`succeeded`:`neutral`,children:a===`no_row`?`No wiki row`:`Confirmed`}):null,e.canWrite&&r?.id?(0,L.jsxs)(L.Fragment,{children:[i.row_key?(0,L.jsx)(z,{type:`button`,variant:`outline`,size:`sm`,disabled:e.loading[`project:${r.id}:wiki`],onClick:()=>void e.onUpdateWikiMatch(r.id,`confirmed`,i.row_key),children:`Confirm`}):null,(0,L.jsx)(z,{type:`button`,variant:`outline`,size:`sm`,disabled:e.loading[`project:${r.id}:wiki`],onClick:()=>void e.onUpdateWikiMatch(r.id,`no_row`),children:`No row`})]}):null]})})]},o)})})]})})]}):null]})}function Bn(e){let[t,n]=(0,l.useState)(``),[r,i]=(0,l.useState)(`new`),[a,o]=(0,l.useState)(``),[s,c]=(0,l.useState)(``),[u,d]=(0,l.useState)(``),[f,p]=(0,l.useState)([]),[m,h]=(0,l.useState)(``),[g,_]=(0,l.useState)(``),[v,y]=(0,l.useState)([]),[b,x]=(0,l.useState)(`USD`),[ee,S]=(0,l.useState)(``),[C,te]=(0,l.useState)(``),[ne,re]=(0,l.useState)(``),[ie,oe]=(0,l.useState)(``),[se,w]=(0,l.useState)(``),[ce,le]=(0,l.useState)(``),[ue,T]=(0,l.useState)(`United States`),[E,de]=(0,l.useState)(``),[fe,pe]=(0,l.useState)(`new`),[D,O]=(0,l.useState)(``),[k,me]=(0,l.useState)(``),[he,ge]=(0,l.useState)([]),[_e,ve]=(0,l.useState)(``),[ye,be]=(0,l.useState)(``),[xe,Se]=(0,l.useState)(``),[Ce,we]=(0,l.useState)(``),[Te,Ee]=(0,l.useState)(``),[De,Oe]=(0,l.useState)(!1),[ke,Ae]=(0,l.useState)([{name:`Projects - 5`,cost_center_name:`Projects`}]),[je,Me]=(0,l.useState)(`Projects - 5`),[Ne,Pe]=(0,l.useState)(``),[Fe,Ie]=(0,l.useState)(!1),Le=(0,l.useRef)(e.onSearchCustomers),Re=(0,l.useRef)(e.onSearchContacts),ze=(0,l.useRef)(e.onSearchAccountManagers),Be=(0,l.useRef)(e.onLoadCostCenters),Ve=(0,l.useRef)(0),He=(0,l.useRef)(0),Ue=(0,l.useRef)(0),A=(0,l.useRef)(0),We=t.trim()?`Engineering for ${t.trim()}`.slice(0,140):``,Ge=[ne,ie,se,ce,E].some(e=>e.trim()),Ke=[_e,ye,xe,Ce,Te].some(e=>e.trim()),qe=t.trim()&&(r===`new`?a.trim():u.trim())&&!e.loading;(0,l.useEffect)(()=>{Le.current=e.onSearchCustomers},[e.onSearchCustomers]),(0,l.useEffect)(()=>{Re.current=e.onSearchContacts},[e.onSearchContacts]),(0,l.useEffect)(()=>{ze.current=e.onSearchAccountManagers},[e.onSearchAccountManagers]),(0,l.useEffect)(()=>{Be.current=e.onLoadCostCenters},[e.onLoadCostCenters]),(0,l.useEffect)(()=>{let e=!0,t=Ve.current+1;return Ve.current=t,Be.current().then(n=>{!e||Ve.current!==t||(Ae(n),Me(e=>n.some(t=>t.name===e)?e:`Projects - 5`))}),()=>{e=!1}},[]),(0,l.useEffect)(()=>{if(r!==`existing`){He.current+=1,p([]);return}let e=!0,t=He.current+1;He.current=t;let n=window.setTimeout(()=>{Le.current(s).then(n=>{!e||He.current!==t||p(n)})},250);return()=>{e=!1,window.clearTimeout(n)}},[r,s]),(0,l.useEffect)(()=>{if(r!==`new`){Ue.current+=1,y([]);return}let e=!0,t=Ue.current+1;Ue.current=t;let n=window.setTimeout(()=>{ze.current(m).then(n=>{!e||Ue.current!==t||y(n)})},250);return()=>{e=!1,window.clearTimeout(n)}},[r,m]),(0,l.useEffect)(()=>{if(r!==`new`||fe!==`existing`){A.current+=1,ge([]);return}let e=!0,t=A.current+1;A.current=t;let n=window.setTimeout(()=>{Re.current(D).then(n=>{!e||A.current!==t||ge(n)})},250);return()=>{e=!1,window.clearTimeout(n)}},[r,fe,D]);async function Je(){qe&&await e.onCreateProject({project_name:t.trim(),customer_mode:r,customer_name:r===`new`?a.trim():void 0,customer:r===`existing`?u.trim():void 0,account_manager:r===`new`&&g.trim()||void 0,default_billing_currency:r===`new`?b.trim()||`USD`:void 0,default_cost_center:je.trim()||`Projects - 5`,activity_type:Fe&&Ne.trim()||void 0,customer_details:r===`new`&&ee.trim()||void 0,customer_website:r===`new`&&C.trim()||void 0,address_line1:r===`new`&&ne.trim()||void 0,address_line2:r===`new`&&ie.trim()||void 0,address_city:r===`new`&&se.trim()||void 0,address_state:r===`new`&&ce.trim()||void 0,address_country:r===`new`&&ne.trim()?ue.trim()||`United States`:void 0,address_postal_code:r===`new`&&E.trim()||void 0,contact:r===`new`&&fe===`existing`&&k.trim()||void 0,contact_first_name:r===`new`&&fe===`new`&&_e.trim()||void 0,contact_last_name:r===`new`&&fe===`new`&&ye.trim()||void 0,contact_email:r===`new`&&fe===`new`&&xe.trim()||void 0,contact_phone:r===`new`&&fe===`new`&&Ce.trim()||void 0,contact_mobile:r===`new`&&fe===`new`&&Te.trim()||void 0})&&e.onClose()}return(0,L.jsxs)(`div`,{className:`fixed inset-0 z-50 grid place-items-center p-4`,"aria-labelledby":`createProjectTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,L.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close project creation`,onClick:e.onClose}),(0,L.jsxs)(`div`,{className:`relative grid max-h-[90vh] w-full max-w-2xl gap-4 overflow-y-auto rounded-md border bg-background p-5 shadow-2xl`,children:[(0,L.jsxs)(`div`,{className:`flex items-start justify-between gap-3`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`strong`,{id:`createProjectTitle`,className:`block text-base`,children:`New ERP project`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Creates a project and links a new or existing customer.`})]}),(0,L.jsx)(z,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close project creation`,onClick:e.onClose,children:(0,L.jsx)(ae,{})})]}),(0,L.jsxs)(`div`,{className:`grid gap-3`,children:[(0,L.jsxs)(H,{children:[`Project name *`,(0,L.jsx)(V,{value:t,autoComplete:`off`,maxLength:140,placeholder:`Acme Portal`,onChange:e=>n(e.target.value)})]}),(0,L.jsxs)(`div`,{className:`grid gap-2`,children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Customer`}),(0,L.jsx)(`div`,{className:`grid grid-cols-2 gap-2`,children:[`new`,`existing`].map(e=>(0,L.jsx)(z,{type:`button`,variant:r===e?`default`:`outline`,onClick:()=>i(e),children:e===`new`?`New customer`:`Existing customer`},e))})]}),r===`new`?(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,L.jsxs)(H,{className:`md:col-span-2`,children:[`Customer name *`,(0,L.jsx)(V,{value:a,autoComplete:`off`,maxLength:140,placeholder:`Acme`,onChange:e=>o(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Account manager`,(0,L.jsx)(V,{value:m,autoComplete:`off`,placeholder:`Search @508.dev user`,onChange:e=>{h(e.target.value),_(``)}})]}),m.trim().length>=2?(0,L.jsx)(`div`,{className:`grid max-h-40 gap-2 overflow-y-auto rounded-md border p-2 md:col-span-2`,children:v.length?v.map(e=>{let t=e.email||e.name||``;return(0,L.jsxs)(`label`,{className:`flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-secondary`,children:[(0,L.jsx)(`input`,{type:`radio`,name:`erpAccountManager`,value:t,checked:g===t,onChange:()=>{_(t),h(t)}}),(0,L.jsxs)(`span`,{className:`grid gap-0.5 text-sm`,children:[(0,L.jsx)(`strong`,{children:e.full_name||t}),(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:t})]})]},t)}):(0,L.jsx)(`span`,{className:`px-2 py-3 text-sm text-muted-foreground`,children:`No enabled @508.dev users found.`})}):null]}):(0,L.jsxs)(`div`,{className:`grid gap-3`,children:[(0,L.jsxs)(H,{children:[`Find customer *`,(0,L.jsx)(V,{value:s,autoComplete:`off`,placeholder:`Search customer`,onChange:e=>c(e.target.value)})]}),(0,L.jsx)(`div`,{className:`grid max-h-48 gap-2 overflow-y-auto rounded-md border p-2`,children:f.length?f.map(e=>{let t=e.name||e.customer_name||``;return(0,L.jsxs)(`label`,{className:`flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-secondary`,children:[(0,L.jsx)(`input`,{type:`radio`,name:`erpCustomer`,value:t,checked:u===t,onChange:()=>d(t)}),(0,L.jsxs)(`span`,{className:`grid gap-0.5 text-sm`,children:[(0,L.jsx)(`strong`,{children:e.customer_name||t}),(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:[t,e.default_currency].filter(Boolean).join(` | `)})]})]},t)}):(0,L.jsx)(`span`,{className:`px-2 py-3 text-sm text-muted-foreground`,children:`Search at least two characters.`})})]}),r===`new`?(0,L.jsxs)(L.Fragment,{children:[(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,L.jsxs)(H,{className:`md:col-span-2`,children:[`Customer details`,(0,L.jsx)(`textarea`,{value:ee,className:`min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`,maxLength:2e3,placeholder:`More information`,onChange:e=>S(e.target.value)})]}),(0,L.jsxs)(H,{className:`md:col-span-2`,children:[`Website`,(0,L.jsx)(V,{value:C,autoComplete:`url`,placeholder:`https://example.com`,onChange:e=>te(e.target.value)})]})]}),(0,L.jsxs)(`div`,{className:`grid gap-3`,children:[(0,L.jsxs)(`div`,{className:`flex items-center justify-between gap-3`,children:[(0,L.jsx)(`strong`,{className:`text-sm text-foreground`,children:`Contact`}),(0,L.jsx)(`div`,{className:`grid grid-cols-2 gap-2`,children:[`new`,`existing`].map(e=>(0,L.jsx)(z,{type:`button`,size:`sm`,variant:fe===e?`default`:`outline`,onClick:()=>pe(e),children:e===`new`?`New`:`Existing`},e))})]}),fe===`new`?(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,L.jsxs)(H,{children:[`First name `,Ke?`*`:``,(0,L.jsx)(V,{value:_e,autoComplete:`given-name`,onChange:e=>ve(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Last name`,(0,L.jsx)(V,{value:ye,autoComplete:`family-name`,onChange:e=>be(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Email`,(0,L.jsx)(V,{value:xe,type:`email`,autoComplete:`email`,onChange:e=>Se(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Phone`,(0,L.jsx)(V,{value:Ce,type:`tel`,autoComplete:`tel`,onChange:e=>we(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Mobile`,(0,L.jsx)(V,{value:Te,type:`tel`,autoComplete:`tel`,onChange:e=>Ee(e.target.value)})]})]}):(0,L.jsxs)(`div`,{className:`grid gap-3`,children:[(0,L.jsxs)(H,{children:[`Find contact`,(0,L.jsx)(V,{value:D,autoComplete:`off`,placeholder:`Search name or email`,onChange:e=>O(e.target.value)})]}),(0,L.jsx)(`div`,{className:`grid max-h-48 gap-2 overflow-y-auto rounded-md border p-2`,children:he.length?he.map(e=>{let t=e.name||``,n=e.full_name||t,r=[{key:`company`,value:e.company_name},{key:`email`,value:e.email_id},{key:`phone`,value:e.phone},{key:`mobile`,value:e.mobile_no}].filter(e=>!!e.value);return(0,L.jsxs)(`label`,{className:`flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-secondary`,children:[(0,L.jsx)(`input`,{type:`radio`,name:`erpContact`,value:t,checked:k===t,onChange:()=>me(t)}),(0,L.jsxs)(`span`,{className:`grid gap-0.5 text-sm`,children:[(0,L.jsx)(`strong`,{children:(0,L.jsx)(wn,{value:n,query:D})}),r.length?(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:r.map((e,t)=>(0,L.jsxs)(`span`,{children:[t>0?` | `:``,(0,L.jsx)(wn,{value:e.value,query:D})]},e.key))}):null]})]},t)}):(0,L.jsx)(`span`,{className:`px-2 py-3 text-sm text-muted-foreground`,children:`Search at least two characters.`})})]})]}),(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,L.jsx)(`strong`,{className:`text-sm text-foreground md:col-span-2`,children:`Address`}),(0,L.jsxs)(H,{className:`md:col-span-2`,children:[`Address line 1 `,Ge?`*`:``,(0,L.jsx)(V,{value:ne,autoComplete:`address-line1`,onChange:e=>re(e.target.value)})]}),(0,L.jsxs)(H,{className:`md:col-span-2`,children:[`Address line 2`,(0,L.jsx)(V,{value:ie,autoComplete:`address-line2`,onChange:e=>oe(e.target.value)})]}),(0,L.jsxs)(H,{children:[`City`,(0,L.jsx)(V,{value:se,autoComplete:`address-level2`,onChange:e=>w(e.target.value)})]}),(0,L.jsxs)(H,{children:[`State`,(0,L.jsx)(V,{value:ce,autoComplete:`address-level1`,onChange:e=>le(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Postal code`,(0,L.jsx)(V,{value:E,autoComplete:`postal-code`,onChange:e=>de(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Country`,(0,L.jsx)(V,{value:ue,autoComplete:`country-name`,onChange:e=>T(e.target.value)})]})]})]}):null,(0,L.jsxs)(`div`,{className:`grid gap-3 rounded-md border p-3`,children:[(0,L.jsx)(z,{type:`button`,variant:`outline`,onClick:()=>Oe(e=>!e),children:De?`Hide advanced`:`Show advanced`}),De?(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[r===`new`?(0,L.jsxs)(H,{children:[`Billing currency`,(0,L.jsx)(V,{value:b,autoComplete:`off`,maxLength:3,onChange:e=>x(e.target.value.toUpperCase())})]}):null,(0,L.jsxs)(H,{children:[`Cost center`,(0,L.jsx)(zt,{value:je,onChange:e=>Me(e.target.value),children:ke.map(e=>{let t=e.name||``;return(0,L.jsx)(`option`,{value:t,children:[t,e.company].filter(Boolean).join(` | `)},t)})})]}),(0,L.jsxs)(H,{children:[`Activity type`,(0,L.jsx)(V,{value:Fe?Ne:We,autoComplete:`off`,maxLength:140,placeholder:We||`Engineering for project`,onChange:e=>{Ie(!0),Pe(e.target.value)}})]})]}):null]})]}),(0,L.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[(0,L.jsx)(z,{type:`button`,variant:`outline`,onClick:e.onClose,children:`Cancel`}),(0,L.jsx)(z,{type:`button`,disabled:!qe,onClick:()=>void Je(),children:`Create project`})]})]})]})}function Vn(e){let t=e.project,n=t.roster_members||[],[r,i]=(0,l.useState)(``),[a,o]=(0,l.useState)([]),[s,c]=(0,l.useState)(``),[u,d]=(0,l.useState)(``),[f,p]=(0,l.useState)(``),[h,g]=(0,l.useState)(``),_=[t.actual_start_date||t.expected_start_date,t.actual_end_date||t.expected_end_date].filter(Boolean).map(e=>In(e)).join(` to `)||`Not set`,y=typeof t.percent_complete==`number`?`${Math.round(t.percent_complete)}%`:`Not set`,b=a.find(e=>e.candidate_id===s),x=r.trim().includes(`@`)?r.trim().length>=5:r.trim().length>=3,ee=!!(u.trim()||f.trim()||h.trim()),S=Rn(f),C=Rn(h),te=!!((f.trim()||h.trim())&&!u.trim()),re=!!(u.trim()&&(!f.trim()||!h.trim())),ae=!!(f.trim()&&S===void 0)||!!(h.trim()&&C===void 0),oe=te||re||ae||S!==void 0&&S<0||C!==void 0&&C<0,se=ee&&!oe?{activity_type:u.trim(),billing_rate:S,costing_rate:C}:void 0;(0,l.useEffect)(()=>{if(!e.canWrite)return;let t=r.trim();if(s&&b&&t===(b.email||b.label||``))return;if(s&&c(``),!(t.includes(`@`)?t.length>=5:t.length>=3)){o([]);return}let n=new AbortController,i=window.setTimeout(()=>{G(`/dashboard/api/project-member-candidates?query=${encodeURIComponent(t)}`,{signal:n.signal}).then(e=>o(e)).catch(e=>{e instanceof DOMException&&e.name===`AbortError`||o([])})},500);return()=>{n.abort(),window.clearTimeout(i)}},[r,e.canWrite,b,s]);function w(e){c(e.candidate_id),i(e.email||e.label||e.full_name||r)}return(0,L.jsxs)(L.Fragment,{children:[(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-start`,children:[(0,L.jsxs)(z,{type:`button`,variant:`outline`,onClick:e.onBack,children:[(0,L.jsx)(m,{}),`Projects`]}),(0,L.jsxs)(`div`,{className:`min-w-0`,children:[(0,L.jsx)(Lt,{children:t.display_name}),(0,L.jsxs)(`div`,{className:`mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground`,children:[(0,L.jsx)(R,{variant:Fn(t.source_status),children:t.source_status||`Unknown`}),t.erpnext_project_id?(0,L.jsx)(`span`,{className:`font-mono`,children:t.erpnext_project_id}):null,t.last_synced_at?(0,L.jsxs)(`span`,{children:[`Synced `,Gt(t.last_synced_at)]}):null]})]}),(0,L.jsxs)(`div`,{className:`flex flex-wrap justify-start gap-2 md:justify-end`,children:[e.canWrite?(0,L.jsxs)(zt,{className:`w-[160px]`,"aria-label":`Status for ${t.display_name}`,value:t.source_status||``,disabled:e.loading[`project:${t.id}:status`],onChange:n=>e.onUpdateStatus(t.id,n.target.value),children:[(0,L.jsx)(`option`,{value:`Open`,children:`Open`}),(0,L.jsx)(`option`,{value:`Completed`,children:`Completed`}),(0,L.jsx)(`option`,{value:`Cancelled`,children:`Cancelled`})]}):null,t.erpnext_project_url?(0,L.jsxs)(`a`,{className:`inline-flex min-h-9 items-center justify-center gap-2 rounded-md border bg-secondary px-3 text-sm font-semibold`,href:t.erpnext_project_url,target:`_blank`,rel:`noreferrer`,children:[(0,L.jsx)(v,{className:`size-4`}),`ERP project`]}):null,t.customer_erpnext_url?(0,L.jsxs)(`a`,{className:`inline-flex min-h-9 items-center justify-center gap-2 rounded-md border bg-secondary px-3 text-sm font-semibold`,href:t.customer_erpnext_url,target:`_blank`,rel:`noreferrer`,children:[(0,L.jsx)(v,{className:`size-4`}),`ERP customer`]}):null]})]})}),(0,L.jsxs)(Rt,{className:`grid gap-4 md:grid-cols-2 lg:grid-cols-4`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Customer`}),(0,L.jsx)(`strong`,{className:`block`,children:t.customer||`None`})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Timeline`}),(0,L.jsx)(`strong`,{className:`block`,children:_})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Progress`}),(0,L.jsx)(`strong`,{className:`block`,children:y})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Linked Gigs`}),(0,L.jsx)(`strong`,{className:`block`,children:t.linked_engagement_count||0})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`ERP Type`}),(0,L.jsx)(`div`,{className:`mt-1`,children:t.project_type?(0,L.jsx)(R,{variant:`neutral`,children:t.project_type}):(0,L.jsx)(`strong`,{className:`block`,children:`Not set`})})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`ERP Modified`}),(0,L.jsx)(`strong`,{className:`block`,children:Gt(t.source_modified_at)})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Cache ID`}),(0,L.jsx)(`strong`,{className:`block break-all font-mono text-xs`,children:t.id})]})]})]}),(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Project roster`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:n.length?`${n.length} synced ERP user${n.length===1?``:`s`}`:`No ERP roster`})]}),e.canWrite?(0,L.jsxs)(Rt,{className:`grid gap-3 border-b md:grid-cols-[minmax(260px,1fr)_minmax(180px,.7fr)_minmax(130px,.45fr)_minmax(130px,.45fr)_auto_auto] md:items-end`,children:[(0,L.jsxs)(`div`,{className:`relative`,children:[(0,L.jsxs)(H,{children:[`Person search`,(0,L.jsx)(V,{value:r,autoComplete:`off`,placeholder:`Search @508.dev person`,onChange:e=>i(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),a.length===1&&w(a[0]))}})]}),x&&!s?(0,L.jsx)(`div`,{className:`absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border bg-background shadow-lg`,children:a.length?a.map(e=>(0,L.jsxs)(`button`,{type:`button`,className:`grid w-full gap-0.5 px-3 py-2 text-left hover:bg-secondary focus:bg-secondary focus:outline-none`,onClick:()=>w(e),children:[(0,L.jsx)(`span`,{className:`truncate text-sm font-bold`,children:e.label||e.full_name||e.email||`Person`}),(0,L.jsx)(`span`,{className:`truncate text-xs text-muted-foreground`,children:[e.email,e.sources?.join(`, `)].filter(Boolean).join(` | `)})]},e.candidate_id)):(0,L.jsx)(`div`,{className:`px-3 py-2 text-sm text-muted-foreground`,children:`No verified @508.dev results`})}):null]}),(0,L.jsxs)(H,{children:[`Activity Type`,(0,L.jsx)(V,{value:u,autoComplete:`off`,placeholder:`Optional rate step`,onChange:e=>d(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Billing rate`,(0,L.jsx)(V,{value:f,inputMode:`decimal`,autoComplete:`off`,placeholder:`USD/hr`,onChange:e=>p(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Costing rate`,(0,L.jsx)(V,{value:h,inputMode:`decimal`,autoComplete:`off`,placeholder:`USD/hr`,onChange:e=>g(e.target.value)})]}),(0,L.jsxs)(z,{type:`button`,variant:`outline`,disabled:e.loading[`project:${t.id}:user`]||!s||!b?.email||oe,onClick:()=>void e.onAddUser(t.id,b?.email||r,s,se).then(e=>{e&&(i(``),o([]),c(``),d(``),p(``),g(``))}),children:[(0,L.jsx)(ie,{}),`Add ERP user`]}),(0,L.jsxs)(z,{type:`button`,variant:`outline`,disabled:e.loading[`project:${t.id}:historical`]||!r.trim(),onClick:()=>void e.onAddHistoricalMember(t.id,r).then(e=>{e&&i(``)}),children:[(0,L.jsx)(ie,{}),`Add historical`]})]}):null,(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{className:`min-w-[860px]`,"aria-label":`Project roster`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(U,{children:`Name`}),(0,L.jsx)(U,{children:`Email`}),(0,L.jsx)(U,{children:`ERP user`}),(0,L.jsx)(U,{children:`Links`}),(0,L.jsx)(U,{children:`Source`}),(0,L.jsx)(U,{children:`Last seen`}),e.canWrite?(0,L.jsx)(U,{children:`Actions`}):null]})}),(0,L.jsx)(Ht,{children:n.length?n.map(n=>{let r=Ln(n),i=n.source_user_id||n.email||``,a=n.roster_kind===`historical`||n.source===`manual`;return(0,L.jsxs)(Ut,{children:[(0,L.jsx)(W,{children:(0,L.jsx)(`strong`,{children:n.full_name||n.email||n.source_user_id})}),(0,L.jsx)(W,{children:n.email||`None`}),(0,L.jsx)(W,{className:`font-mono text-xs`,children:n.erpnext_user_url?(0,L.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:n.erpnext_user_url,target:`_blank`,rel:`noreferrer`,children:[n.source_user_id||`ERP user`,(0,L.jsx)(v,{className:`size-3.5`})]}):n.source_user_id||`Unknown`}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[n.supplier_erpnext_url?(0,L.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:n.supplier_erpnext_url,target:`_blank`,rel:`noreferrer`,children:[`Supplier`,(0,L.jsx)(v,{className:`size-3.5`})]}):null,n.crm_contact_id&&e.crmContactUrl(n.crm_contact_id)?(0,L.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:e.crmContactUrl(n.crm_contact_id),target:`_blank`,rel:`noreferrer`,children:[`CRM`,(0,L.jsx)(v,{className:`size-3.5`})]}):null,!n.supplier_erpnext_url&&!(n.crm_contact_id&&e.crmContactUrl(n.crm_contact_id))?(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:`None`}):null]})}),(0,L.jsx)(W,{children:n.roster_kind||n.source||`ERP`}),(0,L.jsx)(W,{children:Gt(n.last_seen_at)}),e.canWrite?(0,L.jsx)(W,{children:(0,L.jsxs)(z,{type:`button`,variant:`outline`,size:`sm`,disabled:!i||e.loading[`project:${t.id}:${a?`historical`:`user`}`],onClick:()=>{window.confirm(`Remove ${r} from this project roster?`)&&(a?e.onRemoveHistoricalMember(t.id,i):e.onRemoveUser(t.id,i))},children:[(0,L.jsx)(ne,{}),`Remove`]})}):null]},`${n.source||``}:${n.source_user_id||n.email}`)}):(0,L.jsx)(Ut,{children:(0,L.jsx)(W,{colSpan:e.canWrite?7:6,className:`text-sm text-muted-foreground`,children:`No roster rows have been synced for this project.`})})})]})})]})]})}function Hn(e){let t=e.gigs.reduce((t,n)=>(t.total+=1,t.applications+=Number(n.application_count||0),t.interested+=Number(n.interested_count||0),Pn(n,e.staleDays)!==null&&(t.stale+=1),t),{total:0,applications:0,interested:0,stale:0}),n=(0,L.jsxs)(It,{className:`grid gap-3 p-4 md:grid-cols-[minmax(160px,1fr)_auto_auto_auto] md:items-end`,children:[(0,L.jsxs)(H,{children:[`Status`,(0,L.jsxs)(zt,{id:`gigStatus`,value:e.status,onChange:t=>e.setStatus(t.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`Any status`}),An.map(e=>(0,L.jsx)(`option`,{value:e,children:Mn(e)},e))]})]}),e.canIncludeHistorical?(0,L.jsxs)(`label`,{className:`flex min-h-9 items-center gap-2 text-xs font-bold text-muted-foreground`,children:[(0,L.jsx)(`input`,{type:`checkbox`,checked:e.includeHistorical,onChange:t=>e.setIncludeHistorical(t.target.checked)}),`Include historical`]}):null,(0,L.jsxs)(z,{id:`refreshGigs`,type:`button`,onClick:e.onRefresh,disabled:e.loading.gigs,children:[(0,L.jsx)(S,{}),`Refresh gigs`]}),e.gigs.length>=e.limit?(0,L.jsx)(z,{type:`button`,variant:`outline`,onClick:()=>e.setLimit(Math.min(e.limit+100,500)),disabled:e.loading.gigs||e.limit>=500,children:`Load more`}):null]}),r=e.selectedGigId?e.loading[`gig:${e.selectedGigId}:detail`]:!1;return e.selectedGigId&&!e.selectedGig&&(e.loading.gigs||r)?(0,L.jsxs)(L.Fragment,{children:[n,(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsx)(Lt,{children:`Gig detail`})}),(0,L.jsx)(Rt,{className:`text-sm text-muted-foreground`,children:`Loading gig.`})]})]}):e.selectedGigId&&!e.selectedGig?(0,L.jsxs)(L.Fragment,{children:[n,(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsx)(Lt,{children:`Gig detail`})}),(0,L.jsxs)(Rt,{className:`grid gap-3`,children:[(0,L.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`This gig is not in the current result set. Clear filters or refresh the gig list.`}),(0,L.jsxs)(z,{type:`button`,variant:`outline`,onClick:e.onCloseGig,children:[(0,L.jsx)(m,{}),`Back to gigs`]})]})]})]}):e.selectedGig?(0,L.jsxs)(L.Fragment,{children:[n,(0,L.jsx)(Gn,{gig:e.selectedGig,loading:e.loading,canWrite:e.canWrite,crmContactUrl:e.crmContactUrl,crmAttachmentUrl:e.crmAttachmentUrl,staleDays:e.staleDays,onBack:e.onCloseGig,onUpdateStatus:e.onUpdateStatus,onAddApplication:e.onAddApplication,onUpdateApplicationStatus:e.onUpdateApplicationStatus})]}):(0,L.jsxs)(L.Fragment,{children:[n,(0,L.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-4`,"aria-label":`Gig summary`,children:[(0,L.jsx)(Sn,{id:`gigMetricTotal`,label:`Gigs`,value:t.total}),(0,L.jsx)(Sn,{id:`gigMetricCandidates`,label:`Candidates`,value:t.applications}),(0,L.jsx)(Sn,{id:`gigMetricInterested`,label:`Interested`,value:t.interested}),(0,L.jsx)(Sn,{id:`gigMetricStale`,label:`Stale recruiting`,value:t.stale})]}),(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Discord gigs`}),(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center justify-end gap-2`,children:[(0,L.jsxs)(z,{type:`button`,variant:`ghost`,size:`sm`,onClick:()=>e.onSort(`activity`),"aria-label":`Sort gigs by activity`,children:[`Activity`,` `,e.sort.key===`activity`?e.sort.direction===`asc`?`↑`:`↓`:``]}),(0,L.jsxs)(z,{type:`button`,variant:`ghost`,size:`sm`,onClick:()=>e.onSort(`title`),"aria-label":`Sort gigs by title`,children:[`Title `,e.sort.key===`title`?e.sort.direction===`asc`?`↑`:`↓`:``]}),(0,L.jsx)(`span`,{id:`gigsStatus`,className:`text-sm text-muted-foreground`,children:e.loading.gigs?`Loading`:`${e.gigs.length} shown`})]})]}),(0,L.jsx)(Cn,{hidden:e.gigs.length!==0,children:`No gigs match this view.`}),(0,L.jsx)(`div`,{id:`gigsBody`,className:I(`grid gap-3 p-4`,e.gigs.length===0&&`hidden`),children:e.gigs.map(t=>(0,L.jsx)(Un,{gig:t,loading:e.loading,canWrite:e.canWrite,staleDays:e.staleDays,onOpenGig:e.onOpenGig,onUpdateStatus:e.onUpdateStatus},t.id))})]})]})}function Un({gig:e,loading:t,canWrite:n,onOpenGig:r,onUpdateStatus:i,staleDays:a}){let o=Array.isArray(e.applications)?e.applications:[],s=e.status===`recruiting`,c=e.discord_guild_id&&e.discord_thread_id?`https://discord.com/channels/${encodeURIComponent(e.discord_guild_id)}/${encodeURIComponent(e.discord_thread_id)}`:``,l=Pn(e,a);return(0,L.jsxs)(`article`,{className:I(`grid gap-4 rounded-md border bg-background p-4 lg:grid-cols-[minmax(0,1fr)_220px_180px] lg:items-start`,!s&&`border-l-4 border-l-muted-foreground/60 bg-secondary/45`),children:[(0,L.jsxs)(`div`,{className:`min-w-0`,children:[(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[(0,L.jsx)(`a`,{className:`text-base font-extrabold text-primary`,href:`/dashboard/gigs/${encodeURIComponent(e.id)}`,onClick:t=>{t.preventDefault(),r(e.id)},children:e.title||`Untitled gig`}),(0,L.jsx)(R,{variant:e.status===`filled`?`succeeded`:e.status===`lost`?`failed`:s?`queued`:`neutral`,children:e.status_label||Mn(e.status)}),s?null:(0,L.jsx)(R,{variant:`neutral`,children:`Not recruiting`}),l===null?null:(0,L.jsxs)(R,{variant:`running`,children:[l,`d stale`]})]}),(0,L.jsxs)(`div`,{className:`mt-2 flex flex-wrap gap-1.5`,children:[e.posting_type?(0,L.jsx)(R,{variant:`neutral`,children:Mn(e.posting_type)}):null,e.discord_channel_name?(0,L.jsxs)(R,{variant:`neutral`,children:[`#`,e.discord_channel_name]}):null,(e.required_skills||[]).slice(0,5).map(e=>(0,L.jsx)(R,{variant:`queued`,children:e},e)),(e.preferred_skills||[]).slice(0,3).map(e=>(0,L.jsx)(R,{variant:`neutral`,children:e},e))]}),(0,L.jsxs)(`div`,{className:`mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground`,children:[(0,L.jsxs)(`span`,{children:[`Activity `,Gt(Nn(e))||`unknown`]}),(0,L.jsxs)(`span`,{children:[`Posted `,Gt(e.posted_at)||`unknown`]}),c?(0,L.jsx)(`a`,{className:`font-extrabold text-primary`,href:c,target:`_blank`,rel:`noreferrer`,children:`Open Discord thread`}):null]})]}),(0,L.jsxs)(`div`,{className:`grid grid-cols-2 gap-2 text-sm lg:grid-cols-1`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`block text-xs font-bold text-muted-foreground`,children:`People`}),(0,L.jsx)(`strong`,{children:e.application_count||o.length}),(0,L.jsxs)(`span`,{className:`ml-2 text-muted-foreground`,children:[Number(e.interested_count||0),` interested`]})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`block text-xs font-bold text-muted-foreground`,children:`Top candidates`}),(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:o.slice(0,3).map(e=>Wn(e)).join(`, `)||`None yet`})]})]}),(0,L.jsxs)(`div`,{className:`grid gap-2`,children:[n?(0,L.jsx)(zt,{"aria-label":`Status for ${e.title||`gig`}`,value:e.status,disabled:t[`gig:${e.id}:status`],onChange:t=>i(e.id,t.target.value),children:An.map(e=>(0,L.jsx)(`option`,{value:e,children:Mn(e)},e))}):null,(0,L.jsx)(z,{type:`button`,onClick:()=>r(e.id),children:`Manage people`})]})]})}function Wn(e){return e.name||e.email_508||e.discord_username||(typeof e.evaluation?.discord_username==`string`?e.evaluation.discord_username:``)||`Candidate`}function Gn({gig:e,loading:t,canWrite:n,crmContactUrl:r,crmAttachmentUrl:i,staleDays:a,onBack:o,onUpdateStatus:s,onAddApplication:c,onUpdateApplicationStatus:u}){let[d,f]=(0,l.useState)(``),p=Array.isArray(e.applications)?e.applications:[],h=e.status===`recruiting`,g=e.discord_guild_id&&e.discord_thread_id?`https://discord.com/channels/${encodeURIComponent(e.discord_guild_id)}/${encodeURIComponent(e.discord_thread_id)}`:``,_=Pn(e,a);return(0,L.jsxs)(`div`,{className:`grid gap-5`,children:[(0,L.jsxs)(It,{className:I(!h&&`border-l-4 border-l-muted-foreground/60 bg-secondary/35`),children:[(0,L.jsxs)(B,{className:`items-start`,children:[(0,L.jsxs)(`div`,{className:`grid gap-2`,children:[(0,L.jsxs)(z,{type:`button`,variant:`ghost`,size:`sm`,className:`w-fit`,onClick:o,children:[(0,L.jsx)(m,{}),`Back to gigs`]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(Lt,{className:`text-xl`,children:e.title||`Untitled gig`}),(0,L.jsxs)(`div`,{className:`mt-2 flex flex-wrap gap-1.5`,children:[(0,L.jsx)(R,{variant:e.status===`filled`?`succeeded`:e.status===`lost`?`failed`:h?`queued`:`neutral`,children:e.status_label||Mn(e.status)}),h?null:(0,L.jsx)(R,{variant:`neutral`,children:`Not recruiting`}),_===null?null:(0,L.jsxs)(R,{variant:`running`,children:[_,`d stale`]}),e.posting_type?(0,L.jsx)(R,{variant:`neutral`,children:Mn(e.posting_type)}):null,e.discord_channel_name?(0,L.jsxs)(R,{variant:`neutral`,children:[`#`,e.discord_channel_name]}):null,(e.required_skills||[]).map(e=>(0,L.jsx)(R,{variant:`queued`,children:e},e)),(e.preferred_skills||[]).map(e=>(0,L.jsx)(R,{variant:`neutral`,children:e},e))]})]})]}),(0,L.jsxs)(`div`,{className:`grid min-w-[190px] gap-2`,children:[n?(0,L.jsxs)(H,{children:[`Gig status`,(0,L.jsx)(zt,{"aria-label":`Status for ${e.title||`gig`}`,value:e.status,disabled:t[`gig:${e.id}:status`],onChange:t=>s(e.id,t.target.value),children:An.map(e=>(0,L.jsx)(`option`,{value:e,children:Mn(e)},e))})]}):null,g?(0,L.jsxs)(`a`,{className:`inline-flex min-h-9 items-center justify-center gap-2 rounded-md border bg-secondary px-3 text-sm font-semibold`,href:g,target:`_blank`,rel:`noreferrer`,children:[(0,L.jsx)(v,{className:`size-4`}),`Discord thread`]}):null]})]}),(0,L.jsxs)(Rt,{className:`grid gap-4 lg:grid-cols-[1fr_1fr_1fr]`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Activity`}),(0,L.jsx)(`strong`,{className:`block`,children:Gt(Nn(e))||`unknown`}),(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[`Posted `,Gt(e.posted_at)||`unknown`]})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`People`}),(0,L.jsx)(`strong`,{className:`block`,children:e.application_count||p.length}),(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[Number(e.interested_count||0),` interested`]})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Discord`}),(0,L.jsx)(`strong`,{className:`block`,children:e.discord_channel_name||`No channel`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.discord_thread_id?`Thread ${e.discord_thread_id}`:`No thread`})]})]})]}),(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`People`}),(0,L.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[p.length,` candidate`,p.length===1?``:`s`]})]}),n?(0,L.jsxs)(`form`,{className:`grid gap-2 border-t p-4 md:grid-cols-[minmax(220px,1fr)_auto]`,onSubmit:t=>{t.preventDefault(),c(e.id,d).then(e=>{e&&f(``)})},children:[(0,L.jsxs)(H,{className:`min-w-0`,children:[`CRM profile`,(0,L.jsx)(V,{value:d,onChange:e=>f(e.target.value),placeholder:`https://crm.508.dev/#Contact/view/...`,"aria-label":`CRM profile for candidate`})]}),(0,L.jsxs)(z,{type:`submit`,className:`self-end`,disabled:t[`gig:${e.id}:addCandidate`]||!d.trim(),children:[(0,L.jsx)(re,{}),`Add candidate`]})]}):null,(0,L.jsx)(Cn,{hidden:p.length!==0,children:`No suggested or interested people yet.`}),(0,L.jsx)(`div`,{className:I(`grid gap-3 p-4`,p.length===0&&`hidden`),children:p.map(a=>(0,L.jsx)(Kn,{gigId:e.id,application:a,loading:t,canWrite:n,crmContactUrl:r,crmAttachmentUrl:i,onUpdateApplicationStatus:u},a.id))})]})]})}function Kn({gigId:e,application:t,loading:n,canWrite:r,crmContactUrl:i,crmAttachmentUrl:a,onUpdateApplicationStatus:o}){let s=Wn(t),c=i(t.crm_contact_id),l=a(t.latest_resume_id),u=typeof t.fit_score==`number`?`${Math.round(t.fit_score)}/100`:typeof t.match_score==`number`?t.match_score.toFixed(1):``,d=typeof t.evaluation?.llm_summary==`string`?t.evaluation.llm_summary:``;return(0,L.jsxs)(`div`,{className:`grid gap-2 rounded-md border bg-background p-2`,children:[(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[c?(0,L.jsx)(`a`,{className:`font-extrabold text-primary`,href:c,target:`_blank`,rel:`noopener noreferrer`,children:s}):(0,L.jsx)(`strong`,{children:s}),(0,L.jsx)(R,{variant:t.status===`interested`?`succeeded`:`neutral`,children:Mn(t.status)}),(0,L.jsx)(R,{variant:`neutral`,children:Mn(t.source||`manual_add`)}),u?(0,L.jsxs)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:[`Fit `,u]}):null,c?(0,L.jsx)(`a`,{className:`text-xs font-extrabold text-primary`,href:c,target:`_blank`,rel:`noopener noreferrer`,"aria-label":`Open ${s} CRM profile`,children:`CRM profile`}):null,l?(0,L.jsx)(`a`,{className:`text-xs font-extrabold text-primary`,href:l,target:`_blank`,rel:`noopener noreferrer`,children:`Resume`}):null]}),d?(0,L.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:d}):null,r?(0,L.jsx)(zt,{"aria-label":`Candidate status for ${s}`,value:t.status||`suggested`,disabled:n[`application:${t.id}:status`],onChange:n=>o(e,t.id,n.target.value),children:jn.map(e=>(0,L.jsx)(`option`,{value:e,children:Mn(e)},e))}):null]})}function qn(e){let t=sn[e.peopleFilterKind]?.options||[];return(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`People lookup`}),(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center justify-end gap-2`,children:[e.canSync?(0,L.jsxs)(z,{id:`syncPeople`,"data-permission":`people:sync`,type:`button`,onClick:e.onSync,disabled:e.loading.syncPeople,children:[(0,L.jsx)(S,{}),`Sync people`]}):null,e.canSyncNewsletters?(0,L.jsxs)(z,{id:`syncNewsletters`,"data-permission":`people:sync`,type:`button`,variant:`secondary`,onClick:e.onSyncNewsletters,disabled:e.loading.syncNewsletters,children:[(0,L.jsx)(S,{}),`Sync newsletters`]}):null,e.crmBaseUrl?(0,L.jsx)(`a`,{id:`crmHomeLink`,className:`text-sm font-extrabold text-primary`,href:e.crmBaseUrl,target:`_blank`,rel:`noreferrer`,children:`Open CRM`}):null,(0,L.jsx)(`span`,{id:`peopleStatus`,className:`text-sm text-muted-foreground`,children:e.loading.people?`Loading`:`${e.people.length} shown`})]})]}),(0,L.jsxs)(`div`,{className:`grid gap-3 border-b p-4 md:grid-cols-[minmax(0,1fr)_auto]`,children:[(0,L.jsxs)(H,{children:[`Search CRM people cache`,(0,L.jsx)(V,{id:`peopleQuery`,value:e.peopleQuery,autoComplete:`off`,placeholder:`Name, email, CRM id, Discord, resume`,onChange:t=>e.setPeopleQuery(t.target.value),onKeyDown:t=>{t.key===`Enter`&&e.onSearch()}})]}),(0,L.jsxs)(z,{id:`searchPeople`,type:`button`,onClick:e.onSearch,disabled:e.loading.people,children:[(0,L.jsx)(C,{}),`Search`]})]}),(0,L.jsxs)(`div`,{className:`grid gap-3 border-b bg-background p-4 md:grid-cols-[minmax(120px,.7fr)_minmax(150px,1fr)_minmax(150px,1fr)_auto]`,children:[(0,L.jsxs)(H,{children:[`Member`,(0,L.jsxs)(zt,{id:`peopleMember`,value:e.peopleMember,onChange:t=>e.setPeopleMember(t.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`Any`}),(0,L.jsx)(`option`,{value:`true`,children:`Member`}),(0,L.jsx)(`option`,{value:`false`,children:`Not member`})]})]}),(0,L.jsxs)(H,{children:[`Add filter`,(0,L.jsx)(zt,{id:`peopleFilterKind`,value:e.peopleFilterKind,disabled:e.peopleFilterKeys.length===0,onChange:t=>e.setPeopleFilterKind(t.target.value),children:e.peopleFilterKeys.map(e=>(0,L.jsx)(`option`,{value:e,children:sn[e].label},e))})]}),(0,L.jsxs)(H,{children:[`Value`,(0,L.jsx)(zt,{id:`peopleFilterValue`,value:e.peopleFilterValue,onChange:t=>e.setPeopleFilterValue(t.target.value),children:t.map(([e,t])=>(0,L.jsx)(`option`,{value:e,children:t},e))})]}),(0,L.jsx)(z,{id:`addPeopleFilter`,type:`button`,onClick:e.addFilter,disabled:e.peopleFilterKeys.length===0,children:`Add filter`}),(0,L.jsx)(`div`,{id:`activePeopleFilters`,className:`md:col-span-4`,children:(0,L.jsx)(kn,{filters:e.peopleFilters,onRemove:e.removeFilter})})]}),(0,L.jsx)(Cn,{hidden:e.people.length!==0,children:`No people match this lookup.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`peopleTable`,className:I(`min-w-[900px]`,e.people.length===0&&`hidden`),"aria-label":`People lookup results`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(xn,{className:`w-[27%]`,label:`Name`,scope:`people`,sort:e.sort,sortKey:`name`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[28%]`,label:`Status`,scope:`people`,sort:e.sort,sortKey:`status`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[20%]`,label:`Discord`,scope:`people`,sort:e.sort,sortKey:`discord`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[25%]`,label:`Resume / skills`,scope:`people`,sort:e.sort,sortKey:`resume`,onSort:(t,n)=>e.onSort(n)})]})}),(0,L.jsx)(Ht,{id:`peopleBody`,children:e.people.map(t=>{let n=t.name||t.email_508||t.email||`CRM contact`,r=e.crmContactUrl(t.crm_contact_id),i=t.profile_status||{},a=Number(i.skills_count||0),o=e.crmAttachmentUrl(t.latest_resume_id);return(0,L.jsxs)(Ut,{children:[(0,L.jsxs)(W,{children:[r?(0,L.jsx)(`a`,{className:`font-extrabold text-primary`,href:r,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${n} in CRM`,children:n}):(0,L.jsx)(`strong`,{children:n}),(0,L.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:[t.email_508||t.email,t.contact_type].filter(Boolean).join(` | `)})]}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[i.crm_active?null:(0,L.jsx)(R,{variant:`missing`,children:t.sync_status||`CRM sync issue`}),(0,L.jsx)(R,{variant:i.is_member?`succeeded`:`missing`,children:i.is_member?`Member`:`Missing Member`}),(0,L.jsx)(R,{variant:i.discord_linked?`succeeded`:`missing`,children:i.discord_linked?`Discord`:`Missing Discord`}),(0,L.jsx)(R,{variant:i.email_508?`succeeded`:`missing`,children:i.email_508?`508 email`:`Missing 508 email`}),i.latest_resume?null:(0,L.jsx)(R,{variant:`missing`,children:`Missing Resume`})]})}),(0,L.jsx)(W,{children:[t.discord_username,t.discord_user_id].filter(Boolean).join(` | `)||`Not linked`}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5`,children:[o?(0,L.jsx)(`a`,{className:`inline-flex min-h-7 items-center rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:o,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${n} resume`,children:`Resume`}):(0,L.jsx)(`span`,{children:t.latest_resume_name||t.latest_resume_id||`No resume`}),(0,L.jsx)(R,{variant:a>0?`succeeded`:`missing`,children:a>0?`Skills parsed`:`Skills not parsed`})]})})]},t.crm_contact_id||n)})})]})})]})}function Jn(e){let t=sn[e.onboardingFilterKind]?.options||[];return(0,L.jsxs)(L.Fragment,{children:[e.canWrite?(0,L.jsx)(er,{loading:e.loading.engineerSetup,onSetup:e.onSetupEngineer}):null,(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Onboarding queue`}),(0,L.jsx)(`span`,{id:`onboardingStatus`,className:`text-sm text-muted-foreground`,children:e.loading.onboarding?`Loading`:`${e.people.length} shown`})]}),(0,L.jsxs)(`div`,{className:`grid gap-3 border-b p-4 md:grid-cols-[minmax(0,1fr)_auto]`,children:[(0,L.jsxs)(H,{children:[`Search prospects`,(0,L.jsx)(V,{id:`onboardingQuery`,value:e.onboardingQuery,autoComplete:`off`,placeholder:`Name, email, Discord, onboarder`,onChange:t=>e.setOnboardingQuery(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,L.jsxs)(z,{id:`searchOnboarding`,type:`button`,onClick:e.onSearch,disabled:e.loading.onboarding,children:[(0,L.jsx)(C,{}),`Search`]})]}),(0,L.jsxs)(`div`,{className:`grid gap-3 border-b bg-background p-4 md:grid-cols-[minmax(140px,.8fr)_minmax(150px,1fr)_minmax(150px,1fr)_minmax(120px,.7fr)_auto]`,children:[(0,L.jsxs)(H,{children:[`Status`,(0,L.jsxs)(zt,{id:`onboardingState`,value:e.onboardingState,onChange:t=>e.setOnboardingState(t.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`Any state`}),ln.map(([e,t])=>(0,L.jsx)(`option`,{value:e,children:t},e))]})]}),(0,L.jsxs)(H,{children:[`Onboarder`,(0,L.jsx)(V,{id:`onboarderFilter`,value:e.onboarderFilter,autoComplete:`off`,placeholder:`Any onboarder`,onChange:t=>e.setOnboarderFilter(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,L.jsxs)(H,{children:[`Add filter`,(0,L.jsx)(zt,{id:`onboardingFilterKind`,value:e.onboardingFilterKind,disabled:e.onboardingFilterKeys.length===0,onChange:t=>e.setOnboardingFilterKind(t.target.value),children:e.onboardingFilterKeys.map(e=>(0,L.jsx)(`option`,{value:e,children:sn[e].label},e))})]}),(0,L.jsxs)(H,{children:[`Value`,(0,L.jsx)(zt,{id:`onboardingFilterValue`,value:e.onboardingFilterValue,onChange:t=>e.setOnboardingFilterValue(t.target.value),children:t.map(([e,t])=>(0,L.jsx)(`option`,{value:e,children:t},e))})]}),(0,L.jsx)(z,{id:`addOnboardingFilter`,type:`button`,onClick:e.addFilter,disabled:e.onboardingFilterKeys.length===0,children:`Add filter`}),(0,L.jsx)(`div`,{id:`activeOnboardingFilters`,className:`md:col-span-5`,children:(0,L.jsx)(kn,{filters:e.onboardingFilters,onRemove:e.removeFilter,suffix:`onboarding filter`})})]}),(0,L.jsx)(Cn,{hidden:e.people.length!==0,children:`No prospects match this queue view.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`onboardingTable`,className:I(`min-w-[1180px]`,e.people.length===0&&`hidden`),"aria-label":`Onboarding queue`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(xn,{className:`w-[20%]`,label:`Name`,scope:`onboarding`,sort:e.sort,sortKey:`name`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[13%]`,label:`Status`,scope:`onboarding`,sort:e.sort,sortKey:`onboarding_state`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[22%]`,label:`Onboarder`,scope:`onboarding`,sort:e.sort,sortKey:`onboarder`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[13%]`,label:`Updated`,scope:`onboarding`,sort:e.sort,sortKey:`updated`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(U,{className:`w-[15%]`,children:`Links`}),(0,L.jsx)(xn,{className:`w-[17%]`,label:`Needs`,scope:`onboarding`,sort:e.sort,sortKey:`profile_gaps`,onSort:(t,n)=>e.onSort(n)})]})}),(0,L.jsx)(Ht,{id:`onboardingBody`,children:e.people.map(t=>(0,L.jsx)(tr,{person:t,loading:e.loading,canWrite:e.canWrite,onAssign:e.onAssign,onStatusChange:e.onStatusChange,crmContactUrl:e.crmContactUrl,crmAttachmentUrl:e.crmAttachmentUrl},t.crm_contact_id||t.name))})]})})]})]})}var Yn=[`Female`,`Genderqueer`,`Male`,`Non-Conforming`,`Other`,`Prefer not to say`,`Transgender`],Xn=[`Company Email`,`Personal Email`,`User ID`];function Zn(e){let t=(e||``).trim().split(/\s+/).filter(Boolean);return t.length===0?{first:``,middle:``,last:``}:t.length===1?{first:t[0],middle:``,last:``}:t.length===2?{first:t[0],middle:``,last:t[1]}:{first:t[0],middle:t.slice(1,-1).join(` `),last:t[t.length-1]}}function Qn(e){let t=(e.email||``).trim();return!t||t.toLowerCase().endsWith(`@508.dev`)?``:t}function $n(e){let t=(e.email_508||``).trim();if(t)return t;let n=(e.email||``).trim();return n.toLowerCase().endsWith(`@508.dev`)?n:``}function er({loading:e,onSetup:t}){let[n,r]=(0,l.useState)(``),[i,a]=(0,l.useState)([]),[o,s]=(0,l.useState)(!1),[c,u]=(0,l.useState)(``),[d,f]=(0,l.useState)(``),[p,m]=(0,l.useState)(``),[h,g]=(0,l.useState)(``),[_,v]=(0,l.useState)(``),[y,b]=(0,l.useState)(``),[x,ee]=(0,l.useState)(``),[S,te]=(0,l.useState)(``),[ne,ie]=(0,l.useState)(``),[ae,oe]=(0,l.useState)(``),[se,w]=(0,l.useState)(``);function ce(e){let t=Zn(e.name);m(t.first),g(t.middle),v(t.last),f($n(e)),oe(Qn(e)),b(e.address_country||``),r(e.name||e.email_508||e.email||``),a([]),u(``)}async function le(){let e=n.trim();if(e){s(!0),u(``);try{a(await G(`/dashboard/api/people?${new URLSearchParams({limit:`8`,query:e}).toString()}`))}catch(e){u(mn(e,`Unable to search people`)),a([])}finally{s(!1)}}}async function ue(){let e={email:d,first_name:p,middle_name:h,last_name:_,country:y,personal_email:ae};x.trim()&&(e.gender=x),S.trim()&&(e.date_of_birth=S),ne.trim()&&(e.date_of_joining=ne),se.trim()&&(e.prefered_email=se),await t(e)&&(r(``),a([]),f(``),m(``),g(``),v(``),b(``),ee(``),te(``),ie(``),oe(``),w(``))}return(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsx)(Lt,{children:`Engineer setup`})}),(0,L.jsx)(Rt,{children:(0,L.jsxs)(`form`,{className:`grid gap-3`,onSubmit:e=>{e.preventDefault(),ue()},children:[(0,L.jsxs)(`div`,{className:`grid gap-3 border-b pb-3 md:grid-cols-[minmax(0,1fr)_auto]`,children:[(0,L.jsxs)(H,{children:[`CRM person`,(0,L.jsx)(V,{value:n,autoComplete:`off`,placeholder:`Search name or email`,onChange:e=>r(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),le())}})]}),(0,L.jsxs)(z,{type:`button`,onClick:le,disabled:o||!n.trim(),children:[(0,L.jsx)(C,{}),`Search`]}),c?(0,L.jsx)(`span`,{className:`text-sm font-semibold text-destructive`,children:c}):null,i.length>0?(0,L.jsx)(`div`,{className:`grid gap-2 md:col-span-2`,children:i.map(e=>{let t=e.name||e.email_508||e.email||e.crm_contact_id,n=[e.email_508||e.email,e.contact_type].filter(Boolean).join(` | `);return(0,L.jsxs)(`button`,{type:`button`,className:`grid rounded-md border bg-background px-3 py-2 text-left text-sm hover:border-primary`,onClick:()=>ce(e),children:[(0,L.jsx)(`strong`,{children:t}),n?(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:n}):null]},e.crm_contact_id||t)})}):null]}),(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(130px,.6fr)]`,children:[(0,L.jsxs)(H,{children:[`Company email`,(0,L.jsx)(V,{value:d,autoComplete:`off`,placeholder:`engineer@508.dev`,onChange:e=>f(e.target.value)})]}),(0,L.jsxs)(H,{children:[`First name`,(0,L.jsx)(V,{value:p,autoComplete:`off`,placeholder:`First`,onChange:e=>m(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Middle name`,(0,L.jsx)(V,{value:h,autoComplete:`off`,placeholder:`Optional`,onChange:e=>g(e.target.value)})]})]}),(0,L.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(130px,.6fr)]`,children:[(0,L.jsxs)(H,{children:[`Last name`,(0,L.jsx)(V,{value:_,autoComplete:`off`,placeholder:`Last`,onChange:e=>v(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Country`,(0,L.jsx)(V,{value:y,autoComplete:`off`,placeholder:`Taiwan`,onChange:e=>b(e.target.value)})]})]}),(0,L.jsxs)(`details`,{className:`rounded-md border bg-background p-3`,children:[(0,L.jsx)(`summary`,{className:`cursor-pointer text-sm font-extrabold`,children:`Advanced options`}),(0,L.jsxs)(`div`,{className:`mt-3 grid gap-3 md:grid-cols-2`,children:[(0,L.jsxs)(H,{children:[`Gender`,(0,L.jsxs)(zt,{value:x,onChange:e=>ee(e.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`Default`}),Yn.map(e=>(0,L.jsx)(`option`,{value:e,children:e},e))]})]}),(0,L.jsxs)(H,{children:[`Date of birth`,(0,L.jsx)(V,{value:S,type:`date`,autoComplete:`off`,onChange:e=>te(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Date of joining`,(0,L.jsx)(V,{value:ne,type:`date`,autoComplete:`off`,onChange:e=>ie(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Personal email`,(0,L.jsx)(V,{value:ae,type:`email`,autoComplete:`off`,placeholder:`Optional`,onChange:e=>oe(e.target.value)})]}),(0,L.jsxs)(H,{children:[`Preferred contact email`,(0,L.jsxs)(zt,{value:se,onChange:e=>w(e.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`Default`}),Xn.map(e=>(0,L.jsx)(`option`,{value:e,children:e},e))]})]})]})]}),(0,L.jsx)(`div`,{className:`flex flex-wrap items-center justify-between gap-3`,children:(0,L.jsxs)(z,{id:`setupEngineer`,type:`submit`,disabled:e||!d.trim()||!p.trim(),children:[(0,L.jsx)(re,{}),`Set up engineer`]})})]})})]})}function tr({person:e,loading:t,canWrite:n,onAssign:r,onStatusChange:i,crmContactUrl:a,crmAttachmentUrl:o}){let s=e.name||e.email_508||e.email||`CRM contact`,[c,u]=(0,l.useState)(Zt(e.onboarder));(0,l.useEffect)(()=>u(Zt(e.onboarder)),[e.onboarder]);let d=dn(Jt(e)),f=e.profile_status||{},p=[[`Discord`,f.discord_linked],[`Resume`,f.latest_resume],[`Skills`,Number(f.skills_count||0)>0]].filter(([,e])=>!e),m=a(e.crm_contact_id),h=o(e.latest_resume_id);return(0,L.jsxs)(Ut,{children:[(0,L.jsxs)(W,{children:[m?(0,L.jsx)(`a`,{className:`font-extrabold text-primary`,href:m,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${s} in CRM`,children:s}):(0,L.jsx)(`strong`,{children:s}),(0,L.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:e.email_508||e.email||``})]}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`grid max-w-56 gap-2`,children:[(0,L.jsx)(R,{variant:Xt(Jt(e)),children:e.onboarding_status_label||Yt(Jt(e))}),n?(0,L.jsxs)(zt,{"aria-label":`Onboarding status for ${s}`,value:d,disabled:t[`onboarding-status:${e.crm_contact_id}`],onChange:t=>i(e.crm_contact_id,t.target.value),children:[d?null:(0,L.jsx)(`option`,{value:``,disabled:!0,children:`No status`}),cn.map(([e,t])=>(0,L.jsx)(`option`,{value:e,children:t},e))]}):null]})}),(0,L.jsx)(W,{children:(0,L.jsxs)(`form`,{className:`grid max-w-64 grid-cols-[minmax(100px,1fr)_auto] items-center gap-2`,onSubmit:t=>{t.preventDefault(),r(e.crm_contact_id,c)},children:[(0,L.jsx)(V,{"aria-label":`Onboarder for ${s}`,value:c,placeholder:`508 username`,onChange:e=>u(e.target.value)}),(0,L.jsx)(z,{type:`submit`,size:`sm`,"aria-label":`Save onboarder for ${s}`,disabled:t[`onboarder:${e.crm_contact_id}`],children:`Save`})]})}),(0,L.jsx)(W,{children:Gt(e.onboarding_updated_at)}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[h?(0,L.jsx)(`a`,{className:`inline-flex min-h-7 items-center rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:h,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${s} resume`,children:`Resume`}):null,nn(e.linkedin)?(0,L.jsx)(`a`,{className:`inline-flex min-h-7 items-center rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:nn(e.linkedin),target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${s} LinkedIn`,children:`LinkedIn`}):null,rn(e.github_username)?(0,L.jsx)(`a`,{className:`inline-flex min-h-7 items-center rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:rn(e.github_username),target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${s} GitHub`,children:e.github_username||`GitHub`}):null,!h&&!nn(e.linkedin)&&!rn(e.github_username)?`None`:null]})}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[p.map(([e])=>(0,L.jsxs)(R,{variant:`missing`,children:[`Missing `,e]},String(e))),p.length===0?`None`:null]})})]})}function nr(e){return(0,L.jsxs)(L.Fragment,{children:[(0,L.jsxs)(It,{className:`grid gap-3 p-4 md:grid-cols-4 md:items-end`,children:[(0,L.jsxs)(H,{children:[`Window`,(0,L.jsxs)(zt,{id:`minutes`,value:e.minutes,onChange:t=>e.setMinutes(t.target.value),children:[(0,L.jsx)(`option`,{value:`15`,children:`15 minutes`}),(0,L.jsx)(`option`,{value:`60`,children:`1 hour`}),(0,L.jsx)(`option`,{value:`360`,children:`6 hours`}),(0,L.jsx)(`option`,{value:`1440`,children:`24 hours`})]})]}),(0,L.jsxs)(H,{children:[`Status`,(0,L.jsxs)(zt,{id:`status`,value:e.status,onChange:t=>e.setStatus(t.target.value),children:[(0,L.jsx)(`option`,{value:``,children:`Any status`}),(0,L.jsx)(`option`,{value:`queued`,children:`Queued`}),(0,L.jsx)(`option`,{value:`running`,children:`Running`}),(0,L.jsx)(`option`,{value:`succeeded`,children:`Succeeded`}),(0,L.jsx)(`option`,{value:`failed`,children:`Failed`}),(0,L.jsx)(`option`,{value:`dead`,children:`Dead`}),(0,L.jsx)(`option`,{value:`canceled`,children:`Canceled`})]})]}),(0,L.jsxs)(H,{children:[`Type`,(0,L.jsx)(V,{id:`jobType`,value:e.jobType,autoComplete:`off`,placeholder:`Any type`,onChange:t=>e.setJobType(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,L.jsxs)(z,{id:`refreshJobs`,type:`button`,onClick:e.onSearch,disabled:e.loading.jobs,children:[(0,L.jsx)(S,{}),`Refresh jobs`]})]}),(0,L.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-4`,"aria-label":`Job summary`,children:[(0,L.jsx)(Sn,{id:`metricTotal`,label:`Total`,value:e.jobs.length}),(0,L.jsx)(Sn,{id:`metricQueued`,label:`Queued`,value:e.jobCounts.queued||0}),(0,L.jsx)(Sn,{id:`metricRunning`,label:`Running`,value:e.jobCounts.running||0}),(0,L.jsx)(Sn,{id:`metricFailed`,label:`Failed`,value:(e.jobCounts.failed||0)+(e.jobCounts.dead||0)})]}),(0,L.jsxs)(It,{children:[(0,L.jsx)(B,{children:(0,L.jsx)(Lt,{children:`Recent jobs`})}),(0,L.jsx)(Cn,{hidden:e.jobs.length!==0,children:`No jobs match these filters.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`jobsTable`,className:I(`min-w-[980px]`,e.jobs.length===0&&`hidden`),"aria-label":`Recent jobs`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(xn,{className:`w-[22%]`,label:`Job id`,scope:`jobs`,sort:e.sort,sortKey:`job_id`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[24%]`,label:`Type`,scope:`jobs`,sort:e.sort,sortKey:`type`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[12%]`,label:`Status`,scope:`jobs`,sort:e.sort,sortKey:`status`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[12%]`,label:`Attempts`,scope:`jobs`,sort:e.sort,sortKey:`attempts`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[18%]`,label:`Updated`,scope:`jobs`,sort:e.sort,sortKey:`updated_at`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(U,{children:`Actions`})]})}),(0,L.jsx)(Ht,{id:`jobsBody`,children:e.jobs.map(t=>(0,L.jsxs)(Ut,{children:[(0,L.jsx)(W,{className:`font-mono`,children:t.job_id}),(0,L.jsx)(W,{children:t.type}),(0,L.jsx)(W,{children:(0,L.jsx)(R,{variant:t.status||`neutral`,children:t.status})}),(0,L.jsxs)(W,{children:[t.attempts,`/`,t.max_attempts]}),(0,L.jsx)(W,{children:Gt(t.updated_at)}),(0,L.jsx)(W,{children:(0,L.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[(0,L.jsx)(z,{type:`button`,size:`sm`,variant:`outline`,"aria-label":`View details for ${t.type} job ${t.job_id}`,onClick:()=>e.onDetail(t.job_id),disabled:e.loading[`detail:${t.job_id}`],children:`Details`}),e.canWrite?(0,L.jsx)(z,{type:`button`,size:`sm`,"aria-label":`Rerun ${t.type} job ${t.job_id}`,onClick:()=>e.onRerun(t.job_id),disabled:e.loading[`rerun:${t.job_id}`],children:`Rerun`}):null]})})]},t.job_id))})]})})]}),e.jobDetail?(0,L.jsxs)(It,{id:`jobDetailPanel`,children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Job detail`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.jobDetail.job_id})]}),(0,L.jsxs)(Rt,{className:`grid gap-4`,children:[(0,L.jsx)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[[`Type`,e.jobDetail.type],[`Status`,e.jobDetail.status],[`Attempts`,`${e.jobDetail.attempts}/${e.jobDetail.max_attempts}`],[`Updated`,Gt(e.jobDetail.updated_at)],[`Created`,Gt(e.jobDetail.created_at)],[`Run after`,Gt(e.jobDetail.run_after)],[`Locked by`,e.jobDetail.locked_by||`None`],[`Last error`,e.jobDetail.last_error||`None`]].map(([e,t])=>(0,L.jsxs)(`div`,{className:`grid gap-1 rounded-md border bg-background p-3`,children:[(0,L.jsx)(`span`,{className:`text-[11px] font-extrabold uppercase text-muted-foreground`,children:e}),(0,L.jsx)(`strong`,{className:`break-words text-sm`,children:t})]},e))}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`h2`,{className:`mb-2 text-[15px] font-bold`,children:`Payload`}),(0,L.jsx)(`pre`,{className:`max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border bg-background p-3 font-mono text-xs`,children:qt(e.jobDetail.payload)||`No payload`})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`h2`,{className:`mb-2 text-[15px] font-bold`,children:`Result`}),(0,L.jsx)(`pre`,{className:`max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border bg-background p-3 font-mono text-xs`,children:qt(e.jobDetail.result)||`No result`})]})]})]}):null]})}function rr(e){return(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Recent audit`}),(0,L.jsxs)(z,{id:`refreshAudit`,type:`button`,variant:`outline`,onClick:e.onRefresh,disabled:e.loading.audit,children:[(0,L.jsx)(S,{}),`Refresh`]})]}),(0,L.jsx)(Cn,{hidden:e.events.length!==0,children:`No audit events found.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`auditTable`,className:I(`min-w-[760px]`,e.events.length===0&&`hidden`),"aria-label":`Recent audit events`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(xn,{className:`w-[24%]`,label:`Time`,scope:`audit`,sort:e.sort,sortKey:`occurred_at`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[28%]`,label:`Actor`,scope:`audit`,sort:e.sort,sortKey:`actor`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[28%]`,label:`Action`,scope:`audit`,sort:e.sort,sortKey:`action`,onSort:(t,n)=>e.onSort(n)}),(0,L.jsx)(xn,{className:`w-[20%]`,label:`Result`,scope:`audit`,sort:e.sort,sortKey:`result`,onSort:(t,n)=>e.onSort(n)})]})}),(0,L.jsx)(Ht,{id:`auditBody`,children:e.events.map(e=>(0,L.jsxs)(Ut,{children:[(0,L.jsx)(W,{children:Gt(e.occurred_at)}),(0,L.jsx)(W,{children:e.actor_display_name||e.actor_subject||e.actor_provider}),(0,L.jsx)(W,{children:e.action}),(0,L.jsx)(W,{children:(0,L.jsx)(R,{variant:e.result===`success`?`succeeded`:`failed`,children:e.result})})]},e.id||`${e.occurred_at||``}-${e.actor_subject||``}-${e.action||``}`))})]})})]})}function ir({report:e,loading:t,onRefresh:n}){let r=e?.summary||{},i=[[`Status`,e?.status_counts||{}],[`Intent`,e?.intent_counts||{}],[`Planner`,e?.planner_counts||{}]].flatMap(([e,t])=>Object.entries(t).map(([t,n])=>({label:e,value:t,count:n}))).sort((e,t)=>t.count-e.count||e.label.localeCompare(t.label)),a=Array.isArray(e?.recent_unsupported)?e.recent_unsupported:[];return(0,L.jsxs)(L.Fragment,{children:[(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Agent requests`}),(0,L.jsxs)(z,{id:`refreshAgent`,type:`button`,variant:`outline`,onClick:n,disabled:t.agent,children:[(0,L.jsx)(S,{}),`Refresh`]})]}),(0,L.jsxs)(Rt,{className:`grid gap-3 md:grid-cols-5`,children:[(0,L.jsx)(Sn,{id:`agentMetricTotal`,label:`Total`,value:r.total||0}),(0,L.jsx)(Sn,{id:`agentMetricHandled`,label:`Handled`,value:r.handled||0}),(0,L.jsx)(Sn,{id:`agentMetricConfirmations`,label:`Confirmations`,value:r.requires_confirmation||0}),(0,L.jsx)(Sn,{id:`agentMetricClarifications`,label:`Clarifications`,value:r.needs_clarification||0}),(0,L.jsx)(Sn,{id:`agentMetricUnsupported`,label:`Not understood`,value:r.unsupported||0})]})]}),(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Request mix`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Recent agent.request audit events.`})]}),(0,L.jsx)(Cn,{hidden:i.length!==0,children:`No agent request data found.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`agentBreakdownTable`,className:I(`min-w-[860px]`,i.length===0&&`hidden`),"aria-label":`Agent request breakdown`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(U,{children:`Dimension`}),(0,L.jsx)(U,{children:`Value`}),(0,L.jsx)(U,{children:`Count`})]})}),(0,L.jsx)(Ht,{id:`agentBreakdownBody`,children:i.map(e=>(0,L.jsxs)(Ut,{children:[(0,L.jsx)(W,{children:e.label}),(0,L.jsx)(W,{children:e.value}),(0,L.jsx)(W,{children:e.count})]},`${e.label}-${e.value}`))})]})})]}),(0,L.jsxs)(It,{children:[(0,L.jsxs)(B,{children:[(0,L.jsx)(Lt,{children:`Not understood`}),(0,L.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Sanitized request text only.`})]}),(0,L.jsx)(Cn,{hidden:a.length!==0,children:`No unsupported agent requests found.`}),(0,L.jsx)(`div`,{className:`overflow-x-auto`,children:(0,L.jsxs)(Bt,{id:`agentUnsupportedTable`,className:I(`min-w-[860px]`,a.length===0&&`hidden`),"aria-label":`Unsupported agent requests`,children:[(0,L.jsx)(Vt,{children:(0,L.jsxs)(Ut,{children:[(0,L.jsx)(U,{children:`Time`}),(0,L.jsx)(U,{children:`Actor`}),(0,L.jsx)(U,{children:`Message`}),(0,L.jsx)(U,{children:`Result`})]})}),(0,L.jsx)(Ht,{id:`agentUnsupportedBody`,children:a.map(e=>(0,L.jsxs)(Ut,{children:[(0,L.jsx)(W,{children:Gt(e.occurred_at)}),(0,L.jsx)(W,{children:e.actor}),(0,L.jsx)(W,{children:e.message_sanitized}),(0,L.jsx)(W,{children:(0,L.jsx)(R,{variant:e.result===`success`?`succeeded`:`failed`,children:e.result||`unknown`})})]},`${e.occurred_at||``}-${e.actor||``}-${e.message_sanitized||``}`))})]})})]})]})}var ar=document.getElementById(`root`);if(!ar)throw Error(`Missing #root container`);(0,ue.createRoot)(ar).render((0,L.jsx)(l.StrictMode,{children:(0,L.jsx)(Tn,{})})); \ No newline at end of file diff --git a/apps/api/src/five08/backend/static/dashboard/index.html b/apps/api/src/five08/backend/static/dashboard/index.html index 690e49af..c7a8b5a5 100644 --- a/apps/api/src/five08/backend/static/dashboard/index.html +++ b/apps/api/src/five08/backend/static/dashboard/index.html @@ -4,7 +4,7 @@ 508 Operations Dashboard - + diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index d9162738..9723d724 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -3825,12 +3825,20 @@ def _migadu_client(self) -> MigaduClient: async def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: """Best-effort subscribe mailbox and backup addresses to newsletter tools.""" - result = await asyncio.to_thread( - sync_newsletter_contacts, - settings, - emails, - source="discord_create_user_accounts", - ) + try: + result = await asyncio.to_thread( + sync_newsletter_contacts, + settings, + emails, + source="discord_create_user_accounts", + ) + except Exception as exc: + error_warning = self._sanitize_error_message_for_discord( + f"Newsletter sync failed: {exc}", + max_length=500, + ) + logger.warning("Newsletter sync warning: %s", error_warning, exc_info=True) + return error_warning warning = format_newsletter_sync_warning(result) if warning: logger.warning("Newsletter sync warning: %s", warning) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py b/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py index b6818d36..bb7860ff 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/migadu.py @@ -215,12 +215,20 @@ def _migadu_client(self) -> MigaduClient: async def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: """Best-effort subscribe mailbox and backup addresses to newsletter tools.""" - result = await asyncio.to_thread( - sync_newsletter_contacts, - settings, - emails, - source="discord_create_mailbox", - ) + try: + result = await asyncio.to_thread( + sync_newsletter_contacts, + settings, + emails, + source="discord_create_mailbox", + ) + except Exception as exc: + error_warning = _truncate_discord_text( + f"Newsletter sync failed: {exc}", + limit=500, + ) + logger.warning("Newsletter sync warning: %s", error_warning, exc_info=True) + return error_warning warning = format_newsletter_sync_warning(result) if warning: logger.warning("Newsletter sync warning: %s", warning) diff --git a/apps/worker/src/five08/worker/jobs.py b/apps/worker/src/five08/worker/jobs.py index 00d6da11..d47d7efc 100644 --- a/apps/worker/src/five08/worker/jobs.py +++ b/apps/worker/src/five08/worker/jobs.py @@ -165,11 +165,33 @@ def sync_projects_from_erpnext_job() -> dict[str, Any]: return processor.sync_open_projects() +def _mask_newsletter_sync_result(result: dict[str, Any]) -> dict[str, Any]: + """Mask email addresses before newsletter sync results are persisted.""" + crm_failures = result.get("crm_lookup_failures") + if isinstance(crm_failures, list): + for failure in crm_failures: + if isinstance(failure, dict) and failure.get("mailbox"): + failure["mailbox"] = mask_email(str(failure["mailbox"])) + + providers = result.get("providers") + if isinstance(providers, dict): + for provider_result in providers.values(): + if not isinstance(provider_result, dict): + continue + failures = provider_result.get("failures") + if not isinstance(failures, list): + continue + for failure in failures: + if isinstance(failure, dict) and failure.get("email"): + failure["email"] = mask_email(str(failure["email"])) + return result + + def sync_508_members_newsletters_job() -> dict[str, Any]: """Sync Migadu member emails into configured newsletter providers.""" logger.info("Processing 508 members newsletter sync job") processor = NewsletterSyncProcessor(settings) - return processor.sync_508_members() + return _mask_newsletter_sync_result(processor.sync_508_members()) JOB_FUNCTIONS: dict[str, Callable[..., dict[str, Any]]] = { diff --git a/packages/shared/src/five08/agent/tools.py b/packages/shared/src/five08/agent/tools.py index f6c3e7b3..96ef80d3 100644 --- a/packages/shared/src/five08/agent/tools.py +++ b/packages/shared/src/five08/agent/tools.py @@ -1426,11 +1426,15 @@ def _migadu_client(self) -> MigaduClient: ) def _add_emails_to_newsletter(self, emails: list[str]) -> str | None: - result = sync_newsletter_contacts( - self.runtime_config, - emails, - source="agent_account_creation", - ) + try: + result = sync_newsletter_contacts( + self.runtime_config, + emails, + source="agent_account_creation", + ) + except Exception as exc: + text = " ".join(f"Newsletter sync failed: {exc}".split()).strip() + return f"{text[:197]}..." if len(text) > 200 else text warning = format_newsletter_sync_warning(result) if not warning: return None diff --git a/packages/shared/src/five08/clients/brevo.py b/packages/shared/src/five08/clients/brevo.py index aca1dc1b..a348b4a7 100644 --- a/packages/shared/src/five08/clients/brevo.py +++ b/packages/shared/src/five08/clients/brevo.py @@ -83,6 +83,8 @@ def add_contact_to_list( def get_contact(self, email: str) -> dict[str, Any] | None: """Return one Brevo contact by email, or None when it does not exist.""" normalized_email = email.strip().lower() + if not normalized_email or normalized_email.count("@") != 1: + raise ValueError("Brevo contact email must be a full email address.") headers = { "Accept": "application/json", "api-key": self.api_key, diff --git a/packages/shared/src/five08/clients/keila.py b/packages/shared/src/five08/clients/keila.py index e255ebb8..64e951e8 100644 --- a/packages/shared/src/five08/clients/keila.py +++ b/packages/shared/src/five08/clients/keila.py @@ -68,7 +68,12 @@ def upsert_active_contact( self._request("POST", "/api/v1/contacts", json={"data": payload}) or {} ) - contact_id = str(existing.get("id") or normalized_email) + contact_id_value = existing.get("id") + if not contact_id_value: + raise KeilaAPIError( + f"Existing Keila contact {normalized_email} missing id." + ) + contact_id = str(contact_id_value) payload.pop("status", None) return ( self._request( diff --git a/packages/shared/src/five08/newsletter_sync.py b/packages/shared/src/five08/newsletter_sync.py index 548a7a0b..10321e32 100644 --- a/packages/shared/src/five08/newsletter_sync.py +++ b/packages/shared/src/five08/newsletter_sync.py @@ -356,15 +356,18 @@ def sync_newsletter_contacts( return result seen: set[str] = set() + default_mailbox_email = (mailbox_email or "").strip().lower() for email in emails: normalized_email = email.strip().lower() if not normalized_email or normalized_email in seen: continue seen.add(normalized_email) + if not default_mailbox_email: + default_mailbox_email = normalized_email result["contacts_considered"] += 1 contact = NewsletterContact( email=normalized_email, - mailbox_email=(mailbox_email or normalized_email).strip().lower(), + mailbox_email=default_mailbox_email, name=name, source=source, ) diff --git a/tests/unit/test_agent_gateway.py b/tests/unit/test_agent_gateway.py index 6e5be1ab..d7bd349c 100644 --- a/tests/unit/test_agent_gateway.py +++ b/tests/unit/test_agent_gateway.py @@ -2595,6 +2595,40 @@ def test_user_accounts_tool_reports_suppressed_newsletter_contact( assert fakes.brevo.subscriptions == [{"email": "jane@508.dev", "list_id": 4}] +def test_user_accounts_tool_reports_newsletter_sync_exceptions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_account_tool_fakes(monkeypatch) + + def _raise_newsletter_error(*args: object, **kwargs: object) -> dict[str, object]: + raise RuntimeError("provider exploded") + + monkeypatch.setattr( + "five08.agent.tools.sync_newsletter_contacts", + _raise_newsletter_error, + ) + registry = ToolRegistry(runtime_config=_account_runtime_config(brevo_api_key="key")) + + result = registry.execute( + "account_write.create_user_accounts", + {"contact_id": "contact-1", "mailbox_username": "jane@508.dev"}, + organization_id="org-1", + actor_id="123", + actor_scopes={ + "mailbox:create", + "user:manage", + "integration:manage", + "crm:contact:read", + "crm:contact:update", + }, + ) + + assert result["mailbox"]["newsletter_subscribed"] is False + assert result["mailbox"]["newsletter_error"] == ( + "Newsletter sync failed: provider exploded" + ) + + def test_user_accounts_tool_subscribes_mailbox_and_backup_email_to_keila( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit/test_brevo_client.py b/tests/unit/test_brevo_client.py index 97fd1a9f..a09ff855 100644 --- a/tests/unit/test_brevo_client.py +++ b/tests/unit/test_brevo_client.py @@ -105,6 +105,12 @@ def test_get_contact_returns_none_for_missing_contact() -> None: assert result is None +@pytest.mark.parametrize("email", ["", "not-an-email"]) +def test_get_contact_rejects_invalid_email(email: str) -> None: + with pytest.raises(ValueError, match="full email address"): + BrevoClient(api_key="brevo-key").get_contact(email) + + def test_find_list_id_by_name_gets_matching_list() -> None: response = Mock() response.status_code = 200 diff --git a/tests/unit/test_crm_create_sso_user.py b/tests/unit/test_crm_create_sso_user.py index 5a37ef80..b9ea8c8c 100644 --- a/tests/unit/test_crm_create_sso_user.py +++ b/tests/unit/test_crm_create_sso_user.py @@ -44,6 +44,21 @@ def cog(mock_espo_api: Mock) -> CRMCog: return CRMCog(Mock()) +@pytest.mark.asyncio +async def test_add_emails_to_newsletter_returns_warning_on_unexpected_error( + cog: CRMCog, +) -> None: + with patch( + "five08.discord_bot.cogs.crm.sync_newsletter_contacts", + side_effect=RuntimeError("provider `exploded`"), + ): + warning = await cog._add_emails_to_newsletter( + ["jane@508.dev", "jane@example.com"] + ) + + assert warning == "Newsletter sync failed: provider 'exploded'" + + @pytest.mark.asyncio async def test_create_sso_user_creates_links_and_sends_recovery_email( cog: CRMCog, mock_interaction: AsyncMock, mock_espo_api: Mock diff --git a/tests/unit/test_keila_client.py b/tests/unit/test_keila_client.py index fde2177d..a2ae8c77 100644 --- a/tests/unit/test_keila_client.py +++ b/tests/unit/test_keila_client.py @@ -141,6 +141,22 @@ def test_upsert_active_contact_updates_existing_contact_without_status() -> None assert result == {"id": "contact-1"} +def test_upsert_active_contact_requires_existing_contact_id() -> None: + existing = Mock() + existing.status_code = 200 + existing.content = b'{"data":{"email":"jane@example.com","status":"active"}}' + existing.json.return_value = { + "data": {"email": "jane@example.com", "status": "active"} + } + + with patch("five08.clients.keila.requests.request", return_value=existing): + with pytest.raises(KeilaAPIError, match="missing id"): + KeilaClient(api_key="keila-key").upsert_active_contact( + email="jane@example.com", + data={"audiences": ["508_members"]}, + ) + + def test_keila_client_raises_on_request_error() -> None: with patch( "five08.clients.keila.requests.request", diff --git a/tests/unit/test_migadu_create_mailbox.py b/tests/unit/test_migadu_create_mailbox.py index b70925de..11cb3aa0 100644 --- a/tests/unit/test_migadu_create_mailbox.py +++ b/tests/unit/test_migadu_create_mailbox.py @@ -75,6 +75,21 @@ def test_normalize_mailbox_request_rejects_non_508_domain( migadu_cog._normalize_mailbox_request("alice@gmail.com") +@pytest.mark.asyncio +async def test_add_emails_to_newsletter_returns_warning_on_unexpected_error( + migadu_cog: MigaduCog, +) -> None: + with patch( + "five08.discord_bot.cogs.migadu.sync_newsletter_contacts", + side_effect=RuntimeError("provider exploded"), + ): + warning = await migadu_cog._add_emails_to_newsletter( + ["alice@508.dev", "alice@gmail.com"] + ) + + assert warning == "Newsletter sync failed: provider exploded" + + @pytest.mark.asyncio async def test_create_mailbox_command_success_with_crm_defaults_and_sync( migadu_cog: MigaduCog, diff --git a/tests/unit/test_newsletter_sync.py b/tests/unit/test_newsletter_sync.py index 4977add5..06a594be 100644 --- a/tests/unit/test_newsletter_sync.py +++ b/tests/unit/test_newsletter_sync.py @@ -13,6 +13,7 @@ NewsletterSyncProcessor, build_newsletter_providers, format_newsletter_sync_warning, + sync_newsletter_contacts, ) @@ -295,6 +296,24 @@ def test_format_newsletter_sync_warning_reports_suppressed_skips() -> None: assert warning == "brevo skipped 1 suppressed contact(s)" +def test_sync_newsletter_contacts_uses_first_email_as_default_mailbox_pointer() -> None: + result = sync_newsletter_contacts( + _settings(brevo_api_key=None), + ["jane@508.dev", "jane@example.com"], + source="test", + ) + + assert result["providers"]["keila"]["synced"] == 2 + assert [item["email"] for item in FakeKeilaClient.upserts] == [ + "jane@508.dev", + "jane@example.com", + ] + assert [item["data"]["mailbox_email"] for item in FakeKeilaClient.upserts] == [ + "jane@508.dev", + "jane@508.dev", + ] + + def test_sync_508_members_skips_mailbox_when_crm_lookup_fails() -> None: FakeMigaduClient.mailboxes = [ MigaduMailbox( diff --git a/tests/unit/test_worker_newsletter_sync.py b/tests/unit/test_worker_newsletter_sync.py new file mode 100644 index 00000000..852c7161 --- /dev/null +++ b/tests/unit/test_worker_newsletter_sync.py @@ -0,0 +1,56 @@ +"""Unit tests for worker newsletter sync job result handling.""" + +import pytest + +from five08.worker import jobs + + +def test_sync_508_members_newsletters_job_masks_failure_emails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeNewsletterSyncProcessor: + def __init__(self, settings: object) -> None: + self.settings = settings + + def sync_508_members(self) -> dict[str, object]: + return { + "crm_lookup_failures": [ + {"mailbox": "jane@508.dev", "error": "CRM unavailable"} + ], + "providers": { + "brevo": { + "failures": [ + { + "email": "jane@example.com", + "error": "provider unavailable", + } + ] + }, + "keila": {"failures": "not-a-list"}, + }, + } + + monkeypatch.setattr( + jobs, + "NewsletterSyncProcessor", + FakeNewsletterSyncProcessor, + ) + + result = jobs.sync_508_members_newsletters_job() + + assert result == { + "crm_lookup_failures": [ + {"mailbox": "j***@5****...", "error": "CRM unavailable"} + ], + "providers": { + "brevo": { + "failures": [ + { + "email": "j***@e****...", + "error": "provider unavailable", + } + ] + }, + "keila": {"failures": "not-a-list"}, + }, + } From 54304cfbd0d059981ee7277f2f1e4c14de1b691f Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 11 Jun 2026 09:11:45 +0800 Subject: [PATCH 09/14] Address newsletter sync review feedback --- apps/api/src/five08/backend/api.py | 5 +- packages/shared/src/five08/clients/keila.py | 9 ++- packages/shared/src/five08/newsletter_sync.py | 11 ++-- tests/unit/test_agent_gateway.py | 2 + tests/unit/test_backend_api.py | 17 +++++ tests/unit/test_keila_client.py | 24 +++++++ tests/unit/test_newsletter_sync.py | 66 +++++++++++++++++++ 7 files changed, 128 insertions(+), 6 deletions(-) diff --git a/apps/api/src/five08/backend/api.py b/apps/api/src/five08/backend/api.py index 1f84116b..1444303d 100644 --- a/apps/api/src/five08/backend/api.py +++ b/apps/api/src/five08/backend/api.py @@ -640,7 +640,10 @@ async def _enqueue_newsletter_sync_job( idempotency_key = ( _newsletter_sync_idempotency_key(now=now) if reason == "scheduler" - else f"newsletter-sync:508-members:{reason}:{now.strftime('%Y%m%d%H%M%S')}" + else ( + f"newsletter-sync:508-members:{reason}:" + f"{now.strftime('%Y%m%d%H%M%S%f')}:{uuid4().hex}" + ) ) job: EnqueuedJob = await asyncio.to_thread( enqueue_job, diff --git a/packages/shared/src/five08/clients/keila.py b/packages/shared/src/five08/clients/keila.py index 032f1140..8ccac97c 100644 --- a/packages/shared/src/five08/clients/keila.py +++ b/packages/shared/src/five08/clients/keila.py @@ -8,6 +8,7 @@ import requests KEILA_API_BASE_URL = "https://app.keila.io" +_EXISTING_CONTACT_UNSET = object() class KeilaAPIError(RuntimeError): @@ -46,6 +47,7 @@ def upsert_active_contact( first_name: str | None = None, last_name: str | None = None, data: dict[str, Any] | None = None, + existing_contact: dict[str, Any] | None | object = _EXISTING_CONTACT_UNSET, ) -> dict[str, Any]: """Create or update a Keila contact without changing suppressed statuses.""" normalized_email = email.strip().lower() @@ -62,7 +64,12 @@ def upsert_active_contact( if last_name: payload["last_name"] = last_name - existing = self.get_contact_by_email(normalized_email) + if existing_contact is _EXISTING_CONTACT_UNSET: + existing = self.get_contact_by_email(normalized_email) + else: + if existing_contact is not None and not isinstance(existing_contact, dict): + raise TypeError("existing_contact must be a Keila contact object.") + existing = existing_contact if existing is None: return ( self._request("POST", "/api/v1/contacts", json={"data": payload}) or {} diff --git a/packages/shared/src/five08/newsletter_sync.py b/packages/shared/src/five08/newsletter_sync.py index 10321e32..16c34ce3 100644 --- a/packages/shared/src/five08/newsletter_sync.py +++ b/packages/shared/src/five08/newsletter_sync.py @@ -111,17 +111,19 @@ def __init__( self.client = client self.list_id = list_id self.list_name = list_name + self._list_id_lookup_completed = list_id is not None + self._resolved_list_id = list_id def _list_id(self) -> int | None: - if self.list_id is not None: - return self.list_id - return self.client.find_list_id_by_name(self.list_name) + if not self._list_id_lookup_completed: + self._resolved_list_id = self.client.find_list_id_by_name(self.list_name) + self._list_id_lookup_completed = True + return self._resolved_list_id def ensure_contact(self, contact: NewsletterContact) -> str: existing = self.client.get_contact(contact.email) if existing is not None and ( bool(existing.get("emailBlacklisted")) - or bool(existing.get("smsBlacklisted")) or str(existing.get("status") or "").strip().casefold() in PROVIDER_SUPPRESSED_STATUSES ): @@ -162,6 +164,7 @@ def ensure_contact(self, contact: NewsletterContact) -> str: "source": contact.source, "mailbox_email": contact.mailbox_email, }, + existing_contact=existing, ) return "synced" diff --git a/tests/unit/test_agent_gateway.py b/tests/unit/test_agent_gateway.py index 5298426a..a889146b 100644 --- a/tests/unit/test_agent_gateway.py +++ b/tests/unit/test_agent_gateway.py @@ -2123,6 +2123,7 @@ def upsert_active_contact( first_name: str | None = None, last_name: str | None = None, data: dict[str, Any] | None = None, + existing_contact: dict[str, Any] | None | object = None, ) -> dict[str, Any]: self.upserts.append( { @@ -2130,6 +2131,7 @@ def upsert_active_contact( "first_name": first_name, "last_name": last_name, "data": data, + "existing_contact": existing_contact, } ) return {"id": str(len(self.upserts))} diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py index c978d8ad..ddd00b65 100644 --- a/tests/unit/test_backend_api.py +++ b/tests/unit/test_backend_api.py @@ -6569,6 +6569,23 @@ def test_dashboard_sync_newsletters_audits_discord_session(client: TestClient) - assert audit_payload.metadata["source"] == "dashboard" +@pytest.mark.asyncio +async def test_manual_newsletter_sync_idempotency_keys_are_unique() -> None: + with patch( + "five08.backend.api.enqueue_job", + side_effect=[ + Mock(id="job-newsletter-1", created=True), + Mock(id="job-newsletter-2", created=True), + ], + ) as mock_enqueue: + await api._enqueue_newsletter_sync_job(Mock(), reason="dashboard") + await api._enqueue_newsletter_sync_job(Mock(), reason="dashboard") + + keys = [item.kwargs["idempotency_key"] for item in mock_enqueue.call_args_list] + assert keys[0] != keys[1] + assert all(key.startswith("newsletter-sync:508-members:dashboard:") for key in keys) + + def test_dashboard_sync_newsletters_workflows_engineer_is_dry_run( client: TestClient, ) -> None: diff --git a/tests/unit/test_keila_client.py b/tests/unit/test_keila_client.py index 3e70170f..b0c4c25a 100644 --- a/tests/unit/test_keila_client.py +++ b/tests/unit/test_keila_client.py @@ -182,6 +182,30 @@ def test_upsert_active_contact_preserves_existing_contact_data() -> None: assert result == {"id": "contact-1"} +def test_upsert_active_contact_uses_supplied_existing_contact_without_lookup() -> None: + updated = Mock() + updated.status_code = 200 + updated.content = b'{"data":{"id":"contact-1"}}' + updated.json.return_value = {"data": {"id": "contact-1"}} + + with patch( + "five08.clients.keila.requests.request", + return_value=updated, + ) as request: + result = KeilaClient(api_key="keila-key").upsert_active_contact( + email="jane@example.com", + data={"audiences": ["508_members"]}, + existing_contact={"id": "contact-1", "email": "jane@example.com"}, + ) + + request.assert_called_once() + assert request.call_args.args == ( + "PATCH", + "https://app.keila.io/api/v1/contacts/contact-1", + ) + assert result == {"id": "contact-1"} + + def test_upsert_active_contact_requires_existing_contact_id() -> None: existing = Mock() existing.status_code = 200 diff --git a/tests/unit/test_newsletter_sync.py b/tests/unit/test_newsletter_sync.py index 06a594be..191c54c8 100644 --- a/tests/unit/test_newsletter_sync.py +++ b/tests/unit/test_newsletter_sync.py @@ -38,6 +38,7 @@ def list_mailboxes(self) -> list[MigaduMailbox]: class FakeBrevoClient: contacts: dict[str, dict[str, Any]] = {} subscriptions: list[dict[str, Any]] = [] + list_lookup_names: list[str] = [] def __init__( self, @@ -58,12 +59,14 @@ def add_contact_to_list(self, *, email: str, list_id: int) -> dict[str, Any]: return {"id": len(self.subscriptions)} def find_list_id_by_name(self, name: str) -> int | None: + self.list_lookup_names.append(name) return 4 if name == "508 members" else None class FakeKeilaClient: contacts: dict[str, dict[str, Any]] = {} upserts: list[dict[str, Any]] = [] + lookups: list[str] = [] def __init__( self, @@ -77,6 +80,7 @@ def __init__( self.timeout_seconds = timeout_seconds def get_contact_by_email(self, email: str) -> dict[str, Any] | None: + self.lookups.append(email) return self.contacts.get(email) def upsert_active_contact( @@ -86,6 +90,7 @@ def upsert_active_contact( first_name: str | None = None, last_name: str | None = None, data: dict[str, Any] | None = None, + existing_contact: dict[str, Any] | None | object = None, ) -> dict[str, Any]: self.upserts.append( { @@ -93,6 +98,7 @@ def upsert_active_contact( "first_name": first_name, "last_name": last_name, "data": data, + "existing_contact": existing_contact, } ) return {"id": str(len(self.upserts))} @@ -123,8 +129,10 @@ def reset_fakes(monkeypatch: pytest.MonkeyPatch) -> None: FakeMigaduClient.mailboxes = [] FakeBrevoClient.contacts = {} FakeBrevoClient.subscriptions = [] + FakeBrevoClient.list_lookup_names = [] FakeKeilaClient.contacts = {} FakeKeilaClient.upserts = [] + FakeKeilaClient.lookups = [] FakeEspoClient.contacts = [] FakeEspoClient.raise_error = False monkeypatch.setattr("five08.newsletter_sync.MigaduClient", FakeMigaduClient) @@ -205,6 +213,24 @@ def test_sync_508_members_skips_provider_suppressed_contacts() -> None: } +def test_sync_508_members_allows_sms_only_brevo_blacklist() -> None: + FakeMigaduClient.mailboxes = [ + MigaduMailbox( + address="jane@508.dev", + name="Jane Doe", + password_recovery_email=None, + ) + ] + FakeBrevoClient.contacts = { + "jane@508.dev": {"smsBlacklisted": True, "emailBlacklisted": False} + } + + result = NewsletterSyncProcessor(_settings(keila_api_key=None)).sync_508_members() + + assert FakeBrevoClient.subscriptions == [{"email": "jane@508.dev", "list_id": 4}] + assert result["providers"]["brevo"]["statuses"] == {"synced": 1} + + def test_sync_508_members_skips_brevo_list_unsubscribed_contacts() -> None: FakeMigaduClient.mailboxes = [ MigaduMailbox( @@ -224,6 +250,46 @@ def test_sync_508_members_skips_brevo_list_unsubscribed_contacts() -> None: } +def test_sync_508_members_caches_brevo_list_lookup_by_name() -> None: + FakeMigaduClient.mailboxes = [ + MigaduMailbox( + address="jane@508.dev", + name="Jane Doe", + password_recovery_email="jane@example.com", + ) + ] + + result = NewsletterSyncProcessor( + _settings( + brevo_508_members_newsletter_list_id=None, + brevo_508_members_newsletter_list_name="508 members", + keila_api_key=None, + ) + ).sync_508_members() + + assert result["providers"]["brevo"]["synced"] == 2 + assert FakeBrevoClient.list_lookup_names == ["508 members"] + + +def test_sync_508_members_avoids_duplicate_keila_contact_lookups() -> None: + FakeMigaduClient.mailboxes = [ + MigaduMailbox( + address="jane@508.dev", + name="Jane Doe", + password_recovery_email="jane@example.com", + ) + ] + + result = NewsletterSyncProcessor(_settings(brevo_api_key=None)).sync_508_members() + + assert result["providers"]["keila"]["synced"] == 2 + assert FakeKeilaClient.lookups == ["jane@508.dev", "jane@example.com"] + assert [item["existing_contact"] for item in FakeKeilaClient.upserts] == [ + None, + None, + ] + + def test_sync_508_members_skips_crm_blocked_mailboxes() -> None: FakeMigaduClient.mailboxes = [ MigaduMailbox( From 75fac4d273d4bb89f7fecf11c77f7bec09d83668 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 11 Jun 2026 09:23:10 +0800 Subject: [PATCH 10/14] Use CRM gating for newsletter resync exclusions --- .env.example | 2 +- ENVIRONMENT.md | 4 +- docs/configuration.md | 7 +-- packages/shared/src/five08/newsletter_sync.py | 22 +++++++++- packages/shared/src/five08/runtime_config.py | 2 +- packages/shared/src/five08/settings.py | 7 +-- tests/unit/test_newsletter_sync.py | 43 ++++++++++++++++++- 7 files changed, 72 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 657b1b40..92aa4d49 100644 --- a/.env.example +++ b/.env.example @@ -249,7 +249,7 @@ MIGADU_MAILBOX_DOMAIN=508.dev # KEILA_API_TIMEOUT_SECONDS=20.0 # NEWSLETTER_SYNC_ENABLED=true # NEWSLETTER_SYNC_INTERVAL_SECONDS=604800 -# NEWSLETTER_SYNC_EXCLUDED_MAILBOXES=authentik@508.dev,baserow@508.dev,cal@508.dev,calendar@508.dev,coolify@508.dev,crm@508.dev,docuseal@508.dev,events@508.dev,keycloak@508.dev,kimai@508.dev,matrix@508.dev,openproject@508.dev,supabase@508.dev,vaultwarden@508.dev,wiki@508.dev +# NEWSLETTER_SYNC_EXCLUDED_MAILBOXES=system,service-account # EspoCRM (required for worker integration) ESPO_API_KEY=your_key_here diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index bee6f3d1..2f2b21a3 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -172,9 +172,9 @@ current precedence rules. - `Optional`: `KEILA_API_TIMEOUT_SECONDS` (default: `20.0`) - `Optional`: `NEWSLETTER_SYNC_ENABLED` (default: `true`) - `Optional`: `NEWSLETTER_SYNC_INTERVAL_SECONDS` (default: `604800`, one week) -- `Optional`: `NEWSLETTER_SYNC_EXCLUDED_MAILBOXES` (comma-separated system mailboxes to skip during Migadu resync) +- `Optional`: `NEWSLETTER_SYNC_EXCLUDED_MAILBOXES` (comma-separated mailbox local-parts or full addresses to skip during Migadu resync) - Note: mailbox and backup email subscription to configured newsletter tools is best effort. Failures are reported as warnings and do not block mailbox or account creation. -- Note: the periodic sync uses Migadu mailboxes and password recovery emails as the source of truth for `@508.dev`, skips configured system mailboxes, and does not re-add provider-suppressed contacts. +- Note: the periodic sync uses Migadu mailboxes and password recovery emails as the source of truth for `@508.dev`. When CRM is configured, it only syncs mailboxes that match a CRM contact; it also skips configured excluded mailboxes and does not re-add provider-suppressed contacts. ## Authentik SSO Provisioning diff --git a/docs/configuration.md b/docs/configuration.md index 1b27f5ac..62a1ad46 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -225,10 +225,11 @@ for same-host deployments. - `KEILA_API_TIMEOUT_SECONDS`: optional, defaults to `20.0`. - `NEWSLETTER_SYNC_ENABLED`: optional, defaults to `true`; dashboard changes require an API restart because the scheduler starts at startup. - `NEWSLETTER_SYNC_INTERVAL_SECONDS`: optional, defaults to `604800`; dashboard changes require an API restart because the scheduler sleep interval is startup-bound. -- `NEWSLETTER_SYNC_EXCLUDED_MAILBOXES`: optional comma-separated system mailboxes to skip during Migadu resync. +- `NEWSLETTER_SYNC_EXCLUDED_MAILBOXES`: optional comma-separated mailbox local-parts or full addresses to skip during Migadu resync. Mailbox and backup email subscription to configured newsletter tools is best effort. Failures are reported as warnings and do not block mailbox or account creation. The periodic sync uses Migadu mailboxes and password recovery emails -as the source of truth for `@508.dev`, skips configured system mailboxes, and -does not re-add provider-suppressed contacts. +as the source of truth for `@508.dev`. When CRM is configured, it only syncs +mailboxes that match a CRM contact; it also skips configured excluded mailboxes +and does not re-add provider-suppressed contacts. diff --git a/packages/shared/src/five08/newsletter_sync.py b/packages/shared/src/five08/newsletter_sync.py index 16c34ce3..cc83f1b5 100644 --- a/packages/shared/src/five08/newsletter_sync.py +++ b/packages/shared/src/five08/newsletter_sync.py @@ -80,6 +80,18 @@ def _normalized_csv_set(value: str) -> set[str]: return {item.strip().lower() for item in value.split(",") if item.strip()} +def _mailbox_local_part(email: str) -> str: + return email.split("@", 1)[0].strip().lower() + + +def _is_mailbox_excluded(email: str, excluded_mailboxes: set[str]) -> bool: + normalized_email = email.strip().lower() + if normalized_email in excluded_mailboxes: + return True + local_part = _mailbox_local_part(normalized_email) + return bool(local_part and local_part in excluded_mailboxes) + + def _is_crm_blocked(contact: dict[str, Any] | None) -> bool: if contact is None: return False @@ -184,6 +196,7 @@ def sync_508_members(self) -> dict[str, Any]: "mailboxes_scanned": 0, "system_mailboxes_skipped": 0, "crm_blocked_skipped": 0, + "crm_unmatched_skipped": 0, "crm_lookup_failed_skipped": 0, "contacts_considered": 0, "providers": { @@ -195,9 +208,10 @@ def sync_508_members(self) -> dict[str, Any]: result["warning"] = "no_newsletter_providers_configured" return result + crm_lookup_enabled = self._crm_lookup_enabled() for mailbox in self._migadu_client().list_mailboxes(): result["mailboxes_scanned"] += 1 - if mailbox.address in self.excluded_mailboxes: + if _is_mailbox_excluded(mailbox.address, self.excluded_mailboxes): result["system_mailboxes_skipped"] += 1 continue @@ -209,6 +223,9 @@ def sync_508_members(self) -> dict[str, Any]: if isinstance(failures, list) and len(failures) < 20: failures.append({"mailbox": mailbox.address, "error": str(exc)}) continue + if crm_lookup_enabled and not crm_contacts: + result["crm_unmatched_skipped"] += 1 + continue if any(_is_crm_blocked(contact) for contact in crm_contacts): result["crm_blocked_skipped"] += 1 continue @@ -248,6 +265,9 @@ def _crm_client(self) -> EspoClient | None: return None return EspoClient(base_url, api_key) + def _crm_lookup_enabled(self) -> bool: + return self._crm_client() is not None + def _list_crm_contacts(self, mailbox: MigaduMailbox) -> list[dict[str, Any]]: client = self._crm_client() if client is None: diff --git a/packages/shared/src/five08/runtime_config.py b/packages/shared/src/five08/runtime_config.py index 3624410b..03ddfe57 100644 --- a/packages/shared/src/five08/runtime_config.py +++ b/packages/shared/src/five08/runtime_config.py @@ -190,7 +190,7 @@ class RuntimeConfigDBSnapshot: attr="newsletter_sync_excluded_mailboxes", label="Newsletter excluded mailboxes", category="Newsletter", - description="Comma-separated Migadu mailboxes skipped by the 508 members resync.", + description="Comma-separated Migadu mailbox local-parts or full addresses skipped by the 508 members resync.", value_type="csv", env_names=("NEWSLETTER_SYNC_EXCLUDED_MAILBOXES",), ), diff --git a/packages/shared/src/five08/settings.py b/packages/shared/src/five08/settings.py index d9bf14d9..f56e11da 100644 --- a/packages/shared/src/five08/settings.py +++ b/packages/shared/src/five08/settings.py @@ -87,12 +87,7 @@ class SharedSettings(BaseSettings): keila_api_timeout_seconds: float = 20.0 newsletter_sync_enabled: bool = True newsletter_sync_interval_seconds: int = 604800 - newsletter_sync_excluded_mailboxes: str = ( - "authentik@508.dev,baserow@508.dev,cal@508.dev,calendar@508.dev," - "coolify@508.dev,crm@508.dev,docuseal@508.dev,events@508.dev," - "keycloak@508.dev,kimai@508.dev,matrix@508.dev,openproject@508.dev," - "supabase@508.dev,vaultwarden@508.dev,wiki@508.dev" - ) + newsletter_sync_excluded_mailboxes: str = "" model_config = SettingsConfigDict( env_file=".env", diff --git a/tests/unit/test_newsletter_sync.py b/tests/unit/test_newsletter_sync.py index 191c54c8..ba71c224 100644 --- a/tests/unit/test_newsletter_sync.py +++ b/tests/unit/test_newsletter_sync.py @@ -149,7 +149,7 @@ def _settings(**overrides: Any) -> SimpleNamespace: "brevo_api_key": "brevo-key", "brevo_508_members_newsletter_list_id": 4, "keila_api_key": "keila-key", - "newsletter_sync_excluded_mailboxes": "system@508.dev", + "newsletter_sync_excluded_mailboxes": "system", } values.update(overrides) return SimpleNamespace(**values) @@ -310,6 +310,47 @@ def test_sync_508_members_skips_crm_blocked_mailboxes() -> None: assert FakeKeilaClient.upserts == [] +def test_sync_508_members_skips_crm_unmatched_mailboxes_when_crm_configured() -> None: + FakeMigaduClient.mailboxes = [ + MigaduMailbox( + address="service@508.dev", + name="Service Account", + password_recovery_email="ops@example.com", + ) + ] + + result = NewsletterSyncProcessor( + _settings(espo_base_url="https://crm.example", espo_api_key="espo-key") + ).sync_508_members() + + assert result["crm_unmatched_skipped"] == 1 + assert result["contacts_considered"] == 0 + assert FakeBrevoClient.subscriptions == [] + assert FakeKeilaClient.upserts == [] + + +def test_sync_508_members_syncs_crm_matched_mailboxes_when_crm_configured() -> None: + FakeMigaduClient.mailboxes = [ + MigaduMailbox( + address="jane@508.dev", + name="Jane Doe", + password_recovery_email="jane@example.com", + ) + ] + FakeEspoClient.contacts = [{"id": "contact-1", "type": "Member"}] + + result = NewsletterSyncProcessor( + _settings(espo_base_url="https://crm.example", espo_api_key="espo-key") + ).sync_508_members() + + assert result["crm_unmatched_skipped"] == 0 + assert result["contacts_considered"] == 2 + assert FakeBrevoClient.subscriptions == [ + {"email": "jane@508.dev", "list_id": 4}, + {"email": "jane@example.com", "list_id": 4}, + ] + + def test_sync_508_members_skips_mailbox_when_any_crm_match_is_blocked() -> None: FakeMigaduClient.mailboxes = [ MigaduMailbox( From 32155a76b495acd6daf4dcb5a36f5bd80f554ea7 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 11 Jun 2026 09:45:43 +0800 Subject: [PATCH 11/14] Harden newsletter sync review surfaces --- apps/api/src/five08/backend/api.py | 4 +- apps/worker/src/five08/worker/jobs.py | 19 +++++++- packages/shared/src/five08/agent/tools.py | 12 ++++- packages/shared/src/five08/clients/brevo.py | 18 ++++++-- packages/shared/src/five08/clients/keila.py | 14 +++++- packages/shared/src/five08/clients/migadu.py | 15 ++++++- packages/shared/src/five08/newsletter_sync.py | 7 +-- tests/unit/test_agent_gateway.py | 33 ++++++++++++++ tests/unit/test_brevo_client.py | 45 +++++++++++++++++++ tests/unit/test_keila_client.py | 21 +++++++++ tests/unit/test_migadu_client.py | 25 +++++++++++ tests/unit/test_newsletter_sync.py | 26 +++++++++++ tests/unit/test_worker_newsletter_sync.py | 14 ++++-- 13 files changed, 236 insertions(+), 17 deletions(-) create mode 100644 tests/unit/test_migadu_client.py diff --git a/apps/api/src/five08/backend/api.py b/apps/api/src/five08/backend/api.py index 1444303d..4e80b4a9 100644 --- a/apps/api/src/five08/backend/api.py +++ b/apps/api/src/five08/backend/api.py @@ -415,7 +415,9 @@ def _get_agent_orchestrator() -> AgentOrchestrator: _AGENT_ORCHESTRATOR = AgentOrchestrator( registry=ToolRegistry( _AGENT_TASK_STORE, - runtime_config=ToolRuntimeConfig.from_settings(settings), + runtime_config_factory=lambda: ToolRuntimeConfig.from_settings( + settings + ), ), model_config=AgentModelConfig.from_settings(settings), intent_normalizer=OpenAICompatibleIntentNormalizer.from_settings( diff --git a/apps/worker/src/five08/worker/jobs.py b/apps/worker/src/five08/worker/jobs.py index d47d7efc..7a723dce 100644 --- a/apps/worker/src/five08/worker/jobs.py +++ b/apps/worker/src/five08/worker/jobs.py @@ -2,6 +2,7 @@ import base64 import logging +import re from datetime import datetime, timezone from email import message_from_bytes from collections.abc import Callable @@ -22,6 +23,7 @@ DOCUSEAL_COMPLETED_AT_UTC_FORMAT = "%Y-%m-%d %H:%M:%S" +EMAIL_PATTERN = re.compile(r"(? dict[str, Any]: @@ -170,8 +172,12 @@ def _mask_newsletter_sync_result(result: dict[str, Any]) -> dict[str, Any]: crm_failures = result.get("crm_lookup_failures") if isinstance(crm_failures, list): for failure in crm_failures: - if isinstance(failure, dict) and failure.get("mailbox"): + if not isinstance(failure, dict): + continue + if failure.get("mailbox"): failure["mailbox"] = mask_email(str(failure["mailbox"])) + if failure.get("error"): + failure["error"] = _mask_emails_in_text(str(failure["error"])) providers = result.get("providers") if isinstance(providers, dict): @@ -182,11 +188,20 @@ def _mask_newsletter_sync_result(result: dict[str, Any]) -> dict[str, Any]: if not isinstance(failures, list): continue for failure in failures: - if isinstance(failure, dict) and failure.get("email"): + if not isinstance(failure, dict): + continue + if failure.get("email"): failure["email"] = mask_email(str(failure["email"])) + if failure.get("error"): + failure["error"] = _mask_emails_in_text(str(failure["error"])) return result +def _mask_emails_in_text(text: str) -> str: + """Mask email-like substrings embedded in free-form error text.""" + return EMAIL_PATTERN.sub(lambda match: mask_email(match.group(0)), text) + + def sync_508_members_newsletters_job() -> dict[str, Any]: """Sync Migadu member emails into configured newsletter providers.""" logger.info("Processing 508 members newsletter sync job") diff --git a/packages/shared/src/five08/agent/tools.py b/packages/shared/src/five08/agent/tools.py index 96ef80d3..7930ef67 100644 --- a/packages/shared/src/five08/agent/tools.py +++ b/packages/shared/src/five08/agent/tools.py @@ -5,6 +5,7 @@ import itertools import re import threading +from collections.abc import Callable from dataclasses import dataclass, field from datetime import date, datetime, timezone from typing import Any @@ -308,10 +309,12 @@ def __init__( *, memory_store: MemoryStore | None = None, runtime_config: ToolRuntimeConfig | None = None, + runtime_config_factory: Callable[[], ToolRuntimeConfig] | None = None, ) -> None: self.task_store = task_store or InMemoryTaskStore() self.memory_store = memory_store or InMemoryMemoryStore() - self.runtime_config = runtime_config or ToolRuntimeConfig() + self._runtime_config = runtime_config or ToolRuntimeConfig() + self._runtime_config_factory = runtime_config_factory self._manifests = { "task_read.search_tasks": ToolManifest( name="task_read.search_tasks", @@ -472,6 +475,13 @@ def __init__( ), } + @property + def runtime_config(self) -> ToolRuntimeConfig: + """Return the current external-tool runtime config.""" + if self._runtime_config_factory is not None: + return self._runtime_config_factory() + return self._runtime_config + def get(self, tool_name: str) -> ToolManifest | None: return self._manifests.get(tool_name) diff --git a/packages/shared/src/five08/clients/brevo.py b/packages/shared/src/five08/clients/brevo.py index a348b4a7..37502462 100644 --- a/packages/shared/src/five08/clients/brevo.py +++ b/packages/shared/src/five08/clients/brevo.py @@ -8,12 +8,21 @@ import requests BREVO_API_BASE_URL = "https://api.brevo.com/v3" +ERROR_BODY_MAX_LENGTH = 500 class BrevoAPIError(RuntimeError): """Raised when the Brevo API request fails or returns invalid data.""" +def _response_body_excerpt(body: object) -> str: + """Return a bounded response-body excerpt for persisted/logged errors.""" + text = " ".join(str(body or "").split()) + if len(text) <= ERROR_BODY_MAX_LENGTH: + return text + return f"{text[:ERROR_BODY_MAX_LENGTH]}..." + + class BrevoClient: """Small Brevo API wrapper for newsletter contact subscriptions.""" @@ -65,7 +74,8 @@ def add_contact_to_list( if response.status_code not in {200, 201, 204}: raise BrevoAPIError( "Brevo contact subscription failed: " - f"status={response.status_code}, body={response.text}" + f"status={response.status_code}, " + f"body={_response_body_excerpt(response.text)}" ) if not response.content: @@ -103,7 +113,8 @@ def get_contact(self, email: str) -> dict[str, Any] | None: if response.status_code != 200: raise BrevoAPIError( "Brevo contact lookup failed: " - f"status={response.status_code}, body={response.text}" + f"status={response.status_code}, " + f"body={_response_body_excerpt(response.text)}" ) try: @@ -170,7 +181,8 @@ def _get_lists_page(self, *, limit: int, offset: int) -> dict[str, Any]: if response.status_code != 200: raise BrevoAPIError( "Brevo list lookup failed: " - f"status={response.status_code}, body={response.text}" + f"status={response.status_code}, " + f"body={_response_body_excerpt(response.text)}" ) try: diff --git a/packages/shared/src/five08/clients/keila.py b/packages/shared/src/five08/clients/keila.py index 8ccac97c..0b73ef5b 100644 --- a/packages/shared/src/five08/clients/keila.py +++ b/packages/shared/src/five08/clients/keila.py @@ -8,6 +8,7 @@ import requests KEILA_API_BASE_URL = "https://app.keila.io" +ERROR_BODY_MAX_LENGTH = 500 _EXISTING_CONTACT_UNSET = object() @@ -15,6 +16,14 @@ class KeilaAPIError(RuntimeError): """Raised when the Keila API request fails or returns invalid data.""" +def _response_body_excerpt(body: object) -> str: + """Return a bounded response-body excerpt for persisted/logged errors.""" + text = " ".join(str(body or "").split()) + if len(text) <= ERROR_BODY_MAX_LENGTH: + return text + return f"{text[:ERROR_BODY_MAX_LENGTH]}..." + + class KeilaClient: """Small Keila API wrapper for contact synchronization.""" @@ -32,6 +41,8 @@ def __init__( def get_contact_by_email(self, email: str) -> dict[str, Any] | None: """Return one Keila contact by email, or None when it does not exist.""" normalized_email = email.strip().lower() + if not normalized_email or normalized_email.count("@") != 1: + raise ValueError("Keila contact email must be a full email address.") response = self._request( "GET", f"/api/v1/contacts/{quote(normalized_email, safe='')}", @@ -129,7 +140,8 @@ def _request( if not 200 <= response.status_code < 300: raise KeilaAPIError( "Keila API request failed: " - f"status={response.status_code}, body={response.text}" + f"status={response.status_code}, " + f"body={_response_body_excerpt(response.text)}" ) if not response.content: return {} diff --git a/packages/shared/src/five08/clients/migadu.py b/packages/shared/src/five08/clients/migadu.py index e8b014e3..3ef0526e 100644 --- a/packages/shared/src/five08/clients/migadu.py +++ b/packages/shared/src/five08/clients/migadu.py @@ -8,12 +8,21 @@ import requests MIGADU_API_BASE_URL = "https://api.migadu.com/v1" +ERROR_BODY_MAX_LENGTH = 500 class MigaduAPIError(RuntimeError): """Raised when the Migadu API request fails or returns invalid data.""" +def _response_body_excerpt(body: object) -> str: + """Return a bounded response-body excerpt for persisted/logged errors.""" + text = " ".join(str(body or "").split()) + if len(text) <= ERROR_BODY_MAX_LENGTH: + return text + return f"{text[:ERROR_BODY_MAX_LENGTH]}..." + + def normalize_migadu_mailbox_domain(domain: str | None) -> str: """Normalize the configured Migadu mailbox domain.""" normalized = (domain or "508.dev").strip().lower().lstrip(".") @@ -81,7 +90,8 @@ def create_mailbox(self, request: MigaduMailboxCreateRequest) -> dict[str, Any]: if response.status_code not in {200, 201}: raise MigaduAPIError( "Migadu mailbox creation failed: " - f"status={response.status_code}, body={response.text}" + f"status={response.status_code}, " + f"body={_response_body_excerpt(response.text)}" ) try: @@ -108,7 +118,8 @@ def list_mailboxes(self) -> list[MigaduMailbox]: if response.status_code != 200: raise MigaduAPIError( "Migadu mailbox listing failed: " - f"status={response.status_code}, body={response.text}" + f"status={response.status_code}, " + f"body={_response_body_excerpt(response.text)}" ) try: diff --git a/packages/shared/src/five08/newsletter_sync.py b/packages/shared/src/five08/newsletter_sync.py index cc83f1b5..6b662345 100644 --- a/packages/shared/src/five08/newsletter_sync.py +++ b/packages/shared/src/five08/newsletter_sync.py @@ -133,6 +133,10 @@ def _list_id(self) -> int | None: return self._resolved_list_id def ensure_contact(self, contact: NewsletterContact) -> str: + list_id = self._list_id() + if list_id is None: + return "skipped_list_missing" + existing = self.client.get_contact(contact.email) if existing is not None and ( bool(existing.get("emailBlacklisted")) @@ -141,9 +145,6 @@ def ensure_contact(self, contact: NewsletterContact) -> str: ): return "skipped_provider_suppressed" - list_id = self._list_id() - if list_id is None: - return "skipped_list_missing" if existing is not None: if _contains_list_id(existing.get("listUnsubscribed"), list_id): return "skipped_provider_suppressed" diff --git a/tests/unit/test_agent_gateway.py b/tests/unit/test_agent_gateway.py index a889146b..c48d6d2c 100644 --- a/tests/unit/test_agent_gateway.py +++ b/tests/unit/test_agent_gateway.py @@ -2556,6 +2556,39 @@ def test_user_accounts_tool_subscribes_mailbox_and_backup_email_to_brevo( ] +def test_user_accounts_tool_reads_dynamic_newsletter_runtime_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fakes = _install_account_tool_fakes(monkeypatch) + runtime_values = {"brevo_api_key": None} + registry = ToolRegistry( + runtime_config_factory=lambda: _account_runtime_config( + brevo_api_key=runtime_values["brevo_api_key"] + ) + ) + runtime_values["brevo_api_key"] = "key" + + result = registry.execute( + "account_write.create_user_accounts", + {"contact_id": "contact-1", "mailbox_username": "jane@508.dev"}, + organization_id="org-1", + actor_id="123", + actor_scopes={ + "mailbox:create", + "user:manage", + "integration:manage", + "crm:contact:read", + "crm:contact:update", + }, + ) + + assert result["mailbox"]["newsletter_subscribed"] is True + assert fakes.brevo.subscriptions == [ + {"email": "jane@508.dev", "list_id": 4}, + {"email": "jane@example.com", "list_id": 4}, + ] + + def test_user_accounts_tool_reports_suppressed_newsletter_contact( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit/test_brevo_client.py b/tests/unit/test_brevo_client.py index a09ff855..994e2e2e 100644 --- a/tests/unit/test_brevo_client.py +++ b/tests/unit/test_brevo_client.py @@ -63,6 +63,23 @@ def test_add_contact_to_list_raises_on_request_error() -> None: ) +def test_add_contact_to_list_truncates_error_response_body() -> None: + response = Mock() + response.status_code = 400 + response.text = f"{'x' * 600} jane@example.com" + + with patch("five08.clients.brevo.requests.post", return_value=response): + with pytest.raises(BrevoAPIError) as exc_info: + BrevoClient(api_key="brevo-key").add_contact_to_list( + email="jane@example.com", + list_id=4, + ) + + message = str(exc_info.value) + assert "jane@example.com" not in message + assert len(message) < 600 + + def test_get_contact_fetches_contact_by_email() -> None: response = Mock() response.status_code = 200 @@ -111,6 +128,20 @@ def test_get_contact_rejects_invalid_email(email: str) -> None: BrevoClient(api_key="brevo-key").get_contact(email) +def test_get_contact_truncates_error_response_body() -> None: + response = Mock() + response.status_code = 500 + response.text = f"{'x' * 600} jane@example.com" + + with patch("five08.clients.brevo.requests.get", return_value=response): + with pytest.raises(BrevoAPIError) as exc_info: + BrevoClient(api_key="brevo-key").get_contact("jane@example.com") + + message = str(exc_info.value) + assert "jane@example.com" not in message + assert len(message) < 600 + + def test_find_list_id_by_name_gets_matching_list() -> None: response = Mock() response.status_code = 200 @@ -143,3 +174,17 @@ def test_find_list_id_by_name_returns_none_when_missing() -> None: list_id = BrevoClient(api_key="brevo-key").find_list_id_by_name("508 members") assert list_id is None + + +def test_find_list_id_by_name_truncates_error_response_body() -> None: + response = Mock() + response.status_code = 503 + response.text = f"{'x' * 600} jane@example.com" + + with patch("five08.clients.brevo.requests.get", return_value=response): + with pytest.raises(BrevoAPIError) as exc_info: + BrevoClient(api_key="brevo-key").find_list_id_by_name("508 members") + + message = str(exc_info.value) + assert "jane@example.com" not in message + assert len(message) < 600 diff --git a/tests/unit/test_keila_client.py b/tests/unit/test_keila_client.py index b0c4c25a..5353cc08 100644 --- a/tests/unit/test_keila_client.py +++ b/tests/unit/test_keila_client.py @@ -78,6 +78,12 @@ def test_get_contact_by_email_returns_none_for_missing_contact() -> None: assert result is None +@pytest.mark.parametrize("email", ["", "not-an-email"]) +def test_get_contact_by_email_rejects_invalid_email(email: str) -> None: + with pytest.raises(ValueError, match="full email address"): + KeilaClient(api_key="keila-key").get_contact_by_email(email) + + def test_upsert_active_contact_creates_missing_contact() -> None: missing = Mock() missing.status_code = 404 @@ -229,3 +235,18 @@ def test_keila_client_raises_on_request_error() -> None: ): with pytest.raises(KeilaAPIError, match="request failed"): KeilaClient(api_key="keila-key").get_contact_by_email("jane@example.com") + + +def test_keila_client_truncates_error_response_body() -> None: + response = Mock() + response.status_code = 500 + response.text = f"{'x' * 600} jane@example.com" + response.content = response.text.encode() + + with patch("five08.clients.keila.requests.request", return_value=response): + with pytest.raises(KeilaAPIError) as exc_info: + KeilaClient(api_key="keila-key").get_contact_by_email("jane@example.com") + + message = str(exc_info.value) + assert "jane@example.com" not in message + assert len(message) < 600 diff --git a/tests/unit/test_migadu_client.py b/tests/unit/test_migadu_client.py new file mode 100644 index 00000000..340edb8d --- /dev/null +++ b/tests/unit/test_migadu_client.py @@ -0,0 +1,25 @@ +"""Unit tests for the shared Migadu API client.""" + +from unittest.mock import Mock, patch + +import pytest + +from five08.clients.migadu import MigaduAPIError, MigaduClient + + +def test_list_mailboxes_truncates_error_response_body() -> None: + response = Mock() + response.status_code = 500 + response.text = f"{'x' * 600} jane@508.dev" + + with patch("five08.clients.migadu.requests.get", return_value=response): + with pytest.raises(MigaduAPIError) as exc_info: + MigaduClient( + username="migadu-user", + api_key="migadu-key", + domain="508.dev", + ).list_mailboxes() + + message = str(exc_info.value) + assert "jane@508.dev" not in message + assert len(message) < 600 diff --git a/tests/unit/test_newsletter_sync.py b/tests/unit/test_newsletter_sync.py index ba71c224..0e6740ba 100644 --- a/tests/unit/test_newsletter_sync.py +++ b/tests/unit/test_newsletter_sync.py @@ -39,6 +39,7 @@ class FakeBrevoClient: contacts: dict[str, dict[str, Any]] = {} subscriptions: list[dict[str, Any]] = [] list_lookup_names: list[str] = [] + contact_lookup_emails: list[str] = [] def __init__( self, @@ -52,6 +53,7 @@ def __init__( self.timeout_seconds = timeout_seconds def get_contact(self, email: str) -> dict[str, Any] | None: + self.contact_lookup_emails.append(email) return self.contacts.get(email) def add_contact_to_list(self, *, email: str, list_id: int) -> dict[str, Any]: @@ -130,6 +132,7 @@ def reset_fakes(monkeypatch: pytest.MonkeyPatch) -> None: FakeBrevoClient.contacts = {} FakeBrevoClient.subscriptions = [] FakeBrevoClient.list_lookup_names = [] + FakeBrevoClient.contact_lookup_emails = [] FakeKeilaClient.contacts = {} FakeKeilaClient.upserts = [] FakeKeilaClient.lookups = [] @@ -271,6 +274,29 @@ def test_sync_508_members_caches_brevo_list_lookup_by_name() -> None: assert FakeBrevoClient.list_lookup_names == ["508 members"] +def test_sync_508_members_skips_missing_brevo_list_before_contact_lookup() -> None: + FakeMigaduClient.mailboxes = [ + MigaduMailbox( + address="jane@508.dev", + name="Jane Doe", + password_recovery_email="jane@example.com", + ) + ] + + result = NewsletterSyncProcessor( + _settings( + brevo_508_members_newsletter_list_id=None, + brevo_508_members_newsletter_list_name="Missing list", + keila_api_key=None, + ) + ).sync_508_members() + + assert result["providers"]["brevo"]["synced"] == 0 + assert result["providers"]["brevo"]["statuses"] == {"skipped_list_missing": 2} + assert FakeBrevoClient.list_lookup_names == ["Missing list"] + assert FakeBrevoClient.contact_lookup_emails == [] + + def test_sync_508_members_avoids_duplicate_keila_contact_lookups() -> None: FakeMigaduClient.mailboxes = [ MigaduMailbox( diff --git a/tests/unit/test_worker_newsletter_sync.py b/tests/unit/test_worker_newsletter_sync.py index 852c7161..d3705884 100644 --- a/tests/unit/test_worker_newsletter_sync.py +++ b/tests/unit/test_worker_newsletter_sync.py @@ -15,14 +15,17 @@ def __init__(self, settings: object) -> None: def sync_508_members(self) -> dict[str, object]: return { "crm_lookup_failures": [ - {"mailbox": "jane@508.dev", "error": "CRM unavailable"} + { + "mailbox": "jane@508.dev", + "error": "CRM lookup failed for jane@508.dev", + } ], "providers": { "brevo": { "failures": [ { "email": "jane@example.com", - "error": "provider unavailable", + "error": "provider unavailable for jane@example.com", } ] }, @@ -40,14 +43,17 @@ def sync_508_members(self) -> dict[str, object]: assert result == { "crm_lookup_failures": [ - {"mailbox": "j***@5****...", "error": "CRM unavailable"} + { + "mailbox": "j***@5****...", + "error": "CRM lookup failed for j***@5****...", + } ], "providers": { "brevo": { "failures": [ { "email": "j***@e****...", - "error": "provider unavailable", + "error": "provider unavailable for j***@e****...", } ] }, From 0f33bbfdb0415556e7838b7d1ca965b8ef3be2bd Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 11 Jun 2026 11:09:48 +0800 Subject: [PATCH 12/14] Redact emails in provider error excerpts --- packages/shared/src/five08/clients/brevo.py | 4 +++- packages/shared/src/five08/clients/keila.py | 4 +++- packages/shared/src/five08/clients/migadu.py | 4 +++- packages/shared/src/five08/redaction.py | 14 ++++++++++++++ tests/unit/test_brevo_client.py | 9 ++++++--- tests/unit/test_keila_client.py | 3 ++- tests/unit/test_migadu_client.py | 3 ++- 7 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 packages/shared/src/five08/redaction.py diff --git a/packages/shared/src/five08/clients/brevo.py b/packages/shared/src/five08/clients/brevo.py index 37502462..d1dd654a 100644 --- a/packages/shared/src/five08/clients/brevo.py +++ b/packages/shared/src/five08/clients/brevo.py @@ -7,6 +7,8 @@ import requests +from five08.redaction import redact_email_addresses + BREVO_API_BASE_URL = "https://api.brevo.com/v3" ERROR_BODY_MAX_LENGTH = 500 @@ -17,7 +19,7 @@ class BrevoAPIError(RuntimeError): def _response_body_excerpt(body: object) -> str: """Return a bounded response-body excerpt for persisted/logged errors.""" - text = " ".join(str(body or "").split()) + text = redact_email_addresses(" ".join(str(body or "").split())) if len(text) <= ERROR_BODY_MAX_LENGTH: return text return f"{text[:ERROR_BODY_MAX_LENGTH]}..." diff --git a/packages/shared/src/five08/clients/keila.py b/packages/shared/src/five08/clients/keila.py index 0b73ef5b..b059d43b 100644 --- a/packages/shared/src/five08/clients/keila.py +++ b/packages/shared/src/five08/clients/keila.py @@ -7,6 +7,8 @@ import requests +from five08.redaction import redact_email_addresses + KEILA_API_BASE_URL = "https://app.keila.io" ERROR_BODY_MAX_LENGTH = 500 _EXISTING_CONTACT_UNSET = object() @@ -18,7 +20,7 @@ class KeilaAPIError(RuntimeError): def _response_body_excerpt(body: object) -> str: """Return a bounded response-body excerpt for persisted/logged errors.""" - text = " ".join(str(body or "").split()) + text = redact_email_addresses(" ".join(str(body or "").split())) if len(text) <= ERROR_BODY_MAX_LENGTH: return text return f"{text[:ERROR_BODY_MAX_LENGTH]}..." diff --git a/packages/shared/src/five08/clients/migadu.py b/packages/shared/src/five08/clients/migadu.py index 3ef0526e..5d41ae26 100644 --- a/packages/shared/src/five08/clients/migadu.py +++ b/packages/shared/src/five08/clients/migadu.py @@ -7,6 +7,8 @@ import requests +from five08.redaction import redact_email_addresses + MIGADU_API_BASE_URL = "https://api.migadu.com/v1" ERROR_BODY_MAX_LENGTH = 500 @@ -17,7 +19,7 @@ class MigaduAPIError(RuntimeError): def _response_body_excerpt(body: object) -> str: """Return a bounded response-body excerpt for persisted/logged errors.""" - text = " ".join(str(body or "").split()) + text = redact_email_addresses(" ".join(str(body or "").split())) if len(text) <= ERROR_BODY_MAX_LENGTH: return text return f"{text[:ERROR_BODY_MAX_LENGTH]}..." diff --git a/packages/shared/src/five08/redaction.py b/packages/shared/src/five08/redaction.py new file mode 100644 index 00000000..4a2a2cc3 --- /dev/null +++ b/packages/shared/src/five08/redaction.py @@ -0,0 +1,14 @@ +"""Small redaction helpers for persisted/logged diagnostic strings.""" + +from __future__ import annotations + +import re + +EMAIL_ADDRESS_PATTERN = re.compile( + r"(? str: + """Replace email-like substrings in text with a stable placeholder.""" + return EMAIL_ADDRESS_PATTERN.sub("[redacted-email]", text) diff --git a/tests/unit/test_brevo_client.py b/tests/unit/test_brevo_client.py index 994e2e2e..1a479ec0 100644 --- a/tests/unit/test_brevo_client.py +++ b/tests/unit/test_brevo_client.py @@ -66,7 +66,7 @@ def test_add_contact_to_list_raises_on_request_error() -> None: def test_add_contact_to_list_truncates_error_response_body() -> None: response = Mock() response.status_code = 400 - response.text = f"{'x' * 600} jane@example.com" + response.text = f"email=jane@example.com {'x' * 600}" with patch("five08.clients.brevo.requests.post", return_value=response): with pytest.raises(BrevoAPIError) as exc_info: @@ -77,6 +77,7 @@ def test_add_contact_to_list_truncates_error_response_body() -> None: message = str(exc_info.value) assert "jane@example.com" not in message + assert "[redacted-email]" in message assert len(message) < 600 @@ -131,7 +132,7 @@ def test_get_contact_rejects_invalid_email(email: str) -> None: def test_get_contact_truncates_error_response_body() -> None: response = Mock() response.status_code = 500 - response.text = f"{'x' * 600} jane@example.com" + response.text = f"email=jane@example.com {'x' * 600}" with patch("five08.clients.brevo.requests.get", return_value=response): with pytest.raises(BrevoAPIError) as exc_info: @@ -139,6 +140,7 @@ def test_get_contact_truncates_error_response_body() -> None: message = str(exc_info.value) assert "jane@example.com" not in message + assert "[redacted-email]" in message assert len(message) < 600 @@ -179,7 +181,7 @@ def test_find_list_id_by_name_returns_none_when_missing() -> None: def test_find_list_id_by_name_truncates_error_response_body() -> None: response = Mock() response.status_code = 503 - response.text = f"{'x' * 600} jane@example.com" + response.text = f"email=jane@example.com {'x' * 600}" with patch("five08.clients.brevo.requests.get", return_value=response): with pytest.raises(BrevoAPIError) as exc_info: @@ -187,4 +189,5 @@ def test_find_list_id_by_name_truncates_error_response_body() -> None: message = str(exc_info.value) assert "jane@example.com" not in message + assert "[redacted-email]" in message assert len(message) < 600 diff --git a/tests/unit/test_keila_client.py b/tests/unit/test_keila_client.py index 5353cc08..a2aa1fe5 100644 --- a/tests/unit/test_keila_client.py +++ b/tests/unit/test_keila_client.py @@ -240,7 +240,7 @@ def test_keila_client_raises_on_request_error() -> None: def test_keila_client_truncates_error_response_body() -> None: response = Mock() response.status_code = 500 - response.text = f"{'x' * 600} jane@example.com" + response.text = f"email=jane@example.com {'x' * 600}" response.content = response.text.encode() with patch("five08.clients.keila.requests.request", return_value=response): @@ -249,4 +249,5 @@ def test_keila_client_truncates_error_response_body() -> None: message = str(exc_info.value) assert "jane@example.com" not in message + assert "[redacted-email]" in message assert len(message) < 600 diff --git a/tests/unit/test_migadu_client.py b/tests/unit/test_migadu_client.py index 340edb8d..b1f7063c 100644 --- a/tests/unit/test_migadu_client.py +++ b/tests/unit/test_migadu_client.py @@ -10,7 +10,7 @@ def test_list_mailboxes_truncates_error_response_body() -> None: response = Mock() response.status_code = 500 - response.text = f"{'x' * 600} jane@508.dev" + response.text = f"email=jane@508.dev {'x' * 600}" with patch("five08.clients.migadu.requests.get", return_value=response): with pytest.raises(MigaduAPIError) as exc_info: @@ -22,4 +22,5 @@ def test_list_mailboxes_truncates_error_response_body() -> None: message = str(exc_info.value) assert "jane@508.dev" not in message + assert "[redacted-email]" in message assert len(message) < 600 From aeb4ac0b18c3c36f9fc9b53c632e4ae08b6ee946 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 14 Jun 2026 23:49:44 +0900 Subject: [PATCH 13/14] Harden newsletter sync PII masking --- apps/api/src/five08/backend/api.py | 2 +- apps/worker/src/five08/worker/jobs.py | 13 +++++++-- packages/shared/src/five08/newsletter_sync.py | 3 +- packages/shared/src/five08/redaction.py | 5 ++++ tests/unit/test_backend_api.py | 3 ++ tests/unit/test_newsletter_sync.py | 28 +++++++++++++++++++ tests/unit/test_worker_newsletter_sync.py | 7 +++-- 7 files changed, 54 insertions(+), 7 deletions(-) diff --git a/apps/api/src/five08/backend/api.py b/apps/api/src/five08/backend/api.py index a3d5acb2..3ca9746c 100644 --- a/apps/api/src/five08/backend/api.py +++ b/apps/api/src/five08/backend/api.py @@ -6683,7 +6683,7 @@ async def dashboard_sync_newsletters_handler(request: Request) -> JSONResponse: "queue": settings.redis_queue_name, "job_type": "sync_508_members_newsletters_job", "reason": "dashboard", - "idempotency_key_pattern": "newsletter-sync:508-members:dashboard:", + "idempotency_key_pattern": "newsletter-sync:508-members:dashboard::", }, } ) diff --git a/apps/worker/src/five08/worker/jobs.py b/apps/worker/src/five08/worker/jobs.py index 7a723dce..3c876683 100644 --- a/apps/worker/src/five08/worker/jobs.py +++ b/apps/worker/src/five08/worker/jobs.py @@ -2,12 +2,16 @@ import base64 import logging -import re from datetime import datetime, timezone from email import message_from_bytes from collections.abc import Callable from typing import Any +from urllib.parse import unquote +from five08.redaction import ( + EMAIL_ADDRESS_PATTERN, + PERCENT_ENCODED_EMAIL_ADDRESS_PATTERN, +) from five08.worker.config import settings from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor from five08.worker.crm.intake_form_processor import IntakeFormProcessor @@ -23,7 +27,6 @@ DOCUSEAL_COMPLETED_AT_UTC_FORMAT = "%Y-%m-%d %H:%M:%S" -EMAIL_PATTERN = re.compile(r"(? dict[str, Any]: @@ -199,7 +202,11 @@ def _mask_newsletter_sync_result(result: dict[str, Any]) -> dict[str, Any]: def _mask_emails_in_text(text: str) -> str: """Mask email-like substrings embedded in free-form error text.""" - return EMAIL_PATTERN.sub(lambda match: mask_email(match.group(0)), text) + text = PERCENT_ENCODED_EMAIL_ADDRESS_PATTERN.sub( + lambda match: mask_email(unquote(match.group(0))), + text, + ) + return EMAIL_ADDRESS_PATTERN.sub(lambda match: mask_email(match.group(0)), text) def sync_508_members_newsletters_job() -> dict[str, Any]: diff --git a/packages/shared/src/five08/newsletter_sync.py b/packages/shared/src/five08/newsletter_sync.py index 6b662345..3846ac7c 100644 --- a/packages/shared/src/five08/newsletter_sync.py +++ b/packages/shared/src/five08/newsletter_sync.py @@ -9,6 +9,7 @@ from five08.clients.espo import EspoAPIError, EspoClient from five08.clients.keila import KeilaClient from five08.clients.migadu import MigaduClient, MigaduMailbox +from five08.redaction import redact_email_addresses CRM_BLOCKED_TYPES = {"inactive member", "rejected", "blocked"} CRM_BLOCKED_ONBOARDING_STATES = {"rejected", "waitlist"} @@ -432,7 +433,7 @@ def format_newsletter_sync_warning(result: dict[str, Any]) -> str | None: failures = provider_result.get("failures") if failed and isinstance(failures, list) and failures: detail = "; ".join( - f"{item.get('email')}: {item.get('error')}" + redact_email_addresses(str(item.get("error") or "unknown error")) for item in failures[:3] if isinstance(item, dict) ) diff --git a/packages/shared/src/five08/redaction.py b/packages/shared/src/five08/redaction.py index 4a2a2cc3..9cf2d7b8 100644 --- a/packages/shared/src/five08/redaction.py +++ b/packages/shared/src/five08/redaction.py @@ -7,8 +7,13 @@ EMAIL_ADDRESS_PATTERN = re.compile( r"(? str: """Replace email-like substrings in text with a stable placeholder.""" + text = PERCENT_ENCODED_EMAIL_ADDRESS_PATTERN.sub("[redacted-email]", text) return EMAIL_ADDRESS_PATTERN.sub("[redacted-email]", text) diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py index e9031f10..93530a0a 100644 --- a/tests/unit/test_backend_api.py +++ b/tests/unit/test_backend_api.py @@ -7039,6 +7039,9 @@ def test_dashboard_sync_newsletters_workflows_engineer_is_dry_run( assert response.json()["would_enqueue"]["job_type"] == ( "sync_508_members_newsletters_job" ) + assert response.json()["would_enqueue"]["idempotency_key_pattern"] == ( + "newsletter-sync:508-members:dashboard::" + ) mock_enqueue.assert_not_called() mock_insert.assert_not_called() diff --git a/tests/unit/test_newsletter_sync.py b/tests/unit/test_newsletter_sync.py index 0e6740ba..d3de09c7 100644 --- a/tests/unit/test_newsletter_sync.py +++ b/tests/unit/test_newsletter_sync.py @@ -429,6 +429,34 @@ def test_format_newsletter_sync_warning_reports_suppressed_skips() -> None: assert warning == "brevo skipped 1 suppressed contact(s)" +def test_format_newsletter_sync_warning_redacts_failure_emails() -> None: + warning = format_newsletter_sync_warning( + { + "providers": { + "keila": { + "failed": 1, + "failures": [ + { + "email": "jane@example.com", + "error": ( + "lookup failed for jane@example.com at " + "/contacts/jane%40example.com" + ), + } + ], + } + } + } + ) + + assert warning == ( + "keila failed for 1 contact(s): lookup failed for [redacted-email] " + "at /contacts/[redacted-email]" + ) + assert "jane@example.com" not in warning + assert "jane%40example.com" not in warning + + def test_sync_newsletter_contacts_uses_first_email_as_default_mailbox_pointer() -> None: result = sync_newsletter_contacts( _settings(brevo_api_key=None), diff --git a/tests/unit/test_worker_newsletter_sync.py b/tests/unit/test_worker_newsletter_sync.py index d3705884..1aa6324d 100644 --- a/tests/unit/test_worker_newsletter_sync.py +++ b/tests/unit/test_worker_newsletter_sync.py @@ -25,7 +25,10 @@ def sync_508_members(self) -> dict[str, object]: "failures": [ { "email": "jane@example.com", - "error": "provider unavailable for jane@example.com", + "error": ( + "provider unavailable for /contacts/" + "jane%40example.com" + ), } ] }, @@ -53,7 +56,7 @@ def sync_508_members(self) -> dict[str, object]: "failures": [ { "email": "j***@e****...", - "error": "provider unavailable for j***@e****...", + "error": ("provider unavailable for /contacts/j***@e****..."), } ] }, From 9786246af9dbda60c8f8b495df3cbff2a06caa09 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 15 Jun 2026 15:50:52 +0900 Subject: [PATCH 14/14] Tighten newsletter provider contact handling --- packages/shared/src/five08/clients/brevo.py | 9 ++--- .../src/five08/clients/contact_email.py | 15 ++++++++ packages/shared/src/five08/clients/keila.py | 36 +++++++++++++------ packages/shared/src/five08/newsletter_sync.py | 2 +- tests/unit/test_brevo_client.py | 8 ++++- tests/unit/test_keila_client.py | 14 ++++++-- 6 files changed, 64 insertions(+), 20 deletions(-) create mode 100644 packages/shared/src/five08/clients/contact_email.py diff --git a/packages/shared/src/five08/clients/brevo.py b/packages/shared/src/five08/clients/brevo.py index d1dd654a..71eecdab 100644 --- a/packages/shared/src/five08/clients/brevo.py +++ b/packages/shared/src/five08/clients/brevo.py @@ -7,6 +7,7 @@ import requests +from five08.clients.contact_email import normalize_provider_contact_email from five08.redaction import redact_email_addresses BREVO_API_BASE_URL = "https://api.brevo.com/v3" @@ -46,9 +47,7 @@ def add_contact_to_list( list_id: int, ) -> dict[str, Any]: """Create or update one Brevo contact and add it to a list.""" - normalized_email = email.strip().lower() - if not normalized_email or normalized_email.count("@") != 1: - raise ValueError("Brevo contact email must be a full email address.") + normalized_email = normalize_provider_contact_email(email, "Brevo") if list_id <= 0: raise ValueError("Brevo list ID must be a positive integer.") @@ -94,9 +93,7 @@ def add_contact_to_list( def get_contact(self, email: str) -> dict[str, Any] | None: """Return one Brevo contact by email, or None when it does not exist.""" - normalized_email = email.strip().lower() - if not normalized_email or normalized_email.count("@") != 1: - raise ValueError("Brevo contact email must be a full email address.") + normalized_email = normalize_provider_contact_email(email, "Brevo") headers = { "Accept": "application/json", "api-key": self.api_key, diff --git a/packages/shared/src/five08/clients/contact_email.py b/packages/shared/src/five08/clients/contact_email.py new file mode 100644 index 00000000..1c064594 --- /dev/null +++ b/packages/shared/src/five08/clients/contact_email.py @@ -0,0 +1,15 @@ +"""Shared contact email validation for provider clients.""" + +from __future__ import annotations + +from five08.onboarding_email import validate_plain_email + + +def normalize_provider_contact_email(value: str, provider_name: str) -> str: + """Normalize a provider contact email after full-address validation.""" + try: + return validate_plain_email(value, "contact email").lower() + except ValueError as exc: + raise ValueError( + f"{provider_name} contact email must be a full email address." + ) from exc diff --git a/packages/shared/src/five08/clients/keila.py b/packages/shared/src/five08/clients/keila.py index b059d43b..72684c84 100644 --- a/packages/shared/src/five08/clients/keila.py +++ b/packages/shared/src/five08/clients/keila.py @@ -7,6 +7,7 @@ import requests +from five08.clients.contact_email import normalize_provider_contact_email from five08.redaction import redact_email_addresses KEILA_API_BASE_URL = "https://app.keila.io" @@ -42,9 +43,7 @@ def __init__( def get_contact_by_email(self, email: str) -> dict[str, Any] | None: """Return one Keila contact by email, or None when it does not exist.""" - normalized_email = email.strip().lower() - if not normalized_email or normalized_email.count("@") != 1: - raise ValueError("Keila contact email must be a full email address.") + normalized_email = normalize_provider_contact_email(email, "Keila") response = self._request( "GET", f"/api/v1/contacts/{quote(normalized_email, safe='')}", @@ -63,9 +62,7 @@ def upsert_active_contact( existing_contact: dict[str, Any] | None | object = _EXISTING_CONTACT_UNSET, ) -> dict[str, Any]: """Create or update a Keila contact without changing suppressed statuses.""" - normalized_email = email.strip().lower() - if not normalized_email or normalized_email.count("@") != 1: - raise ValueError("Keila contact email must be a full email address.") + normalized_email = normalize_provider_contact_email(email, "Keila") payload: dict[str, Any] = { "email": normalized_email, @@ -95,10 +92,7 @@ def upsert_active_contact( ) contact_id = str(contact_id_value) existing_data = existing.get("data") - payload["data"] = { - **(existing_data if isinstance(existing_data, dict) else {}), - **(data or {}), - } + payload["data"] = _merge_contact_data(existing_data, data or {}) payload.pop("status", None) return ( self._request( @@ -157,3 +151,25 @@ def _request( if isinstance(nested, dict): return nested return data + + +def _merge_contact_data( + existing_data: object, + new_data: dict[str, Any], +) -> dict[str, Any]: + """Merge Keila contact data while preserving existing audience tags.""" + existing = existing_data if isinstance(existing_data, dict) else {} + merged = {**existing, **new_data} + existing_audiences = existing.get("audiences") + new_audiences = new_data.get("audiences") + if isinstance(existing_audiences, list) and isinstance(new_audiences, list): + merged["audiences"] = _unique_values([*existing_audiences, *new_audiences]) + return merged + + +def _unique_values(values: list[Any]) -> list[Any]: + unique: list[Any] = [] + for value in values: + if value not in unique: + unique.append(value) + return unique diff --git a/packages/shared/src/five08/newsletter_sync.py b/packages/shared/src/five08/newsletter_sync.py index 3846ac7c..623ccaea 100644 --- a/packages/shared/src/five08/newsletter_sync.py +++ b/packages/shared/src/five08/newsletter_sync.py @@ -36,7 +36,7 @@ class NewsletterProvider(Protocol): name: str def ensure_contact(self, contact: NewsletterContact) -> str: - """Ensure contact exists, returning added/updated/already/skipped.""" + """Ensure contact exists, returning a sync status key.""" def _split_name(full_name: str) -> tuple[str | None, str | None]: diff --git a/tests/unit/test_brevo_client.py b/tests/unit/test_brevo_client.py index 1a479ec0..36c34279 100644 --- a/tests/unit/test_brevo_client.py +++ b/tests/unit/test_brevo_client.py @@ -123,12 +123,18 @@ def test_get_contact_returns_none_for_missing_contact() -> None: assert result is None -@pytest.mark.parametrize("email", ["", "not-an-email"]) +@pytest.mark.parametrize("email", ["", "not-an-email", "a@b", "jane @example.com"]) def test_get_contact_rejects_invalid_email(email: str) -> None: with pytest.raises(ValueError, match="full email address"): BrevoClient(api_key="brevo-key").get_contact(email) +@pytest.mark.parametrize("email", ["", "not-an-email", "a@b", "jane @example.com"]) +def test_add_contact_to_list_rejects_invalid_email(email: str) -> None: + with pytest.raises(ValueError, match="full email address"): + BrevoClient(api_key="brevo-key").add_contact_to_list(email=email, list_id=4) + + def test_get_contact_truncates_error_response_body() -> None: response = Mock() response.status_code = 500 diff --git a/tests/unit/test_keila_client.py b/tests/unit/test_keila_client.py index a2aa1fe5..077fd26c 100644 --- a/tests/unit/test_keila_client.py +++ b/tests/unit/test_keila_client.py @@ -78,12 +78,22 @@ def test_get_contact_by_email_returns_none_for_missing_contact() -> None: assert result is None -@pytest.mark.parametrize("email", ["", "not-an-email"]) +@pytest.mark.parametrize("email", ["", "not-an-email", "a@b", "jane @example.com"]) def test_get_contact_by_email_rejects_invalid_email(email: str) -> None: with pytest.raises(ValueError, match="full email address"): KeilaClient(api_key="keila-key").get_contact_by_email(email) +@pytest.mark.parametrize("email", ["", "not-an-email", "a@b", "jane @example.com"]) +def test_upsert_active_contact_rejects_invalid_email(email: str) -> None: + with pytest.raises(ValueError, match="full email address"): + KeilaClient(api_key="keila-key").upsert_active_contact( + email=email, + data={"audiences": ["508_members"]}, + existing_contact=None, + ) + + def test_upsert_active_contact_creates_missing_contact() -> None: missing = Mock() missing.status_code = 404 @@ -180,7 +190,7 @@ def test_upsert_active_contact_preserves_existing_contact_data() -> None: "email": "jane@example.com", "data": { "form": "member-intake", - "audiences": ["508_members"], + "audiences": ["old", "508_members"], "mailbox_email": "jane@example.com", }, }