From 5d2462e5a0522232a4f8fa80581815fb46026f6d Mon Sep 17 00:00:00 2001 From: ARUNAVO RAY Date: Wed, 18 Mar 2026 18:36:51 +0530 Subject: [PATCH] feat: add notification system with Ntfy.sh and Apprise support (#238) * feat: add notification system with Ntfy.sh and Apprise providers (#231) Add push notification support for mirror job events with two providers: - Ntfy.sh: direct HTTP POST to ntfy topics with priority/tag support - Apprise API: aggregator gateway supporting 100+ notification services Includes database migration (0010), settings UI tab, test endpoint, auto-save integration, token encryption, and comprehensive tests. Notifications are fire-and-forget and never block the mirror flow. * fix: address review findings for notification system - Fix silent catch in GET handler that returned ciphertext to UI, causing double-encryption on next save. Now clears token to "" on decryption failure instead. - Add Zod schema validation to test notification endpoint, following project API route pattern guidelines. - Mark notifyOnNewRepo toggle as "coming soon" with disabled state, since the backend doesn't yet emit new_repo events. The schema and type support is in place for when it's implemented. * fix notification gating and config validation * trim sync notification details --- docs/NOTIFICATIONS.md | 88 + drizzle/0011_notification_config.sql | 1 + drizzle/meta/0011_snapshot.json | 2030 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- scripts/validate-migrations.ts | 30 +- src/components/config/ConfigTabs.tsx | 80 +- .../config/NotificationSettings.tsx | 394 ++++ src/lib/db/schema.ts | 30 + src/lib/gitea-enhanced.ts | 2 +- src/lib/helpers.ts | 14 +- src/lib/notification-service.test.ts | 221 ++ src/lib/notification-service.ts | 165 ++ src/lib/providers/apprise.test.ts | 98 + src/lib/providers/apprise.ts | 15 + src/lib/providers/ntfy.test.ts | 95 + src/lib/providers/ntfy.ts | 21 + src/pages/api/config/index.test.ts | 51 + src/pages/api/config/index.ts | 106 +- src/pages/api/notifications/test.ts | 42 + src/types/config.ts | 25 + 20 files changed, 3497 insertions(+), 20 deletions(-) create mode 100644 docs/NOTIFICATIONS.md create mode 100644 drizzle/0011_notification_config.sql create mode 100644 drizzle/meta/0011_snapshot.json create mode 100644 src/components/config/NotificationSettings.tsx create mode 100644 src/lib/notification-service.test.ts create mode 100644 src/lib/notification-service.ts create mode 100644 src/lib/providers/apprise.test.ts create mode 100644 src/lib/providers/apprise.ts create mode 100644 src/lib/providers/ntfy.test.ts create mode 100644 src/lib/providers/ntfy.ts create mode 100644 src/pages/api/config/index.test.ts create mode 100644 src/pages/api/notifications/test.ts diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md new file mode 100644 index 0000000..d632c0a --- /dev/null +++ b/docs/NOTIFICATIONS.md @@ -0,0 +1,88 @@ +# Notifications + +Gitea Mirror supports push notifications for mirror events. You can be alerted when jobs succeed, fail, or when new repositories are discovered. + +## Supported Providers + +### 1. Ntfy.sh (Direct) + +[Ntfy.sh](https://ntfy.sh) is a simple HTTP-based pub-sub notification service. You can use the public server at `https://ntfy.sh` or self-host your own instance. + +**Setup (public server):** +1. Go to **Configuration > Notifications** +2. Enable notifications and select **Ntfy.sh** as the provider +3. Set the **Topic** to a unique name (e.g., `my-gitea-mirror-abc123`) +4. Leave the Server URL as `https://ntfy.sh` +5. Subscribe to the same topic on your phone or desktop using the [ntfy app](https://ntfy.sh/docs/subscribe/phone/) + +**Setup (self-hosted):** +1. Deploy ntfy using Docker: `docker run -p 8080:80 binwiederhier/ntfy serve` +2. Set the **Server URL** to your instance (e.g., `http://ntfy:8080`) +3. If authentication is enabled, provide an **Access token** +4. Set your **Topic** name + +**Priority levels:** +- `min` / `low` / `default` / `high` / `urgent` +- Error notifications automatically use `high` priority regardless of the default setting + +### 2. Apprise API (Aggregator) + +[Apprise](https://github.com/caronc/apprise-api) is a notification aggregator that supports 100+ services (Slack, Discord, Telegram, Email, Pushover, and many more) through a single API. + +**Setup:** +1. Deploy the Apprise API server: + ```yaml + # docker-compose.yml + services: + apprise: + image: caronc/apprise:latest + ports: + - "8000:8000" + volumes: + - apprise-config:/config + volumes: + apprise-config: + ``` +2. Configure your notification services in Apprise (via its web UI at `http://localhost:8000` or API) +3. Create a configuration token/key in Apprise +4. In Gitea Mirror, go to **Configuration > Notifications** +5. Enable notifications and select **Apprise API** +6. Set the **Server URL** to your Apprise instance (e.g., `http://apprise:8000`) +7. Enter the **Token/path** you created in step 3 + +**Tag filtering:** +- Optionally set a **Tag** to only notify specific Apprise services +- Leave empty to notify all configured services + +## Event Types + +| Event | Default | Description | +|-------|---------|-------------| +| Sync errors | On | A mirror job failed | +| Sync success | Off | A mirror job completed successfully | +| New repo discovered | Off | A new GitHub repo was auto-imported during scheduled sync | + +## Testing + +Use the **Send Test Notification** button on the Notifications settings page to verify your configuration. The test sends a sample success notification to your configured provider. + +## Troubleshooting + +**Notifications not arriving:** +- Check that notifications are enabled in the settings +- Verify the provider configuration (URL, topic/token) +- Use the Test button to check connectivity +- Check the server logs for `[NotificationService]` messages + +**Ntfy authentication errors:** +- Ensure your access token is correct +- If self-hosting, verify the ntfy server allows the topic + +**Apprise connection refused:** +- Verify the Apprise API server is running and accessible from the Gitea Mirror container +- If using Docker, ensure both containers are on the same network +- Check the Apprise server logs for errors + +**Tokens and security:** +- Notification tokens (ntfy access tokens, Apprise tokens) are encrypted at rest using the same AES-256-GCM encryption as GitHub/Gitea tokens +- Tokens are decrypted only when sending notifications or displaying in the settings UI diff --git a/drizzle/0011_notification_config.sql b/drizzle/0011_notification_config.sql new file mode 100644 index 0000000..20e5ba4 --- /dev/null +++ b/drizzle/0011_notification_config.sql @@ -0,0 +1 @@ +ALTER TABLE `configs` ADD `notification_config` text DEFAULT '{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..16e2448 --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,2030 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "de932758-9842-4ae3-9fc3-65d8f2fe2bda", + "prevId": "e94212d4-688d-406c-a774-c8736b72cda1", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_accounts_account_id": { + "name": "idx_accounts_account_id", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "idx_accounts_user_id": { + "name": "idx_accounts_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_accounts_provider": { + "name": "idx_accounts_provider", + "columns": [ + "provider_id", + "provider_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "configs": { + "name": "configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "github_config": { + "name": "github_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gitea_config": { + "name": "gitea_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "include": { + "name": "include", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"*\"]'" + }, + "exclude": { + "name": "exclude", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "schedule_config": { + "name": "schedule_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cleanup_config": { + "name": "cleanup_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "notification_config": { + "name": "notification_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"enabled\":false,\"provider\":\"ntfy\",\"notifyOnSyncError\":true,\"notifyOnSyncSuccess\":false,\"notifyOnNewRepo\":false}'" + } + }, + "indexes": {}, + "foreignKeys": { + "configs_user_id_users_id_fk": { + "name": "configs_user_id_users_id_fk", + "tableFrom": "configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_events_user_channel": { + "name": "idx_events_user_channel", + "columns": [ + "user_id", + "channel" + ], + "isUnique": false + }, + "idx_events_created_at": { + "name": "idx_events_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_events_read": { + "name": "idx_events_read", + "columns": [ + "read" + ], + "isUnique": false + } + }, + "foreignKeys": { + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mirror_jobs": { + "name": "mirror_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_name": { + "name": "organization_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'imported'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mirror'" + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_items": { + "name": "total_items", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_items": { + "name": "completed_items", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "item_ids": { + "name": "item_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_item_ids": { + "name": "completed_item_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "in_progress": { + "name": "in_progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checkpoint": { + "name": "last_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_mirror_jobs_user_id": { + "name": "idx_mirror_jobs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_mirror_jobs_batch_id": { + "name": "idx_mirror_jobs_batch_id", + "columns": [ + "batch_id" + ], + "isUnique": false + }, + "idx_mirror_jobs_in_progress": { + "name": "idx_mirror_jobs_in_progress", + "columns": [ + "in_progress" + ], + "isUnique": false + }, + "idx_mirror_jobs_job_type": { + "name": "idx_mirror_jobs_job_type", + "columns": [ + "job_type" + ], + "isUnique": false + }, + "idx_mirror_jobs_timestamp": { + "name": "idx_mirror_jobs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "mirror_jobs_user_id_users_id_fk": { + "name": "mirror_jobs_user_id_users_id_fk", + "tableFrom": "mirror_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_access_tokens": { + "name": "oauth_access_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_oauth_access_tokens_access_token": { + "name": "idx_oauth_access_tokens_access_token", + "columns": [ + "access_token" + ], + "isUnique": false + }, + "idx_oauth_access_tokens_user_id": { + "name": "idx_oauth_access_tokens_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_oauth_access_tokens_client_id": { + "name": "idx_oauth_access_tokens_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_applications": { + "name": "oauth_applications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "oauth_applications_client_id_unique": { + "name": "oauth_applications_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + }, + "idx_oauth_applications_client_id": { + "name": "idx_oauth_applications_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_applications_user_id": { + "name": "idx_oauth_applications_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consent": { + "name": "oauth_consent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consent_given": { + "name": "consent_given", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_oauth_consent_user_id": { + "name": "idx_oauth_consent_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_oauth_consent_client_id": { + "name": "idx_oauth_consent_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_consent_user_client": { + "name": "idx_oauth_consent_user_client", + "columns": [ + "user_id", + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_consent_user_id_users_id_fk": { + "name": "oauth_consent_user_id_users_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "is_included": { + "name": "is_included", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "destination_org": { + "name": "destination_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'imported'" + }, + "last_mirrored": { + "name": "last_mirrored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_count": { + "name": "repository_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_repository_count": { + "name": "public_repository_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_repository_count": { + "name": "private_repository_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fork_repository_count": { + "name": "fork_repository_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_organizations_user_id": { + "name": "idx_organizations_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_organizations_config_id": { + "name": "idx_organizations_config_id", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "idx_organizations_status": { + "name": "idx_organizations_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_organizations_is_included": { + "name": "idx_organizations_is_included", + "columns": [ + "is_included" + ], + "isUnique": false + }, + "uniq_organizations_user_normalized_name": { + "name": "uniq_organizations_user_normalized_name", + "columns": [ + "user_id", + "normalized_name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "organizations_user_id_users_id_fk": { + "name": "organizations_user_id_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organizations_config_id_configs_id_fk": { + "name": "organizations_config_id_configs_id_fk", + "tableFrom": "organizations", + "tableTo": "configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rate_limits": { + "name": "rate_limits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'github'" + }, + "limit": { + "name": "limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reset": { + "name": "reset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retry_after": { + "name": "retry_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ok'" + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_rate_limits_user_provider": { + "name": "idx_rate_limits_user_provider", + "columns": [ + "user_id", + "provider" + ], + "isUnique": false + }, + "idx_rate_limits_status": { + "name": "idx_rate_limits_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rate_limits_user_id_users_id_fk": { + "name": "rate_limits_user_id_users_id_fk", + "tableFrom": "rate_limits", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_full_name": { + "name": "normalized_full_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization": { + "name": "organization", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mirrored_location": { + "name": "mirrored_location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "is_private": { + "name": "is_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_fork": { + "name": "is_fork", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "forked_from": { + "name": "forked_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_issues": { + "name": "has_issues", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_starred": { + "name": "is_starred", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_lfs": { + "name": "has_lfs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "has_submodules": { + "name": "has_submodules", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'imported'" + }, + "last_mirrored": { + "name": "last_mirrored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination_org": { + "name": "destination_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_repositories_user_id": { + "name": "idx_repositories_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_repositories_config_id": { + "name": "idx_repositories_config_id", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "idx_repositories_status": { + "name": "idx_repositories_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_repositories_owner": { + "name": "idx_repositories_owner", + "columns": [ + "owner" + ], + "isUnique": false + }, + "idx_repositories_organization": { + "name": "idx_repositories_organization", + "columns": [ + "organization" + ], + "isUnique": false + }, + "idx_repositories_is_fork": { + "name": "idx_repositories_is_fork", + "columns": [ + "is_fork" + ], + "isUnique": false + }, + "idx_repositories_is_starred": { + "name": "idx_repositories_is_starred", + "columns": [ + "is_starred" + ], + "isUnique": false + }, + "idx_repositories_user_imported_at": { + "name": "idx_repositories_user_imported_at", + "columns": [ + "user_id", + "imported_at" + ], + "isUnique": false + }, + "uniq_repositories_user_full_name": { + "name": "uniq_repositories_user_full_name", + "columns": [ + "user_id", + "full_name" + ], + "isUnique": true + }, + "uniq_repositories_user_normalized_full_name": { + "name": "uniq_repositories_user_normalized_full_name", + "columns": [ + "user_id", + "normalized_full_name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "repositories_user_id_users_id_fk": { + "name": "repositories_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "repositories_config_id_configs_id_fk": { + "name": "repositories_config_id_configs_id_fk", + "tableFrom": "repositories", + "tableTo": "configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_token": { + "name": "idx_sessions_token", + "columns": [ + "token" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "columns": [ + "provider_id" + ], + "isUnique": true + }, + "idx_sso_providers_provider_id": { + "name": "idx_sso_providers_provider_id", + "columns": [ + "provider_id" + ], + "isUnique": false + }, + "idx_sso_providers_domain": { + "name": "idx_sso_providers_domain", + "columns": [ + "domain" + ], + "isUnique": false + }, + "idx_sso_providers_issuer": { + "name": "idx_sso_providers_issuer", + "columns": [ + "issuer" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_verification_tokens_token": { + "name": "idx_verification_tokens_token", + "columns": [ + "token" + ], + "isUnique": false + }, + "idx_verification_tokens_identifier": { + "name": "idx_verification_tokens_identifier", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verifications": { + "name": "verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_verifications_identifier": { + "name": "idx_verifications_identifier", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9c3766f..b6d8e08 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1774054800000, "tag": "0010_mirrored_location_index", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1774058400000, + "tag": "0011_notification_config", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/scripts/validate-migrations.ts b/scripts/validate-migrations.ts index ceac3da..7be240f 100644 --- a/scripts/validate-migrations.ts +++ b/scripts/validate-migrations.ts @@ -149,8 +149,6 @@ function seedPre0010Database(db: any) { } function verify0010Migration(db: any) { - // Verify the unique partial index exists by checking that two repos - // with the same non-empty mirroredLocation would conflict const indexes = db.prepare( "SELECT name FROM sqlite_master WHERE type='index' AND name='uniq_repositories_user_mirrored_location'" ).all(); @@ -166,6 +164,30 @@ function verify0010Migration(db: any) { } } +function seedPre0011Database(db: any) { + seedPre0009Database(db); + runMigration(db, migrations.find((m) => m.entry.tag === "0009_nervous_tyger_tiger")!); + runMigration(db, migrations.find((m) => m.entry.tag === "0010_mirrored_location_index")!); +} + +function verify0011Migration(db: any) { + const configColumns = db.query("PRAGMA table_info(configs)").all() as TableInfoRow[]; + const notificationConfigColumn = configColumns.find((column: any) => column.name === "notification_config"); + + assert(notificationConfigColumn, "Expected configs.notification_config column to exist after migration"); + assert(notificationConfigColumn.notnull === 1, "Expected configs.notification_config to be NOT NULL"); + assert( + notificationConfigColumn.dflt_value !== null, + "Expected configs.notification_config to have a default value", + ); + + const existingConfig = db.query("SELECT notification_config FROM configs WHERE id = 'c1'").get() as { notification_config: string } | null; + assert(existingConfig, "Expected existing config row to still exist"); + const parsed = JSON.parse(existingConfig.notification_config); + assert(parsed.enabled === false, "Expected default notification_config.enabled to be false"); + assert(parsed.provider === "ntfy", "Expected default notification_config.provider to be 'ntfy'"); +} + const latestUpgradeFixtures: Record = { "0009_nervous_tyger_tiger": { seed: seedPre0009Database, @@ -175,6 +197,10 @@ const latestUpgradeFixtures: Record = { seed: seedPre0010Database, verify: verify0010Migration, }, + "0011_notification_config": { + seed: seedPre0011Database, + verify: verify0011Migration, + }, }; function lintMigrations(selectedMigrations: Migration[]) { diff --git a/src/components/config/ConfigTabs.tsx b/src/components/config/ConfigTabs.tsx index 9eb9877..f557a16 100644 --- a/src/components/config/ConfigTabs.tsx +++ b/src/components/config/ConfigTabs.tsx @@ -3,6 +3,7 @@ import { GitHubConfigForm } from './GitHubConfigForm'; import { GiteaConfigForm } from './GiteaConfigForm'; import { AutomationSettings } from './AutomationSettings'; import { SSOSettings } from './SSOSettings'; +import { NotificationSettings } from './NotificationSettings'; import type { ConfigApiResponse, GiteaConfig, @@ -13,6 +14,7 @@ import type { DatabaseCleanupConfig, MirrorOptions, AdvancedOptions, + NotificationConfig, } from '@/types/config'; import { Button } from '../ui/button'; import { useAuth } from '@/hooks/useAuth'; @@ -30,6 +32,7 @@ type ConfigState = { cleanupConfig: DatabaseCleanupConfig; mirrorOptions: MirrorOptions; advancedOptions: AdvancedOptions; + notificationConfig: NotificationConfig; }; export function ConfigTabs() { @@ -86,6 +89,13 @@ export function ConfigTabs() { starredCodeOnly: false, autoMirrorStarred: false, }, + notificationConfig: { + enabled: false, + provider: "ntfy", + notifyOnSyncError: true, + notifyOnSyncSuccess: false, + notifyOnNewRepo: false, + }, }); const { user } = useAuth(); const [isLoading, setIsLoading] = useState(true); @@ -95,10 +105,12 @@ export function ConfigTabs() { const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState(false); const [isAutoSavingGitHub, setIsAutoSavingGitHub] = useState(false); const [isAutoSavingGitea, setIsAutoSavingGitea] = useState(false); + const [isAutoSavingNotification, setIsAutoSavingNotification] = useState(false); const autoSaveScheduleTimeoutRef = useRef(null); const autoSaveCleanupTimeoutRef = useRef(null); const autoSaveGitHubTimeoutRef = useRef(null); const autoSaveGiteaTimeoutRef = useRef(null); + const autoSaveNotificationTimeoutRef = useRef(null); const isConfigFormValid = (): boolean => { const { githubConfig, giteaConfig } = config; @@ -460,6 +472,55 @@ export function ConfigTabs() { } }, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions]); + // Auto-save function for notification config changes + const autoSaveNotificationConfig = useCallback(async (notifConfig: NotificationConfig) => { + if (!user?.id) return; + + // Clear any existing timeout + if (autoSaveNotificationTimeoutRef.current) { + clearTimeout(autoSaveNotificationTimeoutRef.current); + } + + // Debounce the auto-save to prevent excessive API calls + autoSaveNotificationTimeoutRef.current = setTimeout(async () => { + setIsAutoSavingNotification(true); + + const reqPayload = { + userId: user.id!, + githubConfig: config.githubConfig, + giteaConfig: config.giteaConfig, + scheduleConfig: config.scheduleConfig, + cleanupConfig: config.cleanupConfig, + mirrorOptions: config.mirrorOptions, + advancedOptions: config.advancedOptions, + notificationConfig: notifConfig, + }; + + try { + const response = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(reqPayload), + }); + const result: SaveConfigApiResponse = await response.json(); + + if (result.success) { + // Silent success - no toast for auto-save + invalidateConfigCache(); + } else { + showErrorToast( + `Auto-save failed: ${result.message || 'Unknown error'}`, + toast + ); + } + } catch (error) { + showErrorToast(error, toast); + } finally { + setIsAutoSavingNotification(false); + } + }, 500); // 500ms debounce + }, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions, config.advancedOptions]); + // Cleanup timeouts on unmount useEffect(() => { return () => { @@ -475,6 +536,9 @@ export function ConfigTabs() { if (autoSaveGiteaTimeoutRef.current) { clearTimeout(autoSaveGiteaTimeoutRef.current); } + if (autoSaveNotificationTimeoutRef.current) { + clearTimeout(autoSaveNotificationTimeoutRef.current); + } }; }, []); @@ -506,6 +570,8 @@ export function ConfigTabs() { }, advancedOptions: response.advancedOptions || config.advancedOptions, + notificationConfig: + (response as any).notificationConfig || config.notificationConfig, }); } @@ -635,9 +701,10 @@ export function ConfigTabs() { {/* Content section - Tabs layout */} - + Connections Automation + Notifications Authentication @@ -725,6 +792,17 @@ export function ConfigTabs() { /> + + { + setConfig(prev => ({ ...prev, notificationConfig: newConfig })); + autoSaveNotificationConfig(newConfig); + }} + isAutoSaving={isAutoSavingNotification} + /> + + diff --git a/src/components/config/NotificationSettings.tsx b/src/components/config/NotificationSettings.tsx new file mode 100644 index 0000000..f91d8d5 --- /dev/null +++ b/src/components/config/NotificationSettings.tsx @@ -0,0 +1,394 @@ +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { Bell, Activity, Send } from "lucide-react"; +import { toast } from "sonner"; +import type { NotificationConfig } from "@/types/config"; + +interface NotificationSettingsProps { + notificationConfig: NotificationConfig; + onNotificationChange: (config: NotificationConfig) => void; + isAutoSaving?: boolean; +} + +export function NotificationSettings({ + notificationConfig, + onNotificationChange, + isAutoSaving, +}: NotificationSettingsProps) { + const [isTesting, setIsTesting] = useState(false); + + const handleTestNotification = async () => { + setIsTesting(true); + try { + const resp = await fetch("/api/notifications/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ notificationConfig }), + }); + const result = await resp.json(); + if (result.success) { + toast.success("Test notification sent successfully!"); + } else { + toast.error(`Test failed: ${result.error || "Unknown error"}`); + } + } catch (error) { + toast.error( + `Test failed: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + setIsTesting(false); + } + }; + + return ( + + + + + Notifications + {isAutoSaving && ( + + )} + + + + + {/* Enable/disable toggle */} +
+
+ +

+ Receive alerts when mirror jobs complete or fail +

+
+ + onNotificationChange({ ...notificationConfig, enabled: checked }) + } + /> +
+ + {notificationConfig.enabled && ( + <> + {/* Provider selector */} +
+ + +
+ + {/* Ntfy configuration */} + {notificationConfig.provider === "ntfy" && ( +
+

Ntfy.sh Settings

+ +
+ + + onNotificationChange({ + ...notificationConfig, + ntfy: { + ...notificationConfig.ntfy!, + url: e.target.value, + topic: notificationConfig.ntfy?.topic || "", + priority: notificationConfig.ntfy?.priority || "default", + }, + }) + } + /> +

+ Use https://ntfy.sh for the public server or your self-hosted instance URL +

+
+ +
+ + + onNotificationChange({ + ...notificationConfig, + ntfy: { + ...notificationConfig.ntfy!, + url: notificationConfig.ntfy?.url || "https://ntfy.sh", + topic: e.target.value, + priority: notificationConfig.ntfy?.priority || "default", + }, + }) + } + /> +

+ Choose a unique topic name. Anyone with the topic name can subscribe. +

+
+ +
+ + + onNotificationChange({ + ...notificationConfig, + ntfy: { + ...notificationConfig.ntfy!, + url: notificationConfig.ntfy?.url || "https://ntfy.sh", + topic: notificationConfig.ntfy?.topic || "", + token: e.target.value, + priority: notificationConfig.ntfy?.priority || "default", + }, + }) + } + /> +

+ Required if your ntfy server uses authentication +

+
+ +
+ + +

+ Error notifications always use "high" priority regardless of this setting +

+
+
+ )} + + {/* Apprise configuration */} + {notificationConfig.provider === "apprise" && ( +
+

Apprise API Settings

+ +
+ + + onNotificationChange({ + ...notificationConfig, + apprise: { + ...notificationConfig.apprise!, + url: e.target.value, + token: notificationConfig.apprise?.token || "", + }, + }) + } + /> +

+ URL of your Apprise API server (e.g., http://apprise:8000) +

+
+ +
+ + + onNotificationChange({ + ...notificationConfig, + apprise: { + ...notificationConfig.apprise!, + url: notificationConfig.apprise?.url || "", + token: e.target.value, + }, + }) + } + /> +

+ The Apprise API configuration token or key +

+
+ +
+ + + onNotificationChange({ + ...notificationConfig, + apprise: { + ...notificationConfig.apprise!, + url: notificationConfig.apprise?.url || "", + token: notificationConfig.apprise?.token || "", + tag: e.target.value, + }, + }) + } + /> +

+ Optional tag to filter which Apprise services receive notifications +

+
+
+ )} + + {/* Event toggles */} +
+

Notification Events

+ +
+
+ +

+ Notify when a mirror job fails +

+
+ + onNotificationChange({ ...notificationConfig, notifyOnSyncError: checked }) + } + /> +
+ +
+
+ +

+ Notify when a mirror job completes successfully +

+
+ + onNotificationChange({ ...notificationConfig, notifyOnSyncSuccess: checked }) + } + /> +
+ +
+
+ +

+ Notify when a new GitHub repository is auto-imported +

+
+ + onNotificationChange({ ...notificationConfig, notifyOnNewRepo: checked }) + } + /> +
+
+ + {/* Test button */} +
+ +
+ + )} +
+
+ ); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 0637aab..231c206 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -122,6 +122,31 @@ export const cleanupConfigSchema = z.object({ nextRun: z.coerce.date().optional(), }); +export const ntfyConfigSchema = z.object({ + url: z.string().default("https://ntfy.sh"), + topic: z.string().default(""), + token: z.string().optional(), + priority: z.enum(["min", "low", "default", "high", "urgent"]).default("default"), +}); + +export const appriseConfigSchema = z.object({ + url: z.string().default(""), + token: z.string().default(""), + tag: z.string().optional(), +}); + +export const notificationConfigSchema = z.object({ + enabled: z.boolean().default(false), + provider: z.enum(["ntfy", "apprise"]).default("ntfy"), + notifyOnSyncError: z.boolean().default(true), + notifyOnSyncSuccess: z.boolean().default(false), + notifyOnNewRepo: z.boolean().default(false), + ntfy: ntfyConfigSchema.optional(), + apprise: appriseConfigSchema.optional(), +}); + +export type NotificationConfig = z.infer; + export const configSchema = z.object({ id: z.string(), userId: z.string(), @@ -337,6 +362,11 @@ export const configs = sqliteTable("configs", { .$type>() .notNull(), + notificationConfig: text("notification_config", { mode: "json" }) + .$type>() + .notNull() + .default(sql`'{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}'`), + createdAt: integer("created_at", { mode: "timestamp" }) .notNull() .default(sql`(unixepoch())`), diff --git a/src/lib/gitea-enhanced.ts b/src/lib/gitea-enhanced.ts index 7f133af..4a0f76e 100644 --- a/src/lib/gitea-enhanced.ts +++ b/src/lib/gitea-enhanced.ts @@ -720,7 +720,7 @@ export async function syncGiteaRepoEnhanced({ repositoryId: repository.id, repositoryName: repository.name, message: `Sync requested for repository: ${repository.name}`, - details: `Mirror sync was requested for ${repository.name}. Gitea/Forgejo performs the actual pull asynchronously; check remote logs for pull errors.`, + details: `Mirror sync was requested for ${repository.name}.`, status: "synced", }); diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index f035cad..1a5c64d 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -3,6 +3,7 @@ import { db, mirrorJobs } from "./db"; import { eq, and, or, lt, isNull } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; import { publishEvent } from "./events"; +import { triggerJobNotification } from "./notification-service"; export async function createMirrorJob({ userId, @@ -19,6 +20,7 @@ export async function createMirrorJob({ itemIds, inProgress, skipDuplicateEvent, + skipNotification, }: { userId: string; organizationId?: string; @@ -34,6 +36,7 @@ export async function createMirrorJob({ itemIds?: string[]; inProgress?: boolean; skipDuplicateEvent?: boolean; // Option to skip event publishing for internal operations + skipNotification?: boolean; // Option to skip push notifications for specific internal operations }) { const jobId = uuidv4(); const currentTimestamp = new Date(); @@ -67,7 +70,7 @@ export async function createMirrorJob({ // Insert the job into the database await db.insert(mirrorJobs).values(job); - // Publish the event using SQLite instead of Redis (unless skipped) + // Publish realtime status events unless explicitly skipped if (!skipDuplicateEvent) { const channel = `mirror-status:${userId}`; @@ -89,6 +92,15 @@ export async function createMirrorJob({ }); } + // Trigger push notifications for terminal statuses (never blocks the mirror flow). + // Keep this independent from skipDuplicateEvent so event-stream suppression does not + // silently disable user-facing notifications. + if (!skipNotification && (status === "failed" || status === "mirrored" || status === "synced")) { + triggerJobNotification({ userId, status, repositoryName, organizationName, message, details }).catch(err => { + console.error("[NotificationService] Background notification failed:", err); + }); + } + return jobId; } catch (error) { console.error("Error creating mirror job:", error); diff --git a/src/lib/notification-service.test.ts b/src/lib/notification-service.test.ts new file mode 100644 index 0000000..b068d7e --- /dev/null +++ b/src/lib/notification-service.test.ts @@ -0,0 +1,221 @@ +import { describe, test, expect, beforeEach, mock } from "bun:test"; + +// Mock fetch globally before importing the module +let mockFetch: ReturnType; + +beforeEach(() => { + mockFetch = mock(() => + Promise.resolve(new Response("ok", { status: 200 })) + ); + globalThis.fetch = mockFetch as any; +}); + +// Mock encryption module +mock.module("@/lib/utils/encryption", () => ({ + encrypt: (val: string) => val, + decrypt: (val: string) => val, + isEncrypted: () => false, +})); + +// Import after mocks are set up — db is already mocked via setup.bun.ts +import { sendNotification, testNotification } from "./notification-service"; +import type { NotificationConfig } from "@/types/config"; + +describe("sendNotification", () => { + test("sends ntfy notification when provider is ntfy", async () => { + const config: NotificationConfig = { + enabled: true, + provider: "ntfy", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + ntfy: { + url: "https://ntfy.sh", + topic: "test-topic", + priority: "default", + }, + }; + + await sendNotification(config, { + title: "Test", + message: "Test message", + type: "sync_success", + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe("https://ntfy.sh/test-topic"); + }); + + test("sends apprise notification when provider is apprise", async () => { + const config: NotificationConfig = { + enabled: true, + provider: "apprise", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + apprise: { + url: "http://apprise:8000", + token: "my-token", + }, + }; + + await sendNotification(config, { + title: "Test", + message: "Test message", + type: "sync_success", + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe("http://apprise:8000/notify/my-token"); + }); + + test("does not throw when fetch fails", async () => { + mockFetch = mock(() => Promise.reject(new Error("Network error"))); + globalThis.fetch = mockFetch as any; + + const config: NotificationConfig = { + enabled: true, + provider: "ntfy", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + ntfy: { + url: "https://ntfy.sh", + topic: "test-topic", + priority: "default", + }, + }; + + // Should not throw + await sendNotification(config, { + title: "Test", + message: "Test message", + type: "sync_success", + }); + }); + + test("skips notification when ntfy topic is missing", async () => { + const config: NotificationConfig = { + enabled: true, + provider: "ntfy", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + ntfy: { + url: "https://ntfy.sh", + topic: "", + priority: "default", + }, + }; + + await sendNotification(config, { + title: "Test", + message: "Test message", + type: "sync_success", + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test("skips notification when apprise URL is missing", async () => { + const config: NotificationConfig = { + enabled: true, + provider: "apprise", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + apprise: { + url: "", + token: "my-token", + }, + }; + + await sendNotification(config, { + title: "Test", + message: "Test message", + type: "sync_success", + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe("testNotification", () => { + test("returns success when notification is sent", async () => { + const config: NotificationConfig = { + enabled: true, + provider: "ntfy", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + ntfy: { + url: "https://ntfy.sh", + topic: "test-topic", + priority: "default", + }, + }; + + const result = await testNotification(config); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + test("returns error when topic is missing", async () => { + const config: NotificationConfig = { + enabled: true, + provider: "ntfy", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + ntfy: { + url: "https://ntfy.sh", + topic: "", + priority: "default", + }, + }; + + const result = await testNotification(config); + expect(result.success).toBe(false); + expect(result.error).toContain("topic"); + }); + + test("returns error when fetch fails", async () => { + mockFetch = mock(() => + Promise.resolve(new Response("bad request", { status: 400 })) + ); + globalThis.fetch = mockFetch as any; + + const config: NotificationConfig = { + enabled: true, + provider: "ntfy", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + ntfy: { + url: "https://ntfy.sh", + topic: "test-topic", + priority: "default", + }, + }; + + const result = await testNotification(config); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + test("returns error for unknown provider", async () => { + const config = { + enabled: true, + provider: "unknown" as any, + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + }; + + const result = await testNotification(config); + expect(result.success).toBe(false); + expect(result.error).toContain("Unknown provider"); + }); +}); diff --git a/src/lib/notification-service.ts b/src/lib/notification-service.ts new file mode 100644 index 0000000..82aad66 --- /dev/null +++ b/src/lib/notification-service.ts @@ -0,0 +1,165 @@ +import type { NotificationConfig } from "@/types/config"; +import type { NotificationEvent } from "./providers/ntfy"; +import { sendNtfyNotification } from "./providers/ntfy"; +import { sendAppriseNotification } from "./providers/apprise"; +import { db, configs } from "@/lib/db"; +import { eq } from "drizzle-orm"; +import { decrypt } from "@/lib/utils/encryption"; + +/** + * Sends a notification using the configured provider. + * NEVER throws -- all errors are caught and logged. + */ +export async function sendNotification( + config: NotificationConfig, + event: NotificationEvent, +): Promise { + try { + if (config.provider === "ntfy") { + if (!config.ntfy?.topic) { + console.warn("[NotificationService] Ntfy topic is not configured, skipping notification"); + return; + } + await sendNtfyNotification(config.ntfy, event); + } else if (config.provider === "apprise") { + if (!config.apprise?.url || !config.apprise?.token) { + console.warn("[NotificationService] Apprise URL or token is not configured, skipping notification"); + return; + } + await sendAppriseNotification(config.apprise, event); + } + } catch (error) { + console.error("[NotificationService] Failed to send notification:", error); + } +} + +/** + * Sends a test notification and returns the result. + * Unlike sendNotification, this propagates the success/error status + * so the UI can display the outcome. + */ +export async function testNotification( + notificationConfig: NotificationConfig, +): Promise<{ success: boolean; error?: string }> { + const event: NotificationEvent = { + title: "Gitea Mirror - Test Notification", + message: "This is a test notification from Gitea Mirror. If you see this, notifications are working correctly!", + type: "sync_success", + }; + + try { + if (notificationConfig.provider === "ntfy") { + if (!notificationConfig.ntfy?.topic) { + return { success: false, error: "Ntfy topic is required" }; + } + await sendNtfyNotification(notificationConfig.ntfy, event); + } else if (notificationConfig.provider === "apprise") { + if (!notificationConfig.apprise?.url || !notificationConfig.apprise?.token) { + return { success: false, error: "Apprise URL and token are required" }; + } + await sendAppriseNotification(notificationConfig.apprise, event); + } else { + return { success: false, error: `Unknown provider: ${notificationConfig.provider}` }; + } + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } +} + +/** + * Loads the user's notification config from the database and triggers + * a notification if the event type matches the user's preferences. + * + * NEVER throws -- all errors are caught and logged. This function is + * designed to be called fire-and-forget from the mirror job system. + */ +export async function triggerJobNotification({ + userId, + status, + repositoryName, + organizationName, + message, + details, +}: { + userId: string; + status: string; + repositoryName?: string | null; + organizationName?: string | null; + message?: string; + details?: string; +}): Promise { + try { + // Only trigger for terminal statuses + if (status !== "failed" && status !== "mirrored" && status !== "synced") { + return; + } + + // Fetch user's config from database + const configResults = await db + .select() + .from(configs) + .where(eq(configs.userId, userId)) + .limit(1); + + if (configResults.length === 0) { + return; + } + + const userConfig = configResults[0]; + const notificationConfig = userConfig.notificationConfig as NotificationConfig | undefined; + + if (!notificationConfig?.enabled) { + return; + } + + // Check event type against user preferences + const isError = status === "failed"; + const isSuccess = status === "mirrored" || status === "synced"; + + if (isError && !notificationConfig.notifyOnSyncError) { + return; + } + if (isSuccess && !notificationConfig.notifyOnSyncSuccess) { + return; + } + + // Only decrypt the active provider's token to avoid failures from stale + // credentials on the inactive provider dropping the entire notification + const decryptedConfig = { ...notificationConfig }; + if (decryptedConfig.provider === "ntfy" && decryptedConfig.ntfy?.token) { + decryptedConfig.ntfy = { + ...decryptedConfig.ntfy, + token: decrypt(decryptedConfig.ntfy.token), + }; + } + if (decryptedConfig.provider === "apprise" && decryptedConfig.apprise?.token) { + decryptedConfig.apprise = { + ...decryptedConfig.apprise, + token: decrypt(decryptedConfig.apprise.token), + }; + } + + // Build event + const repoLabel = repositoryName || organizationName || "Unknown"; + const eventType: NotificationEvent["type"] = isError ? "sync_error" : "sync_success"; + + const event: NotificationEvent = { + title: isError + ? `Mirror Failed: ${repoLabel}` + : `Mirror Success: ${repoLabel}`, + message: [ + message || `Repository ${repoLabel} ${isError ? "failed to mirror" : "mirrored successfully"}`, + details ? `\nDetails: ${details}` : "", + ] + .filter(Boolean) + .join(""), + type: eventType, + }; + + await sendNotification(decryptedConfig, event); + } catch (error) { + console.error("[NotificationService] Background notification failed:", error); + } +} diff --git a/src/lib/providers/apprise.test.ts b/src/lib/providers/apprise.test.ts new file mode 100644 index 0000000..8773d21 --- /dev/null +++ b/src/lib/providers/apprise.test.ts @@ -0,0 +1,98 @@ +import { describe, test, expect, beforeEach, mock } from "bun:test"; +import { sendAppriseNotification } from "./apprise"; +import type { NotificationEvent } from "./ntfy"; +import type { AppriseConfig } from "@/types/config"; + +describe("sendAppriseNotification", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = mock(() => + Promise.resolve(new Response("ok", { status: 200 })) + ); + globalThis.fetch = mockFetch as any; + }); + + const baseConfig: AppriseConfig = { + url: "http://apprise:8000", + token: "gitea-mirror", + }; + + const baseEvent: NotificationEvent = { + title: "Test Notification", + message: "This is a test", + type: "sync_success", + }; + + test("constructs correct URL from config", async () => { + await sendAppriseNotification(baseConfig, baseEvent); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe("http://apprise:8000/notify/gitea-mirror"); + }); + + test("strips trailing slash from URL", async () => { + await sendAppriseNotification( + { ...baseConfig, url: "http://apprise:8000/" }, + baseEvent + ); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe("http://apprise:8000/notify/gitea-mirror"); + }); + + test("sends correct JSON body format", async () => { + await sendAppriseNotification(baseConfig, baseEvent); + + const [, opts] = mockFetch.mock.calls[0]; + expect(opts.headers["Content-Type"]).toBe("application/json"); + + const body = JSON.parse(opts.body); + expect(body.title).toBe("Test Notification"); + expect(body.body).toBe("This is a test"); + expect(body.type).toBe("success"); + }); + + test("maps sync_error to failure type", async () => { + const errorEvent: NotificationEvent = { + ...baseEvent, + type: "sync_error", + }; + await sendAppriseNotification(baseConfig, errorEvent); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body.type).toBe("failure"); + }); + + test("includes tag when configured", async () => { + await sendAppriseNotification( + { ...baseConfig, tag: "urgent" }, + baseEvent + ); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body.tag).toBe("urgent"); + }); + + test("omits tag when not configured", async () => { + await sendAppriseNotification(baseConfig, baseEvent); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body.tag).toBeUndefined(); + }); + + test("throws on non-200 response", async () => { + mockFetch = mock(() => + Promise.resolve(new Response("server error", { status: 500 })) + ); + globalThis.fetch = mockFetch as any; + + expect( + sendAppriseNotification(baseConfig, baseEvent) + ).rejects.toThrow("Apprise error: 500"); + }); +}); diff --git a/src/lib/providers/apprise.ts b/src/lib/providers/apprise.ts new file mode 100644 index 0000000..7a747ae --- /dev/null +++ b/src/lib/providers/apprise.ts @@ -0,0 +1,15 @@ +import type { AppriseConfig } from "@/types/config"; +import type { NotificationEvent } from "./ntfy"; + +export async function sendAppriseNotification(config: AppriseConfig, event: NotificationEvent): Promise { + const url = `${config.url.replace(/\/$/, "")}/notify/${config.token}`; + const headers: Record = { "Content-Type": "application/json" }; + const body = JSON.stringify({ + title: event.title, + body: event.message, + type: event.type === "sync_error" ? "failure" : "success", + tag: config.tag || undefined, + }); + const resp = await fetch(url, { method: "POST", body, headers }); + if (!resp.ok) throw new Error(`Apprise error: ${resp.status} ${await resp.text()}`); +} diff --git a/src/lib/providers/ntfy.test.ts b/src/lib/providers/ntfy.test.ts new file mode 100644 index 0000000..32961dd --- /dev/null +++ b/src/lib/providers/ntfy.test.ts @@ -0,0 +1,95 @@ +import { describe, test, expect, beforeEach, mock } from "bun:test"; +import { sendNtfyNotification, type NotificationEvent } from "./ntfy"; +import type { NtfyConfig } from "@/types/config"; + +describe("sendNtfyNotification", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = mock(() => + Promise.resolve(new Response("ok", { status: 200 })) + ); + globalThis.fetch = mockFetch as any; + }); + + const baseConfig: NtfyConfig = { + url: "https://ntfy.sh", + topic: "gitea-mirror", + priority: "default", + }; + + const baseEvent: NotificationEvent = { + title: "Test Notification", + message: "This is a test", + type: "sync_success", + }; + + test("constructs correct URL from config", async () => { + await sendNtfyNotification(baseConfig, baseEvent); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe("https://ntfy.sh/gitea-mirror"); + }); + + test("strips trailing slash from URL", async () => { + await sendNtfyNotification( + { ...baseConfig, url: "https://ntfy.sh/" }, + baseEvent + ); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe("https://ntfy.sh/gitea-mirror"); + }); + + test("includes Authorization header when token is present", async () => { + await sendNtfyNotification( + { ...baseConfig, token: "tk_secret" }, + baseEvent + ); + + const [, opts] = mockFetch.mock.calls[0]; + expect(opts.headers["Authorization"]).toBe("Bearer tk_secret"); + }); + + test("does not include Authorization header when no token", async () => { + await sendNtfyNotification(baseConfig, baseEvent); + + const [, opts] = mockFetch.mock.calls[0]; + expect(opts.headers["Authorization"]).toBeUndefined(); + }); + + test("uses high priority for sync_error events", async () => { + const errorEvent: NotificationEvent = { + ...baseEvent, + type: "sync_error", + }; + await sendNtfyNotification(baseConfig, errorEvent); + + const [, opts] = mockFetch.mock.calls[0]; + expect(opts.headers["Priority"]).toBe("high"); + expect(opts.headers["Tags"]).toBe("warning"); + }); + + test("uses config priority for non-error events", async () => { + await sendNtfyNotification( + { ...baseConfig, priority: "low" }, + baseEvent + ); + + const [, opts] = mockFetch.mock.calls[0]; + expect(opts.headers["Priority"]).toBe("low"); + expect(opts.headers["Tags"]).toBe("white_check_mark"); + }); + + test("throws on non-200 response", async () => { + mockFetch = mock(() => + Promise.resolve(new Response("rate limited", { status: 429 })) + ); + globalThis.fetch = mockFetch as any; + + expect( + sendNtfyNotification(baseConfig, baseEvent) + ).rejects.toThrow("Ntfy error: 429"); + }); +}); diff --git a/src/lib/providers/ntfy.ts b/src/lib/providers/ntfy.ts new file mode 100644 index 0000000..8c1e8db --- /dev/null +++ b/src/lib/providers/ntfy.ts @@ -0,0 +1,21 @@ +import type { NtfyConfig } from "@/types/config"; + +export interface NotificationEvent { + title: string; + message: string; + type: "sync_error" | "sync_success" | "new_repo"; +} + +export async function sendNtfyNotification(config: NtfyConfig, event: NotificationEvent): Promise { + const url = `${config.url.replace(/\/$/, "")}/${config.topic}`; + const headers: Record = { + "Title": event.title, + "Priority": event.type === "sync_error" ? "high" : (config.priority || "default"), + "Tags": event.type === "sync_error" ? "warning" : "white_check_mark", + }; + if (config.token) { + headers["Authorization"] = `Bearer ${config.token}`; + } + const resp = await fetch(url, { method: "POST", body: event.message, headers }); + if (!resp.ok) throw new Error(`Ntfy error: ${resp.status} ${await resp.text()}`); +} diff --git a/src/pages/api/config/index.test.ts b/src/pages/api/config/index.test.ts new file mode 100644 index 0000000..ee9040d --- /dev/null +++ b/src/pages/api/config/index.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { POST } from "./index"; + +describe("POST /api/config notification validation", () => { + test("returns 400 for invalid notificationConfig payload", async () => { + const request = new Request("http://localhost/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + githubConfig: { username: "octo", token: "ghp_x" }, + giteaConfig: { url: "https://gitea.example.com", token: "gt_x", username: "octo" }, + scheduleConfig: { enabled: true, interval: 3600 }, + cleanupConfig: { enabled: false, retentionDays: 604800 }, + mirrorOptions: { + mirrorReleases: false, + releaseLimit: 10, + mirrorLFS: false, + mirrorMetadata: false, + metadataComponents: { + issues: false, + pullRequests: false, + labels: false, + milestones: false, + wiki: false, + }, + }, + advancedOptions: { + skipForks: false, + starredCodeOnly: false, + autoMirrorStarred: false, + }, + notificationConfig: { + enabled: true, + provider: "invalid-provider", + }, + }), + }); + + const response = await POST({ + request, + locals: { + session: { userId: "user-1" }, + }, + } as any); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.message).toContain("Invalid notificationConfig"); + }); +}); diff --git a/src/pages/api/config/index.ts b/src/pages/api/config/index.ts index 1958156..ee4df74 100644 --- a/src/pages/api/config/index.ts +++ b/src/pages/api/config/index.ts @@ -14,6 +14,7 @@ import { import { encrypt, decrypt } from "@/lib/utils/encryption"; import { createDefaultConfig } from "@/lib/utils/config-defaults"; import { requireAuthenticatedUserId } from "@/lib/auth-guards"; +import { notificationConfigSchema } from "@/lib/db/schema"; export const POST: APIRoute = async ({ request, locals }) => { try { @@ -22,7 +23,15 @@ export const POST: APIRoute = async ({ request, locals }) => { const userId = authResult.userId; const body = await request.json(); - const { githubConfig, giteaConfig, scheduleConfig, cleanupConfig, mirrorOptions, advancedOptions } = body; + const { + githubConfig, + giteaConfig, + scheduleConfig, + cleanupConfig, + mirrorOptions, + advancedOptions, + notificationConfig, + } = body; if (!githubConfig || !giteaConfig || !scheduleConfig || !cleanupConfig || !mirrorOptions || !advancedOptions) { return new Response( @@ -38,6 +47,24 @@ export const POST: APIRoute = async ({ request, locals }) => { ); } + let validatedNotificationConfig: any = undefined; + if (notificationConfig !== undefined) { + const parsed = notificationConfigSchema.safeParse(notificationConfig); + if (!parsed.success) { + return new Response( + JSON.stringify({ + success: false, + message: `Invalid notificationConfig: ${parsed.error.message}`, + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + validatedNotificationConfig = parsed.data; + } + // Validate Gitea URL format and protocol if (giteaConfig.url) { try { @@ -115,17 +142,41 @@ export const POST: APIRoute = async ({ request, locals }) => { ); const processedCleanupConfig = mapUiCleanupToDb(cleanupConfig); + // Process notification config if provided + let processedNotificationConfig: any = undefined; + if (validatedNotificationConfig) { + processedNotificationConfig = { ...validatedNotificationConfig }; + // Encrypt ntfy token if present + if (processedNotificationConfig.ntfy?.token) { + processedNotificationConfig.ntfy = { + ...processedNotificationConfig.ntfy, + token: encrypt(processedNotificationConfig.ntfy.token), + }; + } + // Encrypt apprise token if present + if (processedNotificationConfig.apprise?.token) { + processedNotificationConfig.apprise = { + ...processedNotificationConfig.apprise, + token: encrypt(processedNotificationConfig.apprise.token), + }; + } + } + if (existingConfig) { // Update path + const updateFields: Record = { + githubConfig: mappedGithubConfig, + giteaConfig: mappedGiteaConfig, + scheduleConfig: processedScheduleConfig, + cleanupConfig: processedCleanupConfig, + updatedAt: new Date(), + }; + if (processedNotificationConfig) { + updateFields.notificationConfig = processedNotificationConfig; + } await db .update(configs) - .set({ - githubConfig: mappedGithubConfig, - giteaConfig: mappedGiteaConfig, - scheduleConfig: processedScheduleConfig, - cleanupConfig: processedCleanupConfig, - updatedAt: new Date(), - }) + .set(updateFields) .where(eq(configs.id, existingConfig.id)); return new Response( @@ -163,7 +214,7 @@ export const POST: APIRoute = async ({ request, locals }) => { // Create new config const configId = uuidv4(); - await db.insert(configs).values({ + const insertValues: Record = { id: configId, userId, name: "Default Configuration", @@ -176,7 +227,11 @@ export const POST: APIRoute = async ({ request, locals }) => { cleanupConfig: processedCleanupConfig, createdAt: new Date(), updatedAt: new Date(), - }); + }; + if (processedNotificationConfig) { + insertValues.notificationConfig = processedNotificationConfig; + } + await db.insert(configs).values(insertValues); return new Response( JSON.stringify({ @@ -258,13 +313,34 @@ export const GET: APIRoute = async ({ request, locals }) => { githubConfig, giteaConfig }; - + const uiConfig = mapDbToUiConfig(decryptedConfig); - + // Map schedule and cleanup configs to UI format const uiScheduleConfig = mapDbScheduleToUi(dbConfig.scheduleConfig); const uiCleanupConfig = mapDbCleanupToUi(dbConfig.cleanupConfig); - + + // Decrypt notification config tokens + let notificationConfig = dbConfig.notificationConfig; + if (notificationConfig) { + notificationConfig = { ...notificationConfig }; + if (notificationConfig.ntfy?.token) { + try { + notificationConfig.ntfy = { ...notificationConfig.ntfy, token: decrypt(notificationConfig.ntfy.token) }; + } catch { + // Clear token on decryption failure to prevent double-encryption on next save + notificationConfig.ntfy = { ...notificationConfig.ntfy, token: "" }; + } + } + if (notificationConfig.apprise?.token) { + try { + notificationConfig.apprise = { ...notificationConfig.apprise, token: decrypt(notificationConfig.apprise.token) }; + } catch { + notificationConfig.apprise = { ...notificationConfig.apprise, token: "" }; + } + } + } + return new Response(JSON.stringify({ ...dbConfig, ...uiConfig, @@ -278,6 +354,7 @@ export const GET: APIRoute = async ({ request, locals }) => { lastRun: dbConfig.cleanupConfig.lastRun, nextRun: dbConfig.cleanupConfig.nextRun, }, + notificationConfig, }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -288,7 +365,7 @@ export const GET: APIRoute = async ({ request, locals }) => { const uiConfig = mapDbToUiConfig(dbConfig); const uiScheduleConfig = mapDbScheduleToUi(dbConfig.scheduleConfig); const uiCleanupConfig = mapDbCleanupToUi(dbConfig.cleanupConfig); - + return new Response(JSON.stringify({ ...dbConfig, ...uiConfig, @@ -302,6 +379,7 @@ export const GET: APIRoute = async ({ request, locals }) => { lastRun: dbConfig.cleanupConfig.lastRun, nextRun: dbConfig.cleanupConfig.nextRun, }, + notificationConfig: dbConfig.notificationConfig, }), { status: 200, headers: { "Content-Type": "application/json" }, diff --git a/src/pages/api/notifications/test.ts b/src/pages/api/notifications/test.ts new file mode 100644 index 0000000..4ea4dd2 --- /dev/null +++ b/src/pages/api/notifications/test.ts @@ -0,0 +1,42 @@ +import type { APIRoute } from "astro"; +import { requireAuthenticatedUserId } from "@/lib/auth-guards"; +import { testNotification } from "@/lib/notification-service"; +import { notificationConfigSchema } from "@/lib/db/schema"; +import { createSecureErrorResponse } from "@/lib/utils"; + +export const POST: APIRoute = async ({ request, locals }) => { + try { + const authResult = await requireAuthenticatedUserId({ request, locals }); + if ("response" in authResult) return authResult.response; + + const body = await request.json(); + const { notificationConfig } = body; + + if (!notificationConfig) { + return new Response( + JSON.stringify({ success: false, error: "notificationConfig is required" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const parsed = notificationConfigSchema.safeParse(notificationConfig); + if (!parsed.success) { + return new Response( + JSON.stringify({ success: false, error: `Invalid config: ${parsed.error.message}` }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const result = await testNotification(parsed.data); + + return new Response( + JSON.stringify(result), + { + status: result.success ? 200 : 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } catch (error) { + return createSecureErrorResponse(error, "notification test", 500); + } +}; diff --git a/src/types/config.ts b/src/types/config.ts index 260b311..bc4cae9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -85,6 +85,7 @@ export interface SaveConfigApiRequest { giteaConfig: GiteaConfig; scheduleConfig: ScheduleConfig; cleanupConfig: DatabaseCleanupConfig; + notificationConfig?: NotificationConfig; mirrorOptions?: MirrorOptions; advancedOptions?: AdvancedOptions; } @@ -94,6 +95,29 @@ export interface SaveConfigApiResponse { message: string; } +export interface NtfyConfig { + url: string; + topic: string; + token?: string; + priority: "min" | "low" | "default" | "high" | "urgent"; +} + +export interface AppriseConfig { + url: string; + token: string; + tag?: string; +} + +export interface NotificationConfig { + enabled: boolean; + provider: "ntfy" | "apprise"; + notifyOnSyncError: boolean; + notifyOnSyncSuccess: boolean; + notifyOnNewRepo: boolean; + ntfy?: NtfyConfig; + apprise?: AppriseConfig; +} + export interface Config extends ConfigType {} export interface ConfigApiRequest { @@ -109,6 +133,7 @@ export interface ConfigApiResponse { giteaConfig: GiteaConfig; scheduleConfig: ScheduleConfig; cleanupConfig: DatabaseCleanupConfig; + notificationConfig?: NotificationConfig; mirrorOptions?: MirrorOptions; advancedOptions?: AdvancedOptions; include: string[];