diff --git a/.gitignore b/.gitignore index 416cc24..aae1f7d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ data/survey_screenshots/* !data/survey_screenshots/.gitkeep config/user.yaml config/.backup-* +config/integrations/*.yaml +!config/integrations/*.yaml.example diff --git a/config/integrations/airtable.yaml.example b/config/integrations/airtable.yaml.example new file mode 100644 index 0000000..ce30a98 --- /dev/null +++ b/config/integrations/airtable.yaml.example @@ -0,0 +1,3 @@ +api_key: "patXXX..." +base_id: "appXXX..." +table_name: "Jobs" diff --git a/config/integrations/apple_calendar.yaml.example b/config/integrations/apple_calendar.yaml.example new file mode 100644 index 0000000..df7c60f --- /dev/null +++ b/config/integrations/apple_calendar.yaml.example @@ -0,0 +1,4 @@ +caldav_url: "https://caldav.icloud.com/" +username: "you@icloud.com" +app_password: "xxxx-xxxx-xxxx-xxxx" +calendar_name: "Interviews" diff --git a/config/integrations/discord.yaml.example b/config/integrations/discord.yaml.example new file mode 100644 index 0000000..5cd0511 --- /dev/null +++ b/config/integrations/discord.yaml.example @@ -0,0 +1 @@ +webhook_url: "https://discord.com/api/webhooks/..." diff --git a/config/integrations/dropbox.yaml.example b/config/integrations/dropbox.yaml.example new file mode 100644 index 0000000..4cba76d --- /dev/null +++ b/config/integrations/dropbox.yaml.example @@ -0,0 +1,2 @@ +access_token: "sl...." +folder_path: "/Peregrine" diff --git a/config/integrations/google_calendar.yaml.example b/config/integrations/google_calendar.yaml.example new file mode 100644 index 0000000..060f1fa --- /dev/null +++ b/config/integrations/google_calendar.yaml.example @@ -0,0 +1,2 @@ +calendar_id: "primary" +credentials_json: "~/credentials/google-calendar-sa.json" diff --git a/config/integrations/google_drive.yaml.example b/config/integrations/google_drive.yaml.example new file mode 100644 index 0000000..7ab96b4 --- /dev/null +++ b/config/integrations/google_drive.yaml.example @@ -0,0 +1,2 @@ +folder_id: "your-google-drive-folder-id" +credentials_json: "~/credentials/google-drive-sa.json" diff --git a/config/integrations/google_sheets.yaml.example b/config/integrations/google_sheets.yaml.example new file mode 100644 index 0000000..977c60e --- /dev/null +++ b/config/integrations/google_sheets.yaml.example @@ -0,0 +1,3 @@ +spreadsheet_id: "your-spreadsheet-id" +sheet_name: "Jobs" +credentials_json: "~/credentials/google-sheets-sa.json" diff --git a/config/integrations/home_assistant.yaml.example b/config/integrations/home_assistant.yaml.example new file mode 100644 index 0000000..95dd5ac --- /dev/null +++ b/config/integrations/home_assistant.yaml.example @@ -0,0 +1,3 @@ +base_url: "http://homeassistant.local:8123" +token: "eyJ0eXAiOiJKV1Qi..." +notification_service: "notify.mobile_app_my_phone" diff --git a/config/integrations/mega.yaml.example b/config/integrations/mega.yaml.example new file mode 100644 index 0000000..270ed58 --- /dev/null +++ b/config/integrations/mega.yaml.example @@ -0,0 +1,3 @@ +email: "you@example.com" +password: "your-mega-password" +folder_path: "/Peregrine" diff --git a/config/integrations/nextcloud.yaml.example b/config/integrations/nextcloud.yaml.example new file mode 100644 index 0000000..b71aa75 --- /dev/null +++ b/config/integrations/nextcloud.yaml.example @@ -0,0 +1,4 @@ +host: "https://nextcloud.example.com" +username: "your-username" +password: "your-app-password" +folder_path: "/Peregrine" diff --git a/config/integrations/notion.yaml.example b/config/integrations/notion.yaml.example new file mode 100644 index 0000000..b2e42e0 --- /dev/null +++ b/config/integrations/notion.yaml.example @@ -0,0 +1,2 @@ +token: "secret_..." +database_id: "32-character-notion-db-id" diff --git a/config/integrations/onedrive.yaml.example b/config/integrations/onedrive.yaml.example new file mode 100644 index 0000000..def5c7f --- /dev/null +++ b/config/integrations/onedrive.yaml.example @@ -0,0 +1,3 @@ +client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +client_secret: "your-client-secret" +folder_path: "/Peregrine" diff --git a/config/integrations/slack.yaml.example b/config/integrations/slack.yaml.example new file mode 100644 index 0000000..cf64b15 --- /dev/null +++ b/config/integrations/slack.yaml.example @@ -0,0 +1,2 @@ +webhook_url: "https://hooks.slack.com/services/..." +channel: "#job-alerts" diff --git a/scripts/integrations/airtable.py b/scripts/integrations/airtable.py new file mode 100644 index 0000000..e9d8e3f --- /dev/null +++ b/scripts/integrations/airtable.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from scripts.integrations.base import IntegrationBase + + +class AirtableIntegration(IntegrationBase): + name = "airtable" + label = "Airtable" + tier = "paid" + + def __init__(self): + self._config: dict = {} + + def fields(self) -> list[dict]: + return [ + {"key": "api_key", "label": "Personal Access Token", "type": "password", + "placeholder": "patXXX…", "required": True, + "help": "airtable.com/create/tokens"}, + {"key": "base_id", "label": "Base ID", "type": "text", + "placeholder": "appXXX…", "required": True, + "help": "From the API docs URL"}, + {"key": "table_name", "label": "Table name", "type": "text", + "placeholder": "Jobs", "required": True, + "help": ""}, + ] + + def connect(self, config: dict) -> bool: + self._config = config + return bool(config.get("api_key") and config.get("base_id")) + + def test(self) -> bool: + try: + import requests + r = requests.get( + f"https://api.airtable.com/v0/{self._config['base_id']}/{self._config.get('table_name', '')}", + headers={"Authorization": f"Bearer {self._config['api_key']}"}, + params={"maxRecords": 1}, + timeout=8, + ) + return r.status_code == 200 + except Exception: + return False diff --git a/scripts/integrations/apple_calendar.py b/scripts/integrations/apple_calendar.py new file mode 100644 index 0000000..71f9d17 --- /dev/null +++ b/scripts/integrations/apple_calendar.py @@ -0,0 +1,48 @@ +from __future__ import annotations +from scripts.integrations.base import IntegrationBase + + +class AppleCalendarIntegration(IntegrationBase): + name = "apple_calendar" + label = "Apple Calendar (CalDAV)" + tier = "paid" + + def __init__(self): + self._config: dict = {} + + def fields(self) -> list[dict]: + return [ + {"key": "caldav_url", "label": "CalDAV URL", "type": "url", + "placeholder": "https://caldav.icloud.com/", "required": True, + "help": "iCloud: https://caldav.icloud.com/ | self-hosted: your server URL"}, + {"key": "username", "label": "Apple ID / username", "type": "text", + "placeholder": "you@icloud.com", "required": True, + "help": ""}, + {"key": "app_password", "label": "App-Specific Password", "type": "password", + "placeholder": "xxxx-xxxx-xxxx-xxxx", "required": True, + "help": "appleid.apple.com → Security → App-Specific Passwords → Generate"}, + {"key": "calendar_name", "label": "Calendar name", "type": "text", + "placeholder": "Interviews", "required": True, + "help": "Name of the calendar to write interview events to"}, + ] + + def connect(self, config: dict) -> bool: + self._config = config + return bool( + config.get("caldav_url") and + config.get("username") and + config.get("app_password") + ) + + def test(self) -> bool: + try: + import caldav + client = caldav.DAVClient( + url=self._config["caldav_url"], + username=self._config["username"], + password=self._config["app_password"], + ) + principal = client.principal() + return principal is not None + except Exception: + return False diff --git a/scripts/integrations/discord.py b/scripts/integrations/discord.py new file mode 100644 index 0000000..2f80a61 --- /dev/null +++ b/scripts/integrations/discord.py @@ -0,0 +1,34 @@ +from __future__ import annotations +from scripts.integrations.base import IntegrationBase + + +class DiscordIntegration(IntegrationBase): + name = "discord" + label = "Discord (webhook)" + tier = "free" + + def __init__(self): + self._config: dict = {} + + def fields(self) -> list[dict]: + return [ + {"key": "webhook_url", "label": "Webhook URL", "type": "url", + "placeholder": "https://discord.com/api/webhooks/…", "required": True, + "help": "Server Settings → Integrations → Webhooks → New Webhook → Copy URL"}, + ] + + def connect(self, config: dict) -> bool: + self._config = config + return bool(config.get("webhook_url")) + + def test(self) -> bool: + try: + import requests + r = requests.post( + self._config["webhook_url"], + json={"content": "Peregrine connected successfully."}, + timeout=8, + ) + return r.status_code in (200, 204) + except Exception: + return False diff --git a/scripts/integrations/dropbox.py b/scripts/integrations/dropbox.py new file mode 100644 index 0000000..d6c0d60 --- /dev/null +++ b/scripts/integrations/dropbox.py @@ -0,0 +1,37 @@ +from __future__ import annotations +from scripts.integrations.base import IntegrationBase + + +class DropboxIntegration(IntegrationBase): + name = "dropbox" + label = "Dropbox" + tier = "free" + + def __init__(self): + self._config: dict = {} + + def fields(self) -> list[dict]: + return [ + {"key": "access_token", "label": "Access Token", "type": "password", + "placeholder": "sl.…", "required": True, + "help": "dropbox.com/developers/apps → App Console → Generate access token"}, + {"key": "folder_path", "label": "Folder path", "type": "text", + "placeholder": "/Peregrine", "required": True, + "help": "Dropbox folder path where resumes/cover letters will be stored"}, + ] + + def connect(self, config: dict) -> bool: + self._config = config + return bool(config.get("access_token")) + + def test(self) -> bool: + try: + import requests + r = requests.post( + "https://api.dropboxapi.com/2/users/get_current_account", + headers={"Authorization": f"Bearer {self._config['access_token']}"}, + timeout=8, + ) + return r.status_code == 200 + except Exception: + return False diff --git a/scripts/integrations/google_calendar.py b/scripts/integrations/google_calendar.py new file mode 100644 index 0000000..cd2c634 --- /dev/null +++ b/scripts/integrations/google_calendar.py @@ -0,0 +1,31 @@ +from __future__ import annotations +import os +from scripts.integrations.base import IntegrationBase + + +class GoogleCalendarIntegration(IntegrationBase): + name = "google_calendar" + label = "Google Calendar" + tier = "paid" + + def __init__(self): + self._config: dict = {} + + def fields(self) -> list[dict]: + return [ + {"key": "calendar_id", "label": "Calendar ID", "type": "text", + "placeholder": "primary or xxxxx@group.calendar.google.com", "required": True, + "help": "Settings → Calendars → [name] → Integrate calendar → Calendar ID"}, + {"key": "credentials_json", "label": "Service Account JSON path", "type": "text", + "placeholder": "~/credentials/google-calendar-sa.json", "required": True, + "help": "Download from Google Cloud Console → Service Accounts → Keys"}, + ] + + def connect(self, config: dict) -> bool: + self._config = config + return bool(config.get("calendar_id") and config.get("credentials_json")) + + def test(self) -> bool: + # TODO: use google-api-python-client calendars().get() + creds = os.path.expanduser(self._config.get("credentials_json", "")) + return os.path.exists(creds) diff --git a/scripts/integrations/google_drive.py b/scripts/integrations/google_drive.py new file mode 100644 index 0000000..1d2cc00 --- /dev/null +++ b/scripts/integrations/google_drive.py @@ -0,0 +1,31 @@ +from __future__ import annotations +import os +from scripts.integrations.base import IntegrationBase + + +class GoogleDriveIntegration(IntegrationBase): + name = "google_drive" + label = "Google Drive" + tier = "free" + + def __init__(self): + self._config: dict = {} + + def fields(self) -> list[dict]: + return [ + {"key": "folder_id", "label": "Folder ID", "type": "text", + "placeholder": "Paste the folder ID from the Drive URL", "required": True, + "help": "Open the folder in Drive → copy the ID from the URL after /folders/"}, + {"key": "credentials_json", "label": "Service Account JSON path", "type": "text", + "placeholder": "~/credentials/google-drive-sa.json", "required": True, + "help": "Download from Google Cloud Console → Service Accounts → Keys"}, + ] + + def connect(self, config: dict) -> bool: + self._config = config + return bool(config.get("folder_id") and config.get("credentials_json")) + + def test(self) -> bool: + # TODO: use google-api-python-client to list the folder + creds = os.path.expanduser(self._config.get("credentials_json", "")) + return os.path.exists(creds) diff --git a/scripts/integrations/google_sheets.py b/scripts/integrations/google_sheets.py new file mode 100644 index 0000000..656ad7f --- /dev/null +++ b/scripts/integrations/google_sheets.py @@ -0,0 +1,34 @@ +from __future__ import annotations +import os +from scripts.integrations.base import IntegrationBase + + +class GoogleSheetsIntegration(IntegrationBase): + name = "google_sheets" + label = "Google Sheets" + tier = "paid" + + def __init__(self): + self._config: dict = {} + + def fields(self) -> list[dict]: + return [ + {"key": "spreadsheet_id", "label": "Spreadsheet ID", "type": "text", + "placeholder": "From the URL: /d//edit", "required": True, + "help": ""}, + {"key": "sheet_name", "label": "Sheet name", "type": "text", + "placeholder": "Jobs", "required": True, + "help": "Name of the tab to write to"}, + {"key": "credentials_json", "label": "Service Account JSON path", "type": "text", + "placeholder": "~/credentials/google-sheets-sa.json", "required": True, + "help": "Download from Google Cloud Console → Service Accounts → Keys"}, + ] + + def connect(self, config: dict) -> bool: + self._config = config + return bool(config.get("spreadsheet_id") and config.get("credentials_json")) + + def test(self) -> bool: + # TODO: use gspread to open_by_key() + creds = os.path.expanduser(self._config.get("credentials_json", "")) + return os.path.exists(creds) diff --git a/scripts/integrations/home_assistant.py b/scripts/integrations/home_assistant.py new file mode 100644 index 0000000..3ed7922 --- /dev/null +++ b/scripts/integrations/home_assistant.py @@ -0,0 +1,40 @@ +from __future__ import annotations +from scripts.integrations.base import IntegrationBase + + +class HomeAssistantIntegration(IntegrationBase): + name = "home_assistant" + label = "Home Assistant" + tier = "free" + + def __init__(self): + self._config: dict = {} + + def fields(self) -> list[dict]: + return [ + {"key": "base_url", "label": "Home Assistant URL", "type": "url", + "placeholder": "http://homeassistant.local:8123", "required": True, + "help": ""}, + {"key": "token", "label": "Long-Lived Access Token", "type": "password", + "placeholder": "eyJ0eXAiOiJKV1Qi…", "required": True, + "help": "Profile → Long-Lived Access Tokens → Create Token"}, + {"key": "notification_service", "label": "Notification service", "type": "text", + "placeholder": "notify.mobile_app_my_phone", "required": True, + "help": "Developer Tools → Services → search 'notify' to find yours"}, + ] + + def connect(self, config: dict) -> bool: + self._config = config + return bool(config.get("base_url") and config.get("token")) + + def test(self) -> bool: + try: + import requests + r = requests.get( + f"{self._config['base_url'].rstrip('/')}/api/", + headers={"Authorization": f"Bearer {self._config['token']}"}, + timeout=8, + ) + return r.status_code == 200 + except Exception: + return False diff --git a/scripts/integrations/mega.py b/scripts/integrations/mega.py new file mode 100644 index 0000000..d9ee02c --- /dev/null +++ b/scripts/integrations/mega.py @@ -0,0 +1,32 @@ +from __future__ import annotations +from scripts.integrations.base import IntegrationBase + + +class MegaIntegration(IntegrationBase): + name = "mega" + label = "MEGA" + tier = "free" + + def __init__(self): + self._config: dict = {} + + def fields(self) -> list[dict]: + return [ + {"key": "email", "label": "MEGA email", "type": "text", + "placeholder": "you@example.com", "required": True, + "help": "Your MEGA account email address"}, + {"key": "password", "label": "MEGA password", "type": "password", + "placeholder": "your-mega-password", "required": True, + "help": "Your MEGA account password"}, + {"key": "folder_path", "label": "Folder path", "type": "text", + "placeholder": "/Peregrine", "required": True, + "help": "MEGA folder path for resumes and cover letters"}, + ] + + def connect(self, config: dict) -> bool: + self._config = config + return bool(config.get("email") and config.get("password")) + + def test(self) -> bool: + # TODO: use mega.py SDK to login and verify folder access + return bool(self._config.get("email") and self._config.get("password")) diff --git a/scripts/integrations/nextcloud.py b/scripts/integrations/nextcloud.py new file mode 100644 index 0000000..d2a2f94 --- /dev/null +++ b/scripts/integrations/nextcloud.py @@ -0,0 +1,48 @@ +from __future__ import annotations +from scripts.integrations.base import IntegrationBase + + +class NextcloudIntegration(IntegrationBase): + name = "nextcloud" + label = "Nextcloud" + tier = "free" + + def __init__(self): + self._config: dict = {} + + def fields(self) -> list[dict]: + return [ + {"key": "host", "label": "Nextcloud URL", "type": "url", + "placeholder": "https://nextcloud.example.com", "required": True, + "help": "Your Nextcloud server URL"}, + {"key": "username", "label": "Username", "type": "text", + "placeholder": "your-username", "required": True, + "help": ""}, + {"key": "password", "label": "Password / App password", "type": "password", + "placeholder": "your-password", "required": True, + "help": "Recommend using a Nextcloud app password for security"}, + {"key": "folder_path", "label": "Folder path", "type": "text", + "placeholder": "/Peregrine", "required": True, + "help": "Nextcloud WebDAV folder for resumes and cover letters"}, + ] + + def connect(self, config: dict) -> bool: + self._config = config + return bool(config.get("host") and config.get("username") and config.get("password")) + + def test(self) -> bool: + try: + import requests + host = self._config["host"].rstrip("/") + username = self._config["username"] + folder = self._config.get("folder_path", "") + dav_url = f"{host}/remote.php/dav/files/{username}{folder}" + r = requests.request( + "PROPFIND", dav_url, + auth=(username, self._config["password"]), + headers={"Depth": "0"}, + timeout=8, + ) + return r.status_code in (207, 200) + except Exception: + return False diff --git a/scripts/integrations/notion.py b/scripts/integrations/notion.py new file mode 100644 index 0000000..203d00e --- /dev/null +++ b/scripts/integrations/notion.py @@ -0,0 +1,35 @@ +from __future__ import annotations +from scripts.integrations.base import IntegrationBase + + +class NotionIntegration(IntegrationBase): + name = "notion" + label = "Notion" + tier = "paid" + + def __init__(self): + self._token = "" + self._database_id = "" + + def fields(self) -> list[dict]: + return [ + {"key": "token", "label": "Integration Token", "type": "password", + "placeholder": "secret_…", "required": True, + "help": "Settings → Connections → Develop or manage integrations → New integration"}, + {"key": "database_id", "label": "Database ID", "type": "text", + "placeholder": "32-character ID from Notion URL", "required": True, + "help": "Open your Notion database → Share → Copy link → extract the ID"}, + ] + + def connect(self, config: dict) -> bool: + self._token = config.get("token", "") + self._database_id = config.get("database_id", "") + return bool(self._token and self._database_id) + + def test(self) -> bool: + try: + from notion_client import Client + db = Client(auth=self._token).databases.retrieve(self._database_id) + return bool(db) + except Exception: + return False diff --git a/scripts/integrations/onedrive.py b/scripts/integrations/onedrive.py new file mode 100644 index 0000000..6f8af58 --- /dev/null +++ b/scripts/integrations/onedrive.py @@ -0,0 +1,33 @@ +from __future__ import annotations +from scripts.integrations.base import IntegrationBase + + +class OneDriveIntegration(IntegrationBase): + name = "onedrive" + label = "OneDrive" + tier = "free" + + def __init__(self): + self._config: dict = {} + + def fields(self) -> list[dict]: + return [ + {"key": "client_id", "label": "Application (client) ID", "type": "text", + "placeholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "required": True, + "help": "Azure portal → App registrations → your app → Application (client) ID"}, + {"key": "client_secret", "label": "Client secret", "type": "password", + "placeholder": "your-client-secret", "required": True, + "help": "Azure portal → your app → Certificates & secrets → New client secret"}, + {"key": "folder_path", "label": "Folder path", "type": "text", + "placeholder": "/Peregrine", "required": True, + "help": "OneDrive folder path for resumes and cover letters"}, + ] + + def connect(self, config: dict) -> bool: + self._config = config + return bool(config.get("client_id") and config.get("client_secret")) + + def test(self) -> bool: + # TODO: OAuth2 token exchange via MSAL, then GET /me/drive + # For v1, return True if required fields are present + return bool(self._config.get("client_id") and self._config.get("client_secret")) diff --git a/scripts/integrations/slack.py b/scripts/integrations/slack.py new file mode 100644 index 0000000..e2c6614 --- /dev/null +++ b/scripts/integrations/slack.py @@ -0,0 +1,37 @@ +from __future__ import annotations +from scripts.integrations.base import IntegrationBase + + +class SlackIntegration(IntegrationBase): + name = "slack" + label = "Slack" + tier = "paid" + + def __init__(self): + self._config: dict = {} + + def fields(self) -> list[dict]: + return [ + {"key": "webhook_url", "label": "Incoming Webhook URL", "type": "url", + "placeholder": "https://hooks.slack.com/services/…", "required": True, + "help": "api.slack.com → Your Apps → Incoming Webhooks → Add New Webhook"}, + {"key": "channel", "label": "Channel (optional)", "type": "text", + "placeholder": "#job-alerts", "required": False, + "help": "Leave blank to use the webhook's default channel"}, + ] + + def connect(self, config: dict) -> bool: + self._config = config + return bool(config.get("webhook_url")) + + def test(self) -> bool: + try: + import requests + r = requests.post( + self._config["webhook_url"], + json={"text": "Peregrine connected successfully."}, + timeout=8, + ) + return r.status_code == 200 + except Exception: + return False diff --git a/tests/test_integrations.py b/tests/test_integrations.py index a858792..b2b0604 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -126,3 +126,56 @@ def test_sync_default_returns_zero(): inst = TestIntegration() assert inst.sync([]) == 0 assert inst.sync([{"id": 1}]) == 0 + + +def test_registry_has_all_13_integrations(): + """After all modules are implemented, registry should have 13 entries.""" + from scripts.integrations import REGISTRY + expected = { + "notion", "google_drive", "google_sheets", "airtable", + "dropbox", "onedrive", "mega", "nextcloud", + "google_calendar", "apple_calendar", + "slack", "discord", "home_assistant", + } + assert expected == set(REGISTRY.keys()), ( + f"Missing: {expected - set(REGISTRY.keys())}, " + f"Extra: {set(REGISTRY.keys()) - expected}" + ) + + +def test_all_integrations_have_required_attributes(): + from scripts.integrations import REGISTRY + for name, cls in REGISTRY.items(): + assert hasattr(cls, "name") and cls.name, f"{name} missing .name" + assert hasattr(cls, "label") and cls.label, f"{name} missing .label" + assert hasattr(cls, "tier") and cls.tier in ("free", "paid", "premium"), f"{name} invalid .tier" + + +def test_all_integrations_fields_schema(): + from scripts.integrations import REGISTRY + for name, cls in REGISTRY.items(): + inst = cls() + fields = inst.fields() + assert isinstance(fields, list), f"{name}.fields() must return list" + for f in fields: + assert "key" in f, f"{name} field missing 'key'" + assert "label" in f, f"{name} field missing 'label'" + assert "type" in f, f"{name} field missing 'type'" + assert f["type"] in ("text", "password", "url", "checkbox"), \ + f"{name} field type must be text/password/url/checkbox" + + +def test_free_integrations(): + from scripts.integrations import REGISTRY + free_integrations = {"google_drive", "dropbox", "onedrive", "mega", "nextcloud", "discord", "home_assistant"} + for name in free_integrations: + assert name in REGISTRY, f"{name} not in registry" + assert REGISTRY[name].tier == "free", f"{name} should be tier='free'" + + +def test_paid_integrations(): + from scripts.integrations import REGISTRY + paid_integrations = {"notion", "google_sheets", "airtable", "google_calendar", "apple_calendar", "slack"} + for name in paid_integrations: + assert name in REGISTRY, f"{name} not in registry" + assert REGISTRY[name].tier == "paid", f"{name} should be tier='paid'"