From 8d96e176b4d8a34f5d6b715e154c097bcbe1cd2d Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Mon, 27 Oct 2025 08:44:45 +0530 Subject: [PATCH] fix: prevent duplicate orgs and repos --- drizzle/0007_whole_hellion.sql | 18 + drizzle/meta/0007_snapshot.json | 1999 +++++++++++++++++ drizzle/meta/_journal.json | 7 + .../organizations/AddOrganizationDialog.tsx | 16 +- src/components/organizations/Organization.tsx | 199 +- .../organizations/OrganizationsList.tsx | 60 +- .../repositories/AddRepositoryDialog.tsx | 12 +- src/components/repositories/Repository.tsx | 210 +- .../repositories/RepositoryTable.tsx | 22 +- src/lib/db/schema.ts | 6 + src/lib/repo-utils.test.ts | 2 +- src/lib/repo-utils.ts | 2 +- src/lib/scheduler-service.ts | 16 +- src/pages/api/organizations/[id].ts | 59 +- src/pages/api/repositories/[id].ts | 55 +- src/pages/api/sync/index.ts | 16 +- src/pages/api/sync/organization.ts | 139 +- src/pages/api/sync/repository.ts | 81 +- src/types/Repository.ts | 3 +- src/types/organizations.ts | 3 +- 20 files changed, 2800 insertions(+), 125 deletions(-) create mode 100644 drizzle/0007_whole_hellion.sql create mode 100644 drizzle/meta/0007_snapshot.json diff --git a/drizzle/0007_whole_hellion.sql b/drizzle/0007_whole_hellion.sql new file mode 100644 index 0000000..e501b45 --- /dev/null +++ b/drizzle/0007_whole_hellion.sql @@ -0,0 +1,18 @@ +ALTER TABLE `organizations` ADD `normalized_name` text NOT NULL DEFAULT '';--> statement-breakpoint +UPDATE `organizations` SET `normalized_name` = lower(trim(`name`));--> statement-breakpoint +DELETE FROM `organizations` +WHERE rowid NOT IN ( + SELECT MIN(rowid) + FROM `organizations` + GROUP BY `user_id`, `normalized_name` +);--> statement-breakpoint +CREATE UNIQUE INDEX `uniq_organizations_user_normalized_name` ON `organizations` (`user_id`,`normalized_name`);--> statement-breakpoint +ALTER TABLE `repositories` ADD `normalized_full_name` text NOT NULL DEFAULT '';--> statement-breakpoint +UPDATE `repositories` SET `normalized_full_name` = lower(trim(`full_name`));--> statement-breakpoint +DELETE FROM `repositories` +WHERE rowid NOT IN ( + SELECT MIN(rowid) + FROM `repositories` + GROUP BY `user_id`, `normalized_full_name` +);--> statement-breakpoint +CREATE UNIQUE INDEX `uniq_repositories_user_normalized_full_name` ON `repositories` (`user_id`,`normalized_full_name`); diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..5f31cf3 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1999 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e4d9f5ae-4b18-4529-89fd-b7720822af07", + "prevId": "d27b6aa9-14ae-4271-b9c5-6976efbf70bd", + "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())" + } + }, + "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 + }, + "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 + }, + "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 c3a8f83..3a70084 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1761483928546, "tag": "0006_military_la_nuit", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1761534391115, + "tag": "0007_whole_hellion", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/components/organizations/AddOrganizationDialog.tsx b/src/components/organizations/AddOrganizationDialog.tsx index 8d85ab1..caa1a58 100644 --- a/src/components/organizations/AddOrganizationDialog.tsx +++ b/src/components/organizations/AddOrganizationDialog.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -20,9 +20,11 @@ interface AddOrganizationDialogProps { onAddOrganization: ({ org, role, + force, }: { org: string; role: MembershipRole; + force?: boolean; }) => Promise; } @@ -36,6 +38,14 @@ export default function AddOrganizationDialog({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); + useEffect(() => { + if (!isDialogOpen) { + setError(""); + setOrg(""); + setRole("member"); + } + }, [isDialogOpen]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -54,7 +64,7 @@ export default function AddOrganizationDialog({ setRole("member"); setIsDialogOpen(false); } catch (err: any) { - setError(err?.message || "Failed to add repository."); + setError(err?.message || "Failed to add organization."); } finally { setIsLoading(false); } @@ -139,7 +149,7 @@ export default function AddOrganizationDialog({ {isLoading ? ( ) : ( - "Add Repository" + "Add Organization" )} diff --git a/src/components/organizations/Organization.tsx b/src/components/organizations/Organization.tsx index 7482845..8c4d657 100644 --- a/src/components/organizations/Organization.tsx +++ b/src/components/organizations/Organization.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Search, RefreshCw, FlipHorizontal, Filter } from "lucide-react"; +import { Search, RefreshCw, FlipHorizontal, Filter, LoaderCircle, Trash2 } from "lucide-react"; import type { MirrorJob, Organization } from "@/lib/db/schema"; import { OrganizationList } from "./OrganizationsList"; import AddOrganizationDialog from "./AddOrganizationDialog"; @@ -37,6 +37,14 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; export function Organization() { const [organizations, setOrganizations] = useState([]); @@ -52,6 +60,15 @@ export function Organization() { status: "", }); const [loadingOrgIds, setLoadingOrgIds] = useState>(new Set()); // this is used when the api actions are performed + const [duplicateOrgCandidate, setDuplicateOrgCandidate] = useState<{ + org: string; + role: MembershipRole; + } | null>(null); + const [isDuplicateOrgDialogOpen, setIsDuplicateOrgDialogOpen] = useState(false); + const [isProcessingDuplicateOrg, setIsProcessingDuplicateOrg] = useState(false); + const [orgToDelete, setOrgToDelete] = useState(null); + const [isDeleteOrgDialogOpen, setIsDeleteOrgDialogOpen] = useState(false); + const [isDeletingOrg, setIsDeletingOrg] = useState(false); // Create a stable callback using useCallback const handleNewMessage = useCallback((data: MirrorJob) => { @@ -256,19 +273,45 @@ export function Organization() { const handleAddOrganization = async ({ org, role, + force = false, }: { org: string; role: MembershipRole; + force?: boolean; }) => { - try { - if (!user || !user.id) { - return; + if (!user || !user.id) { + return; + } + + const trimmedOrg = org.trim(); + const normalizedOrg = trimmedOrg.toLowerCase(); + + if (!trimmedOrg) { + toast.error("Please enter a valid organization name."); + throw new Error("Invalid organization name"); + } + + if (!force) { + const alreadyExists = organizations.some( + (existing) => existing.name?.trim().toLowerCase() === normalizedOrg + ); + + if (alreadyExists) { + toast.warning("Organization already exists."); + setDuplicateOrgCandidate({ org: trimmedOrg, role }); + setIsDuplicateOrgDialogOpen(true); + throw new Error("Organization already exists"); } + } + + try { + setIsLoading(true); const reqPayload: AddOrganizationApiRequest = { userId: user.id, - org, + org: trimmedOrg, role, + force, }; const response = await apiRequest( @@ -280,25 +323,100 @@ export function Organization() { ); if (response.success) { - toast.success(`Organization added successfully`); - setOrganizations((prev) => [...prev, response.organization]); + const message = force + ? "Organization already exists; using existing entry." + : "Organization added successfully"; + toast.success(message); - await fetchOrganizations(); + await fetchOrganizations(false); setFilter((prev) => ({ ...prev, - searchTerm: org, + searchTerm: trimmedOrg, })); + + if (force) { + setIsDuplicateOrgDialogOpen(false); + setDuplicateOrgCandidate(null); + } } else { showErrorToast(response.error || "Error adding organization", toast); } } catch (error) { showErrorToast(error, toast); + throw error; } finally { setIsLoading(false); } }; + const handleConfirmDuplicateOrganization = async () => { + if (!duplicateOrgCandidate) { + return; + } + + setIsProcessingDuplicateOrg(true); + try { + await handleAddOrganization({ + org: duplicateOrgCandidate.org, + role: duplicateOrgCandidate.role, + force: true, + }); + setIsDialogOpen(false); + setDuplicateOrgCandidate(null); + setIsDuplicateOrgDialogOpen(false); + } catch (error) { + // Error already surfaced via toast + } finally { + setIsProcessingDuplicateOrg(false); + } + }; + + const handleCancelDuplicateOrganization = () => { + setIsDuplicateOrgDialogOpen(false); + setDuplicateOrgCandidate(null); + }; + + const handleRequestDeleteOrganization = (orgId: string) => { + const org = organizations.find((item) => item.id === orgId); + if (!org) { + toast.error("Organization not found"); + return; + } + + setOrgToDelete(org); + setIsDeleteOrgDialogOpen(true); + }; + + const handleDeleteOrganization = async () => { + if (!user || !user.id || !orgToDelete) { + return; + } + + setIsDeletingOrg(true); + try { + const response = await apiRequest<{ success: boolean; error?: string }>( + `/organizations/${orgToDelete.id}`, + { + method: "DELETE", + } + ); + + if (response.success) { + toast.success(`Removed ${orgToDelete.name} from Gitea Mirror.`); + await fetchOrganizations(false); + } else { + showErrorToast(response.error || "Failed to delete organization", toast); + } + } catch (error) { + showErrorToast(error, toast); + } finally { + setIsDeletingOrg(false); + setIsDeleteOrgDialogOpen(false); + setOrgToDelete(null); + } + }; + const handleMirrorAllOrgs = async () => { try { if (!user || !user.id || organizations.length === 0) { @@ -711,6 +829,7 @@ export function Organization() { onMirror={handleMirrorOrg} onIgnore={handleIgnoreOrg} onAddOrganization={() => setIsDialogOpen(true)} + onDelete={handleRequestDeleteOrganization} onRefresh={async () => { await fetchOrganizations(false); }} @@ -721,6 +840,68 @@ export function Organization() { isDialogOpen={isDialogOpen} setIsDialogOpen={setIsDialogOpen} /> + + { + if (!open) { + handleCancelDuplicateOrganization(); + } + }}> + + + Organization already exists + + {duplicateOrgCandidate?.org ?? "This organization"} is already synced in Gitea Mirror. + Continuing will reuse the existing entry without creating a duplicate. You can remove it later if needed. + + + + + + + + + + { + if (!open) { + setIsDeleteOrgDialogOpen(false); + setOrgToDelete(null); + } + }}> + + + Remove organization from Gitea Mirror? + + {orgToDelete?.name ?? "This organization"} will be deleted from Gitea Mirror only. Nothing will be removed from Gitea; you will need to clean it up manually in Gitea if desired. + + + + + + + + ); } diff --git a/src/components/organizations/OrganizationsList.tsx b/src/components/organizations/OrganizationsList.tsx index db75f24..01497b9 100644 --- a/src/components/organizations/OrganizationsList.tsx +++ b/src/components/organizations/OrganizationsList.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban } from "lucide-react"; +import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, Trash2 } from "lucide-react"; import { SiGithub, SiGitea } from "react-icons/si"; import type { Organization } from "@/lib/db/schema"; import type { FilterParams } from "@/types/filter"; @@ -30,6 +30,7 @@ interface OrganizationListProps { loadingOrgIds: Set; onAddOrganization?: () => void; onRefresh?: () => Promise; + onDelete?: (orgId: string) => void; } // Helper function to get status badge variant and icon @@ -60,6 +61,7 @@ export function OrganizationList({ loadingOrgIds, onAddOrganization, onRefresh, + onDelete, }: OrganizationListProps) { const { giteaConfig } = useGiteaConfig(); @@ -414,7 +416,7 @@ export function OrganizationList({ )} {/* Dropdown menu for additional actions */} - {org.status !== "ignored" && org.status !== "mirroring" && ( + {org.status !== "mirroring" && ( - org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })} - > - - Ignore Organization - + {org.status !== "ignored" && ( + org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })} + > + + Ignore Organization + + )} + {onDelete && ( + <> + {org.status !== "ignored" && } + org.id && onDelete(org.id)} + > + + Delete from Mirror + + + )} )} @@ -561,7 +577,7 @@ export function OrganizationList({ )} {/* Dropdown menu for additional actions */} - {org.status !== "ignored" && org.status !== "mirroring" && ( + {org.status !== "mirroring" && ( - org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })} - > - - Ignore Organization - + {org.status !== "ignored" && ( + org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })} + > + + Ignore Organization + + )} + {onDelete && ( + <> + {org.status !== "ignored" && } + org.id && onDelete(org.id)} + > + + Delete from Mirror + + + )} )} diff --git a/src/components/repositories/AddRepositoryDialog.tsx b/src/components/repositories/AddRepositoryDialog.tsx index f44ec5a..5676db0 100644 --- a/src/components/repositories/AddRepositoryDialog.tsx +++ b/src/components/repositories/AddRepositoryDialog.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -17,9 +17,11 @@ interface AddRepositoryDialogProps { onAddRepository: ({ repo, owner, + force, }: { repo: string; owner: string; + force?: boolean; }) => Promise; } @@ -33,6 +35,14 @@ export default function AddRepositoryDialog({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); + useEffect(() => { + if (!isDialogOpen) { + setError(""); + setRepo(""); + setOwner(""); + } + }, [isDialogOpen]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/components/repositories/Repository.tsx b/src/components/repositories/Repository.tsx index 7ad0252..549abcc 100644 --- a/src/components/repositories/Repository.tsx +++ b/src/components/repositories/Repository.tsx @@ -18,7 +18,7 @@ import { SelectValue, } from "../ui/select"; import { Button } from "@/components/ui/button"; -import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter, Ban, Check } from "lucide-react"; +import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter, Ban, Check, LoaderCircle, Trash2 } from "lucide-react"; import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror"; import { Drawer, @@ -30,6 +30,14 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useSSE } from "@/hooks/useSEE"; import { useFilterParams } from "@/hooks/useFilterParams"; import { toast } from "sonner"; @@ -69,6 +77,15 @@ export default function Repository() { }, [setFilter]); const [loadingRepoIds, setLoadingRepoIds] = useState>(new Set()); // this is used when the api actions are performed + const [duplicateRepoCandidate, setDuplicateRepoCandidate] = useState<{ + owner: string; + repo: string; + } | null>(null); + const [isDuplicateRepoDialogOpen, setIsDuplicateRepoDialogOpen] = useState(false); + const [isProcessingDuplicateRepo, setIsProcessingDuplicateRepo] = useState(false); + const [repoToDelete, setRepoToDelete] = useState(null); + const [isDeleteRepoDialogOpen, setIsDeleteRepoDialogOpen] = useState(false); + const [isDeletingRepo, setIsDeletingRepo] = useState(false); // Create a stable callback using useCallback const handleNewMessage = useCallback((data: MirrorJob) => { @@ -618,19 +635,45 @@ export default function Repository() { const handleAddRepository = async ({ repo, owner, + force = false, }: { repo: string; owner: string; + force?: boolean; }) => { - try { - if (!user || !user.id) { - return; - } + if (!user || !user.id) { + return; + } + const trimmedRepo = repo.trim(); + const trimmedOwner = owner.trim(); + + if (!trimmedRepo || !trimmedOwner) { + toast.error("Please provide both owner and repository name."); + throw new Error("Invalid repository details"); + } + + const normalizedFullName = `${trimmedOwner}/${trimmedRepo}`.toLowerCase(); + + if (!force) { + const duplicateRepo = repositories.find( + (existing) => existing.normalizedFullName?.toLowerCase() === normalizedFullName + ); + + if (duplicateRepo) { + toast.warning("Repository already exists."); + setDuplicateRepoCandidate({ repo: trimmedRepo, owner: trimmedOwner }); + setIsDuplicateRepoDialogOpen(true); + throw new Error("Repository already exists"); + } + } + + try { const reqPayload: AddRepositoriesApiRequest = { userId: user.id, - repo, - owner, + repo: trimmedRepo, + owner: trimmedOwner, + force, }; const response = await apiRequest( @@ -642,20 +685,28 @@ export default function Repository() { ); if (response.success) { - toast.success(`Repository added successfully`); - setRepositories((prevRepos) => [...prevRepos, response.repository]); + const message = force + ? "Repository already exists; metadata refreshed." + : "Repository added successfully"; + toast.success(message); - await fetchRepositories(false); // Manual refresh after adding repository + await fetchRepositories(false); setFilter((prev) => ({ ...prev, - searchTerm: repo, + searchTerm: trimmedRepo, })); + + if (force) { + setDuplicateRepoCandidate(null); + setIsDuplicateRepoDialogOpen(false); + } } else { showErrorToast(response.error || "Error adding repository", toast); } } catch (error) { showErrorToast(error, toast); + throw error; } }; @@ -673,6 +724,71 @@ export default function Repository() { ) ).sort(); + const handleConfirmDuplicateRepository = async () => { + if (!duplicateRepoCandidate) { + return; + } + + setIsProcessingDuplicateRepo(true); + try { + await handleAddRepository({ + repo: duplicateRepoCandidate.repo, + owner: duplicateRepoCandidate.owner, + force: true, + }); + setIsDialogOpen(false); + } catch (error) { + // Error already shown + } finally { + setIsProcessingDuplicateRepo(false); + } + }; + + const handleCancelDuplicateRepository = () => { + setDuplicateRepoCandidate(null); + setIsDuplicateRepoDialogOpen(false); + }; + + const handleRequestDeleteRepository = (repoId: string) => { + const repo = repositories.find((item) => item.id === repoId); + if (!repo) { + toast.error("Repository not found"); + return; + } + + setRepoToDelete(repo); + setIsDeleteRepoDialogOpen(true); + }; + + const handleDeleteRepository = async () => { + if (!user || !user.id || !repoToDelete) { + return; + } + + setIsDeletingRepo(true); + try { + const response = await apiRequest<{ success: boolean; error?: string }>( + `/repositories/${repoToDelete.id}`, + { + method: "DELETE", + } + ); + + if (response.success) { + toast.success(`Removed ${repoToDelete.fullName} from Gitea Mirror.`); + await fetchRepositories(false); + } else { + showErrorToast(response.error || "Failed to delete repository", toast); + } + } catch (error) { + showErrorToast(error, toast); + } finally { + setIsDeletingRepo(false); + setIsDeleteRepoDialogOpen(false); + setRepoToDelete(null); + } + }; + // Determine what actions are available for selected repositories const getAvailableActions = () => { if (selectedRepoIds.size === 0) return []; @@ -1198,6 +1314,7 @@ export default function Repository() { onRefresh={async () => { await fetchRepositories(false); }} + onDelete={handleRequestDeleteRepository} /> )} @@ -1206,6 +1323,77 @@ export default function Repository() { isDialogOpen={isDialogOpen} setIsDialogOpen={setIsDialogOpen} /> + + { + if (!open) { + handleCancelDuplicateRepository(); + } + }} + > + + + Repository already exists + + {duplicateRepoCandidate ? `${duplicateRepoCandidate.owner}/${duplicateRepoCandidate.repo}` : "This repository"} is already tracked in Gitea Mirror. Continuing will refresh the existing entry without creating a duplicate. + + + + + + + + + + { + if (!open) { + setIsDeleteRepoDialogOpen(false); + setRepoToDelete(null); + } + }} + > + + + Remove repository from Gitea Mirror? + + {repoToDelete?.fullName ?? "This repository"} will be deleted from Gitea Mirror only. The mirror on Gitea will remain untouched; remove it manually in Gitea if needed. + + + + + + + + ); } diff --git a/src/components/repositories/RepositoryTable.tsx b/src/components/repositories/RepositoryTable.tsx index c46af7f..f4ac78f 100644 --- a/src/components/repositories/RepositoryTable.tsx +++ b/src/components/repositories/RepositoryTable.tsx @@ -1,7 +1,7 @@ import { useMemo, useRef } from "react"; import Fuse from "fuse.js"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown } from "lucide-react"; +import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, Trash2 } from "lucide-react"; import { SiGithub, SiGitea } from "react-icons/si"; import type { Repository } from "@/lib/db/schema"; import { Button } from "@/components/ui/button"; @@ -23,6 +23,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; @@ -40,6 +41,7 @@ interface RepositoryTableProps { selectedRepoIds: Set; onSelectionChange: (selectedIds: Set) => void; onRefresh?: () => Promise; + onDelete?: (repoId: string) => void; } export default function RepositoryTable({ @@ -56,6 +58,7 @@ export default function RepositoryTable({ selectedRepoIds, onSelectionChange, onRefresh, + onDelete, }: RepositoryTableProps) { const tableParentRef = useRef(null); const { giteaConfig } = useGiteaConfig(); @@ -676,6 +679,7 @@ export default function RepositoryTable({ onSync={() => onSync({ repoId: repo.id ?? "" })} onRetry={() => onRetry({ repoId: repo.id ?? "" })} onSkip={(skip) => onSkip({ repoId: repo.id ?? "", skip })} + onDelete={onDelete && repo.id ? () => onDelete(repo.id as string) : undefined} /> {/* Links */} @@ -786,6 +790,7 @@ function RepoActionButton({ onSync, onRetry, onSkip, + onDelete, }: { repo: { id: string; status: string }; isLoading: boolean; @@ -793,6 +798,7 @@ function RepoActionButton({ onSync: () => void; onRetry: () => void; onSkip: (skip: boolean) => void; + onDelete?: () => void; }) { // For ignored repos, show an "Include" action if (repo.status === "ignored") { @@ -849,7 +855,7 @@ function RepoActionButton({ ); } - // Show primary action with dropdown for skip option + // Show primary action with dropdown for additional actions return (
@@ -886,6 +892,18 @@ function RepoActionButton({ Ignore Repository + {onDelete && ( + <> + + + + Delete from Mirror + + + )} ); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index d922963..3891f66 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -127,6 +127,7 @@ export const repositorySchema = z.object({ configId: z.string(), name: z.string(), fullName: z.string(), + normalizedFullName: z.string(), url: z.url(), cloneUrl: z.url(), owner: z.string(), @@ -209,6 +210,7 @@ export const organizationSchema = z.object({ userId: z.string(), configId: z.string(), name: z.string(), + normalizedName: z.string(), avatarUrl: z.string(), membershipRole: z.enum(["member", "admin", "owner", "billing_manager"]).default("member"), isIncluded: z.boolean().default(true), @@ -334,6 +336,7 @@ export const repositories = sqliteTable("repositories", { .references(() => configs.id), name: text("name").notNull(), fullName: text("full_name").notNull(), + normalizedFullName: text("normalized_full_name").notNull(), url: text("url").notNull(), cloneUrl: text("clone_url").notNull(), owner: text("owner").notNull(), @@ -388,6 +391,7 @@ export const repositories = sqliteTable("repositories", { index("idx_repositories_is_fork").on(table.isForked), index("idx_repositories_is_starred").on(table.isStarred), uniqueIndex("uniq_repositories_user_full_name").on(table.userId, table.fullName), + uniqueIndex("uniq_repositories_user_normalized_full_name").on(table.userId, table.normalizedFullName), ]); export const mirrorJobs = sqliteTable("mirror_jobs", { @@ -438,6 +442,7 @@ export const organizations = sqliteTable("organizations", { .notNull() .references(() => configs.id), name: text("name").notNull(), + normalizedName: text("normalized_name").notNull(), avatarUrl: text("avatar_url").notNull(), @@ -469,6 +474,7 @@ export const organizations = sqliteTable("organizations", { index("idx_organizations_config_id").on(table.configId), index("idx_organizations_status").on(table.status), index("idx_organizations_is_included").on(table.isIncluded), + uniqueIndex("uniq_organizations_user_normalized_name").on(table.userId, table.normalizedName), ]); // ===== Better Auth Tables ===== diff --git a/src/lib/repo-utils.test.ts b/src/lib/repo-utils.test.ts index 6265bd8..60c70a7 100644 --- a/src/lib/repo-utils.test.ts +++ b/src/lib/repo-utils.test.ts @@ -62,6 +62,7 @@ describe('normalizeGitRepoToInsert', () => { expect(insert.description).toBeNull(); expect(insert.lastMirrored).toBeNull(); expect(insert.errorMessage).toBeNull(); + expect(insert.normalizedFullName).toBe(repo.fullName.toLowerCase()); }); }); @@ -72,4 +73,3 @@ describe('calcBatchSizeForInsert', () => { expect(batch * 29).toBeLessThanOrEqual(999); }); }); - diff --git a/src/lib/repo-utils.ts b/src/lib/repo-utils.ts index e33ce43..6f08b78 100644 --- a/src/lib/repo-utils.ts +++ b/src/lib/repo-utils.ts @@ -33,6 +33,7 @@ export function normalizeGitRepoToInsert( configId, name: repo.name, fullName: repo.fullName, + normalizedFullName: repo.fullName.toLowerCase(), url: repo.url, cloneUrl: repo.cloneUrl, owner: repo.owner, @@ -68,4 +69,3 @@ export function calcBatchSizeForInsert(columnCount: number, maxParams = 999): nu const effectiveMax = Math.max(1, maxParams - safety); return Math.max(1, Math.floor(effectiveMax / columnCount)); } - diff --git a/src/lib/scheduler-service.ts b/src/lib/scheduler-service.ts index 8a54ea0..5ce5ae7 100644 --- a/src/lib/scheduler-service.ts +++ b/src/lib/scheduler-service.ts @@ -99,12 +99,12 @@ async function runScheduledSync(config: any): Promise { // Check for new repositories const existingRepos = await db - .select({ fullName: repositories.fullName }) + .select({ normalizedFullName: repositories.normalizedFullName }) .from(repositories) .where(eq(repositories.userId, userId)); - const existingRepoNames = new Set(existingRepos.map(r => r.fullName)); - const newRepos = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName)); + const existingRepoNames = new Set(existingRepos.map(r => r.normalizedFullName)); + const newRepos = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName.toLowerCase())); if (newRepos.length > 0) { console.log(`[Scheduler] Found ${newRepos.length} new repositories for user ${userId}`); @@ -123,7 +123,7 @@ async function runScheduledSync(config: any): Promise { await db .insert(repositories) .values(batch) - .onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); + .onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] }); } console.log(`[Scheduler] Successfully imported ${newRepos.length} new repositories for user ${userId}`); } else { @@ -432,12 +432,12 @@ async function performInitialAutoStart(): Promise { // Check for new repositories const existingRepos = await db - .select({ fullName: repositories.fullName }) + .select({ normalizedFullName: repositories.normalizedFullName }) .from(repositories) .where(eq(repositories.userId, config.userId)); - const existingRepoNames = new Set(existingRepos.map(r => r.fullName)); - const reposToImport = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName)); + const existingRepoNames = new Set(existingRepos.map(r => r.normalizedFullName)); + const reposToImport = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName.toLowerCase())); if (reposToImport.length > 0) { console.log(`[Scheduler] Importing ${reposToImport.length} repositories for user ${config.userId}...`); @@ -456,7 +456,7 @@ async function performInitialAutoStart(): Promise { await db .insert(repositories) .values(batch) - .onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); + .onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] }); } console.log(`[Scheduler] Successfully imported ${reposToImport.length} repositories`); } else { diff --git a/src/pages/api/organizations/[id].ts b/src/pages/api/organizations/[id].ts index 152ccac..9f9d80a 100644 --- a/src/pages/api/organizations/[id].ts +++ b/src/pages/api/organizations/[id].ts @@ -1,5 +1,5 @@ import type { APIRoute } from "astro"; -import { db, organizations } from "@/lib/db"; +import { db, organizations, repositories } from "@/lib/db"; import { eq, and } from "drizzle-orm"; import { createSecureErrorResponse } from "@/lib/utils"; import { requireAuth } from "@/lib/utils/auth-helpers"; @@ -61,3 +61,60 @@ export const PATCH: APIRoute = async (context) => { return createSecureErrorResponse(error, "Update organization destination", 500); } }; + +export const DELETE: APIRoute = async (context) => { + try { + const { user, response } = await requireAuth(context); + if (response) return response; + + const userId = user!.id; + const orgId = context.params.id; + + if (!orgId) { + return new Response( + JSON.stringify({ error: "Organization ID is required" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + const [existingOrg] = await db + .select() + .from(organizations) + .where(and(eq(organizations.id, orgId), eq(organizations.userId, userId))) + .limit(1); + + if (!existingOrg) { + return new Response( + JSON.stringify({ error: "Organization not found" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + } + ); + } + + await db.delete(repositories).where( + and( + eq(repositories.userId, userId), + eq(repositories.organization, existingOrg.name) + ) + ); + + await db + .delete(organizations) + .where(and(eq(organizations.id, orgId), eq(organizations.userId, userId))); + + return new Response( + JSON.stringify({ success: true }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } catch (error) { + return createSecureErrorResponse(error, "Delete organization", 500); + } +}; diff --git a/src/pages/api/repositories/[id].ts b/src/pages/api/repositories/[id].ts index debbc07..d2207cb 100644 --- a/src/pages/api/repositories/[id].ts +++ b/src/pages/api/repositories/[id].ts @@ -1,5 +1,5 @@ import type { APIRoute } from "astro"; -import { db, repositories } from "@/lib/db"; +import { db, repositories, mirrorJobs } from "@/lib/db"; import { eq, and } from "drizzle-orm"; import { createSecureErrorResponse } from "@/lib/utils"; import { requireAuth } from "@/lib/utils/auth-helpers"; @@ -60,4 +60,55 @@ export const PATCH: APIRoute = async (context) => { } catch (error) { return createSecureErrorResponse(error, "Update repository destination", 500); } -}; \ No newline at end of file +}; + +export const DELETE: APIRoute = async (context) => { + try { + const { user, response } = await requireAuth(context); + if (response) return response; + + const userId = user!.id; + const repoId = context.params.id; + + if (!repoId) { + return new Response(JSON.stringify({ error: "Repository ID is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const [existingRepo] = await db + .select() + .from(repositories) + .where(and(eq(repositories.id, repoId), eq(repositories.userId, userId))) + .limit(1); + + if (!existingRepo) { + return new Response( + JSON.stringify({ error: "Repository not found" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + } + ); + } + + await db + .delete(repositories) + .where(and(eq(repositories.id, repoId), eq(repositories.userId, userId))); + + await db + .delete(mirrorJobs) + .where(and(eq(mirrorJobs.repositoryId, repoId), eq(mirrorJobs.userId, userId))); + + return new Response( + JSON.stringify({ success: true }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } catch (error) { + return createSecureErrorResponse(error, "Delete repository", 500); + } +}; diff --git a/src/pages/api/sync/index.ts b/src/pages/api/sync/index.ts index 1f08fdd..2fb6924 100644 --- a/src/pages/api/sync/index.ts +++ b/src/pages/api/sync/index.ts @@ -66,6 +66,7 @@ export const POST: APIRoute = async ({ request }) => { configId: config.id, name: repo.name, fullName: repo.fullName, + normalizedFullName: repo.fullName.toLowerCase(), url: repo.url, cloneUrl: repo.cloneUrl, owner: repo.owner, @@ -97,6 +98,7 @@ export const POST: APIRoute = async ({ request }) => { userId, configId: config.id, name: org.name, + normalizedName: org.name.toLowerCase(), avatarUrl: org.avatarUrl, membershipRole: org.membershipRole, isIncluded: false, @@ -113,22 +115,22 @@ export const POST: APIRoute = async ({ request }) => { await db.transaction(async (tx) => { const [existingRepos, existingOrgs] = await Promise.all([ tx - .select({ fullName: repositories.fullName }) + .select({ normalizedFullName: repositories.normalizedFullName }) .from(repositories) .where(eq(repositories.userId, userId)), tx - .select({ name: organizations.name }) + .select({ normalizedName: organizations.normalizedName }) .from(organizations) .where(eq(organizations.userId, userId)), ]); - const existingRepoNames = new Set(existingRepos.map((r) => r.fullName)); - const existingOrgNames = new Set(existingOrgs.map((o) => o.name)); + const existingRepoNames = new Set(existingRepos.map((r) => r.normalizedFullName)); + const existingOrgNames = new Set(existingOrgs.map((o) => o.normalizedName)); insertedRepos = newRepos.filter( - (r) => !existingRepoNames.has(r.fullName) + (r) => !existingRepoNames.has(r.normalizedFullName) ); - insertedOrgs = newOrgs.filter((o) => !existingOrgNames.has(o.name)); + insertedOrgs = newOrgs.filter((o) => !existingOrgNames.has(o.normalizedName)); // Batch insert repositories to avoid SQLite parameter limit (dynamic by column count) const sample = newRepos[0]; @@ -140,7 +142,7 @@ export const POST: APIRoute = async ({ request }) => { await tx .insert(repositories) .values(batch) - .onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); + .onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] }); } } diff --git a/src/pages/api/sync/organization.ts b/src/pages/api/sync/organization.ts index 6f40e1d..a849399 100644 --- a/src/pages/api/sync/organization.ts +++ b/src/pages/api/sync/organization.ts @@ -1,5 +1,4 @@ import type { APIRoute } from "astro"; -import { Octokit } from "@octokit/rest"; import { configs, db, organizations, repositories } from "@/lib/db"; import { and, eq } from "drizzle-orm"; import { jsonResponse, createSecureErrorResponse } from "@/lib/utils"; @@ -15,7 +14,7 @@ import { createGitHubClient } from "@/lib/github"; export const POST: APIRoute = async ({ request }) => { try { const body: AddOrganizationApiRequest = await request.json(); - const { role, org, userId } = body; + const { role, org, userId, force = false } = body; if (!org || !userId || !role) { return jsonResponse({ @@ -24,21 +23,58 @@ export const POST: APIRoute = async ({ request }) => { }); } - // Check if org already exists - const existingOrg = await db + const trimmedOrg = org.trim(); + const normalizedOrg = trimmedOrg.toLowerCase(); + + // Check if org already exists (case-insensitive) + const [existingOrg] = await db .select() .from(organizations) .where( - and(eq(organizations.name, org), eq(organizations.userId, userId)) - ); + and( + eq(organizations.userId, userId), + eq(organizations.normalizedName, normalizedOrg) + ) + ) + .limit(1); - if (existingOrg.length > 0) { + if (existingOrg && !force) { return jsonResponse({ data: { success: false, error: "Organization already exists for this user", }, - status: 400, + status: 409, + }); + } + + if (existingOrg && force) { + const [updatedOrg] = await db + .update(organizations) + .set({ + membershipRole: role, + normalizedName: normalizedOrg, + updatedAt: new Date(), + }) + .where(eq(organizations.id, existingOrg.id)) + .returning(); + + const resPayload: AddOrganizationApiResponse = { + success: true, + organization: updatedOrg ?? existingOrg, + message: "Organization already exists; using existing record.", + }; + + return jsonResponse({ data: resPayload, status: 200 }); + } + + if (existingOrg) { + return jsonResponse({ + data: { + success: false, + error: "Organization already exists for this user", + }, + status: 409, }); } @@ -71,17 +107,21 @@ export const POST: APIRoute = async ({ request }) => { // Create authenticated Octokit instance with rate limit tracking const githubUsername = decryptedConfig.githubConfig?.owner || undefined; - const octokit = createGitHubClient(decryptedConfig.githubConfig.token, userId, githubUsername); + const octokit = createGitHubClient( + decryptedConfig.githubConfig.token, + userId, + githubUsername + ); // Fetch org metadata - const { data: orgData } = await octokit.orgs.get({ org }); + const { data: orgData } = await octokit.orgs.get({ org: trimmedOrg }); // Fetch repos based on config settings const allRepos = []; // Fetch all repos (public, private, and member) to show in UI const publicRepos = await octokit.paginate(octokit.repos.listForOrg, { - org, + org: trimmedOrg, type: "public", per_page: 100, }); @@ -89,7 +129,7 @@ export const POST: APIRoute = async ({ request }) => { // Always fetch private repos to show them in the UI const privateRepos = await octokit.paginate(octokit.repos.listForOrg, { - org, + org: trimmedOrg, type: "private", per_page: 100, }); @@ -97,7 +137,7 @@ export const POST: APIRoute = async ({ request }) => { // Also fetch member repos (includes private repos the user has access to) const memberRepos = await octokit.paginate(octokit.repos.listForOrg, { - org, + org: trimmedOrg, type: "member", per_page: 100, }); @@ -107,38 +147,44 @@ export const POST: APIRoute = async ({ request }) => { allRepos.push(...uniqueMemberRepos); // Insert repositories - const repoRecords = allRepos.map((repo) => ({ - id: uuidv4(), - userId, - configId, - name: repo.name, - fullName: repo.full_name, - url: repo.html_url, - cloneUrl: repo.clone_url ?? "", - owner: repo.owner.login, - organization: - repo.owner.type === "Organization" ? repo.owner.login : null, - mirroredLocation: "", - destinationOrg: null, - isPrivate: repo.private, - isForked: repo.fork, - forkedFrom: null, - hasIssues: repo.has_issues, - isStarred: false, - isArchived: repo.archived, - size: repo.size, - hasLFS: false, - hasSubmodules: false, - language: repo.language ?? null, - description: repo.description ?? null, - defaultBranch: repo.default_branch ?? "main", - visibility: (repo.visibility ?? "public") as RepositoryVisibility, - status: "imported" as RepoStatus, - lastMirrored: null, - errorMessage: null, - createdAt: repo.created_at ? new Date(repo.created_at) : new Date(), - updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(), - })); + const repoRecords = allRepos.map((repo) => { + const normalizedOwner = repo.owner.login.trim().toLowerCase(); + const normalizedRepoName = repo.name.trim().toLowerCase(); + + return { + id: uuidv4(), + userId, + configId, + name: repo.name, + fullName: repo.full_name, + normalizedFullName: `${normalizedOwner}/${normalizedRepoName}`, + url: repo.html_url, + cloneUrl: repo.clone_url ?? "", + owner: repo.owner.login, + organization: + repo.owner.type === "Organization" ? repo.owner.login : null, + mirroredLocation: "", + destinationOrg: null, + isPrivate: repo.private, + isForked: repo.fork, + forkedFrom: null, + hasIssues: repo.has_issues, + isStarred: false, + isArchived: repo.archived, + size: repo.size, + hasLFS: false, + hasSubmodules: false, + language: repo.language ?? null, + description: repo.description ?? null, + defaultBranch: repo.default_branch ?? "main", + visibility: (repo.visibility ?? "public") as RepositoryVisibility, + status: "imported" as RepoStatus, + lastMirrored: null, + errorMessage: null, + createdAt: repo.created_at ? new Date(repo.created_at) : new Date(), + updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(), + }; + }); // Batch insert repositories to avoid SQLite parameter limit // Compute batch size based on column count @@ -150,7 +196,7 @@ export const POST: APIRoute = async ({ request }) => { await db .insert(repositories) .values(batch) - .onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); + .onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] }); } // Insert organization metadata @@ -159,6 +205,7 @@ export const POST: APIRoute = async ({ request }) => { userId, configId, name: orgData.login, + normalizedName: normalizedOrg, avatarUrl: orgData.avatar_url, membershipRole: role, isIncluded: false, diff --git a/src/pages/api/sync/repository.ts b/src/pages/api/sync/repository.ts index fed0590..96d6c93 100644 --- a/src/pages/api/sync/repository.ts +++ b/src/pages/api/sync/repository.ts @@ -15,7 +15,7 @@ import { createMirrorJob } from "@/lib/helpers"; export const POST: APIRoute = async ({ request }) => { try { const body: AddRepositoriesApiRequest = await request.json(); - const { owner, repo, userId } = body; + const { owner, repo, userId, force = false } = body; if (!owner || !repo || !userId) { return new Response( @@ -27,26 +27,43 @@ export const POST: APIRoute = async ({ request }) => { ); } + const trimmedOwner = owner.trim(); + const trimmedRepo = repo.trim(); + + if (!trimmedOwner || !trimmedRepo) { + return jsonResponse({ + data: { + success: false, + error: "Missing owner, repo, or userId", + }, + status: 400, + }); + } + + const normalizedOwner = trimmedOwner.toLowerCase(); + const normalizedRepo = trimmedRepo.toLowerCase(); + const normalizedFullName = `${normalizedOwner}/${normalizedRepo}`; + // Check if repository with the same owner, name, and userId already exists - const existingRepo = await db + const [existingRepo] = await db .select() .from(repositories) .where( and( - eq(repositories.owner, owner), - eq(repositories.name, repo), - eq(repositories.userId, userId) + eq(repositories.userId, userId), + eq(repositories.normalizedFullName, normalizedFullName) ) - ); + ) + .limit(1); - if (existingRepo.length > 0) { + if (existingRepo && !force) { return jsonResponse({ data: { success: false, error: "Repository with this name and owner already exists for this user", }, - status: 400, + status: 409, }); } @@ -68,14 +85,17 @@ export const POST: APIRoute = async ({ request }) => { const octokit = new Octokit(); // No auth for public repos - const { data: repoData } = await octokit.rest.repos.get({ owner, repo }); + const { data: repoData } = await octokit.rest.repos.get({ + owner: trimmedOwner, + repo: trimmedRepo, + }); - const metadata = { - id: uuidv4(), + const baseMetadata = { userId, configId, name: repoData.name, fullName: repoData.full_name, + normalizedFullName, url: repoData.html_url, cloneUrl: repoData.clone_url, owner: repoData.owner.login, @@ -94,6 +114,37 @@ export const POST: APIRoute = async ({ request }) => { description: repoData.description ?? null, defaultBranch: repoData.default_branch, visibility: (repoData.visibility ?? "public") as RepositoryVisibility, + lastMirrored: existingRepo?.lastMirrored ?? null, + errorMessage: existingRepo?.errorMessage ?? null, + mirroredLocation: existingRepo?.mirroredLocation ?? "", + destinationOrg: existingRepo?.destinationOrg ?? null, + updatedAt: repoData.updated_at + ? new Date(repoData.updated_at) + : new Date(), + }; + + if (existingRepo && force) { + const [updatedRepo] = await db + .update(repositories) + .set({ + ...baseMetadata, + normalizedFullName, + configId, + }) + .where(eq(repositories.id, existingRepo.id)) + .returning(); + + const resPayload: AddRepositoriesApiResponse = { + success: true, + repository: updatedRepo ?? existingRepo, + message: "Repository already exists; metadata refreshed.", + }; + + return jsonResponse({ data: resPayload, status: 200 }); + } + + const metadata = { + id: uuidv4(), status: "imported" as Repository["status"], lastMirrored: null, errorMessage: null, @@ -102,15 +153,13 @@ export const POST: APIRoute = async ({ request }) => { createdAt: repoData.created_at ? new Date(repoData.created_at) : new Date(), - updatedAt: repoData.updated_at - ? new Date(repoData.updated_at) - : new Date(), - }; + ...baseMetadata, + } satisfies Repository; await db .insert(repositories) .values(metadata) - .onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); + .onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] }); createMirrorJob({ userId, diff --git a/src/types/Repository.ts b/src/types/Repository.ts index 7b98625..ab8f2ba 100644 --- a/src/types/Repository.ts +++ b/src/types/Repository.ts @@ -81,11 +81,12 @@ export interface AddRepositoriesApiRequest { userId: string; repo: string; owner: string; + force?: boolean; } export interface AddRepositoriesApiResponse { success: boolean; message: string; - repository: Repository; + repository?: Repository; error?: string; } diff --git a/src/types/organizations.ts b/src/types/organizations.ts index f845d46..2019c41 100644 --- a/src/types/organizations.ts +++ b/src/types/organizations.ts @@ -45,11 +45,12 @@ export interface AddOrganizationApiRequest { userId: string; org: string; role: MembershipRole; + force?: boolean; } export interface AddOrganizationApiResponse { success: boolean; message: string; - organization: Organization; + organization?: Organization; error?: string; }