diff --git a/apps/web/app/[locale]/claim/page.tsx b/apps/web/app/[locale]/claim/page.tsx index 8a6cfe4..8fddab3 100644 --- a/apps/web/app/[locale]/claim/page.tsx +++ b/apps/web/app/[locale]/claim/page.tsx @@ -64,6 +64,8 @@ export default async function ClaimPage({ success: { title: t('success.title'), description: t('success.description'), + pendingTitle: t('success.pendingTitle'), + pendingDescription: t('success.pendingDescription'), viewRequests: t('success.viewRequests'), }, addSuccess: { diff --git a/apps/web/app/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx index 8d73b20..8ab1b18 100644 --- a/apps/web/app/[locale]/layout.tsx +++ b/apps/web/app/[locale]/layout.tsx @@ -6,6 +6,7 @@ import { locales, localeDirection, type Locale } from '@/i18n'; import { Providers } from '../providers'; import { Suspense } from 'react'; import { QueryNotification } from '@/components/QueryNotification'; +import { ProgressBar } from '@/components/ProgressBar'; import '../globals.css'; export async function generateMetadata({ @@ -70,6 +71,7 @@ export default async function LocaleLayout({ + {children} diff --git a/apps/web/app/api/skills/add-request/route.ts b/apps/web/app/api/skills/add-request/route.ts index 286431f..e674f2f 100644 --- a/apps/web/app/api/skills/add-request/route.ts +++ b/apps/web/app/api/skills/add-request/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; -import { createDb, addRequestQueries, userQueries } from '@skillhub/db'; +import { createDb, addRequestQueries, discoveredRepoQueries, userQueries } from '@skillhub/db'; import { sanitizeReason } from '@/lib/sanitize'; import { sendClaimSubmittedEmail } from '@/lib/email'; @@ -389,6 +389,26 @@ export async function POST(request: NextRequest) { hasSkillMd: validation.hasSkillMd, }); + // Auto-approve and queue for crawling if SKILL.md found + if (validation.hasSkillMd && validation.skillPaths.length > 0) { + await addRequestQueries.updateStatus(db, requestId, { + status: 'approved', + }); + + // Queue repo for indexer crawling via discovered_repos + try { + await discoveredRepoQueries.upsert(db, { + id: `${parsed.owner}/${parsed.repo}`, + owner: parsed.owner, + repo: parsed.repo, + discoveredVia: 'add-request', + githubStars: 0, + }); + } catch (err) { + console.warn('[Claim] Failed to queue repo for crawling:', err); + } + } + // Build appropriate response message let message: string; if (validation.skillPaths.length > 1) { diff --git a/apps/web/app/api/skills/removal-request/route.ts b/apps/web/app/api/skills/removal-request/route.ts index 80c7782..bc74ff3 100644 --- a/apps/web/app/api/skills/removal-request/route.ts +++ b/apps/web/app/api/skills/removal-request/route.ts @@ -124,6 +124,7 @@ export async function POST(request: NextRequest) { // Check repository ownership using public API (no token needed for public repos) let isOwner = false; + let repoDeleted = false; let githubError: string | null = null; try { @@ -144,7 +145,7 @@ export async function POST(request: NextRequest) { isOwner = true; } } else if (repoResponse.status === 404) { - githubError = 'Repository not found on GitHub'; + repoDeleted = true; } else if (repoResponse.status === 403) { githubError = 'GitHub API rate limit exceeded'; } @@ -160,23 +161,34 @@ export async function POST(request: NextRequest) { ); } - if (!isOwner) { + if (!isOwner && !repoDeleted) { return NextResponse.json( { error: 'You are not the owner of this repository', code: 'NOT_OWNER' }, { status: 403 } ); } - // Create the removal request (auto-approved since owner is verified) + // Create the removal request const sanitizedReason = reason ? sanitizeReason(reason) : 'No reason provided'; const requestId = await removalRequestQueries.create(db, { userId: dbUser.id, skillId, reason: sanitizedReason, - verifiedOwner: true, + verifiedOwner: !repoDeleted, }); - // Auto-approve: Block the skill from being re-indexed + if (repoDeleted) { + // Repo not found — submit for admin review instead of auto-approving + return NextResponse.json({ + success: true, + requestId, + pending: true, + blocked: false, + message: 'Repository not found on GitHub. Your removal request has been submitted for admin review.', + }); + } + + // Owner verified — auto-approve: Block the skill from being re-indexed await skillQueries.block(db, skillId); // Update the request status to approved diff --git a/apps/web/components/ClaimForm.tsx b/apps/web/components/ClaimForm.tsx index cbde2a8..2100a2e 100644 --- a/apps/web/components/ClaimForm.tsx +++ b/apps/web/components/ClaimForm.tsx @@ -55,6 +55,8 @@ interface ClaimFormProps { success: { title: string; description: string; + pendingTitle: string; + pendingDescription: string; viewRequests: string; }; addSuccess: { @@ -161,6 +163,7 @@ export function ClaimForm({ translations }: ClaimFormProps) { const [isSubmittingRemove, setIsSubmittingRemove] = useState(false); const [removeError, setRemoveError] = useState(''); const [removeSuccess, setRemoveSuccess] = useState(false); + const [removePending, setRemovePending] = useState(false); const [removalRequests, setRemovalRequests] = useState([]); const [loadingRemovalRequests, setLoadingRemovalRequests] = useState(false); @@ -267,6 +270,7 @@ export function ClaimForm({ translations }: ClaimFormProps) { } setRemoveSuccess(true); + setRemovePending(data.pending || false); setSkillId(''); setRemoveReason(''); fetchRemovalRequests(); @@ -438,11 +442,13 @@ export function ClaimForm({ translations }: ClaimFormProps) { - {translations.success.title} + {removePending ? translations.success.pendingTitle : translations.success.title} - {translations.success.description} + + {removePending ? translations.success.pendingDescription : translations.success.description} + setRemoveSuccess(false)} + onClick={() => { setRemoveSuccess(false); setRemovePending(false); }} className="btn-secondary" > {translations.success.viewRequests} diff --git a/apps/web/components/ProgressBar.tsx b/apps/web/components/ProgressBar.tsx new file mode 100644 index 0000000..fe8de19 --- /dev/null +++ b/apps/web/components/ProgressBar.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { AppProgressBar } from 'next-nprogress-bar'; + +export function ProgressBar() { + return ( + + ); +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index e05cbc5..deff703 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -719,6 +719,8 @@ "success": { "title": "Skill Blocked", "description": "Your skill has been blocked from indexing. It will no longer appear in SkillHub.", + "pendingTitle": "Request Submitted", + "pendingDescription": "The repository was not found on GitHub. Your removal request has been submitted for admin review.", "viewRequests": "View My Requests" }, "addSuccess": { diff --git a/apps/web/messages/fa.json b/apps/web/messages/fa.json index 3802cf5..f772528 100644 --- a/apps/web/messages/fa.json +++ b/apps/web/messages/fa.json @@ -719,6 +719,8 @@ "success": { "title": "مهارت مسدود شد", "description": "مهارت شما از ایندکس شدن مسدود شد. دیگر در SkillHub نمایش داده نخواهد شد.", + "pendingTitle": "درخواست ثبت شد", + "pendingDescription": "مخزن در GitHub یافت نشد. درخواست حذف شما برای بررسی ادمین ثبت شد.", "viewRequests": "مشاهده درخواستهای من" }, "addSuccess": { diff --git a/apps/web/package.json b/apps/web/package.json index 9346d90..661a784 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "next": "^15.0.0", "next-auth": "5.0.0-beta.30", "next-intl": "^3.4.0", + "next-nprogress-bar": "^2.4.7", "next-themes": "^0.4.4", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/core/src/skill-parser.ts b/packages/core/src/skill-parser.ts index 4a48f25..7fb7b0d 100644 --- a/packages/core/src/skill-parser.ts +++ b/packages/core/src/skill-parser.ts @@ -23,6 +23,7 @@ const MAX_NAME_LENGTH = 64; */ function sanitizeUtf8(input: string): string { // Remove null bytes and C0 control characters (except \t \n \r) + // eslint-disable-next-line no-control-regex let result = input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); // Encode to buffer and decode back to strip invalid sequences result = Buffer.from(result, 'utf8').toString('utf8'); diff --git a/packages/db/src/queries.ts b/packages/db/src/queries.ts index 4a6ee2c..59ccda3 100644 --- a/packages/db/src/queries.ts +++ b/packages/db/src/queries.ts @@ -104,18 +104,39 @@ export const skillQueries = { // Also match hyphenated version (spaces replaced with hyphens) const hyphenated = words.join('-'); const wordConditions = words.map(word => - sql`(${skills.name} ILIKE ${`%${word}%`} OR ${skills.description} ILIKE ${`%${word}%`} OR ${skills.githubOwner} ILIKE ${`%${word}%`})` + sql`(${skills.name} ILIKE ${`%${word}%`} OR ${skills.description} ILIKE ${`%${word}%`} OR ${skills.githubOwner} ILIKE ${`%${word}%`} OR ${skills.githubRepo} ILIKE ${`%${word}%`})` ); conditions.push( sql`( (${sql.join(wordConditions, sql` AND `)}) OR ${skills.name} ILIKE ${`%${hyphenated}%`} OR ${skills.description} ILIKE ${`%${hyphenated}%`} + OR ${skills.githubOwner} ILIKE ${`%${hyphenated}%`} + OR ${skills.githubRepo} ILIKE ${`%${hyphenated}%`} )` ); + } else if (query.includes('-')) { + // Single hyphenated term: also search each part independently + // e.g. "pdf-converter" -> search "pdf-converter" OR ("pdf" AND "converter") + const parts = query.split('-').filter(w => w.length > 0); + if (parts.length > 1) { + const partConditions = parts.map(part => + sql`(${skills.name} ILIKE ${`%${part}%`} OR ${skills.description} ILIKE ${`%${part}%`} OR ${skills.githubOwner} ILIKE ${`%${part}%`} OR ${skills.githubRepo} ILIKE ${`%${part}%`})` + ); + conditions.push( + sql`( + (${skills.name} ILIKE ${`%${query}%`} OR ${skills.description} ILIKE ${`%${query}%`} OR ${skills.githubOwner} ILIKE ${`%${query}%`} OR ${skills.githubRepo} ILIKE ${`%${query}%`}) + OR (${sql.join(partConditions, sql` AND `)}) + )` + ); + } else { + conditions.push( + sql`(${skills.name} ILIKE ${`%${query}%`} OR ${skills.description} ILIKE ${`%${query}%`} OR ${skills.githubOwner} ILIKE ${`%${query}%`} OR ${skills.githubRepo} ILIKE ${`%${query}%`})` + ); + } } else { conditions.push( - sql`(${skills.name} ILIKE ${`%${query}%`} OR ${skills.description} ILIKE ${`%${query}%`} OR ${skills.githubOwner} ILIKE ${`%${query}%`})` + sql`(${skills.name} ILIKE ${`%${query}%`} OR ${skills.description} ILIKE ${`%${query}%`} OR ${skills.githubOwner} ILIKE ${`%${query}%`} OR ${skills.githubRepo} ILIKE ${`%${query}%`})` ); } } @@ -225,18 +246,39 @@ export const skillQueries = { if (words.length > 1) { const hyphenated = words.join('-'); const wordConditions = words.map(word => - sql`(${skills.name} ILIKE ${`%${word}%`} OR ${skills.description} ILIKE ${`%${word}%`} OR ${skills.githubOwner} ILIKE ${`%${word}%`})` + sql`(${skills.name} ILIKE ${`%${word}%`} OR ${skills.description} ILIKE ${`%${word}%`} OR ${skills.githubOwner} ILIKE ${`%${word}%`} OR ${skills.githubRepo} ILIKE ${`%${word}%`})` ); conditions.push( sql`( (${sql.join(wordConditions, sql` AND `)}) OR ${skills.name} ILIKE ${`%${hyphenated}%`} OR ${skills.description} ILIKE ${`%${hyphenated}%`} + OR ${skills.githubOwner} ILIKE ${`%${hyphenated}%`} + OR ${skills.githubRepo} ILIKE ${`%${hyphenated}%`} )` ); + } else if (query.includes('-')) { + // Single hyphenated term: also search each part independently + // e.g. "pdf-converter" -> search "pdf-converter" OR ("pdf" AND "converter") + const parts = query.split('-').filter(w => w.length > 0); + if (parts.length > 1) { + const partConditions = parts.map(part => + sql`(${skills.name} ILIKE ${`%${part}%`} OR ${skills.description} ILIKE ${`%${part}%`} OR ${skills.githubOwner} ILIKE ${`%${part}%`} OR ${skills.githubRepo} ILIKE ${`%${part}%`})` + ); + conditions.push( + sql`( + (${skills.name} ILIKE ${`%${query}%`} OR ${skills.description} ILIKE ${`%${query}%`} OR ${skills.githubOwner} ILIKE ${`%${query}%`} OR ${skills.githubRepo} ILIKE ${`%${query}%`}) + OR (${sql.join(partConditions, sql` AND `)}) + )` + ); + } else { + conditions.push( + sql`(${skills.name} ILIKE ${`%${query}%`} OR ${skills.description} ILIKE ${`%${query}%`} OR ${skills.githubOwner} ILIKE ${`%${query}%`} OR ${skills.githubRepo} ILIKE ${`%${query}%`})` + ); + } } else { conditions.push( - sql`(${skills.name} ILIKE ${`%${query}%`} OR ${skills.description} ILIKE ${`%${query}%`} OR ${skills.githubOwner} ILIKE ${`%${query}%`})` + sql`(${skills.name} ILIKE ${`%${query}%`} OR ${skills.description} ILIKE ${`%${query}%`} OR ${skills.githubOwner} ILIKE ${`%${query}%`} OR ${skills.githubRepo} ILIKE ${`%${query}%`})` ); } } @@ -1995,6 +2037,26 @@ export const addRequestQueries = { .where(eq(addRequests.id, id)); }, + /** + * Find approved add requests matching a repository (owner/repo) + */ + findApprovedByRepo: async ( + db: DB, + owner: string, + repo: string + ) => { + const repoUrl = `https://github.com/${owner}/${repo}`; + return db + .select() + .from(addRequests) + .where( + and( + eq(addRequests.repositoryUrl, repoUrl), + eq(addRequests.status, 'approved') + ) + ); + }, + /** * Check if user already has a pending request for a repository + skill path combination * - If skillPath provided: check if that path is in any pending request (exact or in comma-list) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95c87f5..88c4916 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,9 @@ importers: next-intl: specifier: ^3.4.0 version: 3.26.5(next@15.5.9(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + next-nprogress-bar: + specifier: ^2.4.7 + version: 2.4.7 next-themes: specifier: ^0.4.4 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4310,6 +4313,9 @@ packages: next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + next-nprogress-bar@2.4.7: + resolution: {integrity: sha512-OeveNQYFBhQhZ+RgrDnvHNUEQfHCmipymmD4AfAVE9pFV4jeWi7/nNK5f0lIk7ODRrtjyyr/n2YpkRbs5kUoMg==} + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -4371,6 +4377,9 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nprogress-v2@1.1.10: + resolution: {integrity: sha512-MypWLNIPIM07SS0bAc/oac0vhVFz9vAHm7d1sj//Pnf3J03LQ3CuWrlDteIu6exq0fIvkDJ6tUDRWLaifsIt5w==} + oauth4webapi@3.8.3: resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} @@ -9607,6 +9616,10 @@ snapshots: react: 18.3.1 use-intl: 3.26.5(react@18.3.1) + next-nprogress-bar@2.4.7: + dependencies: + nprogress-v2: 1.1.10 + next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -9660,6 +9673,8 @@ snapshots: dependencies: path-key: 4.0.0 + nprogress-v2@1.1.10: {} + oauth4webapi@3.8.3: {} object-assign@4.1.1: {} diff --git a/services/indexer/src/email-notify.ts b/services/indexer/src/email-notify.ts new file mode 100644 index 0000000..33f27b7 --- /dev/null +++ b/services/indexer/src/email-notify.ts @@ -0,0 +1,157 @@ +/** + * Email notification for skill indexing completion + * Sends notification to users who submitted add-requests + */ + +import { Resend } from 'resend'; + +const SITE_URL = + process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live'; +const FROM_EMAIL = + process.env.RESEND_FROM_EMAIL || 'SkillHub '; + +// Lazy-init Resend client (returns null if API key not configured) +let resendClient: Resend | null = null; + +function getResend(): Resend | null { + if (resendClient) return resendClient; + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) return null; + resendClient = new Resend(apiKey); + return resendClient; +} + +interface SkillIndexedDetails { + skillId: string; + skillName: string; + repositoryUrl: string; +} + +const translations = { + en: { + subject: 'Your skill has been indexed on SkillHub!', + greeting: 'Good news!', + intro: (name: string, repo: string) => + `Your skill ${name} from ${repo} has been successfully indexed on SkillHub.`, + status: + 'Your skill is now searchable and installable by developers.', + cta: 'View Skill', + footer: + 'You received this because you submitted an add request on SkillHub.', + }, + fa: { + subject: 'مهارت شما در SkillHub ایندکس شد!', + greeting: 'خبر خوب!', + intro: (name: string, repo: string) => + `مهارت ${name} از مخزن ${repo} با موفقیت در SkillHub ایندکس شد.`, + status: + 'مهارت شما اکنون قابل جستجو و نصب توسط توسعهدهندگان است.', + cta: 'مشاهده مهارت', + footer: + 'این ایمیل به دلیل ثبت درخواست افزودن مهارت شما در SkillHub ارسال شده است.', + }, +} as const; + +function buildHtml( + locale: 'en' | 'fa', + details: SkillIndexedDetails +): string { + const t = translations[locale]; + const isRtl = locale === 'fa'; + const dir = isRtl ? 'rtl' : 'ltr'; + const fontFamily = isRtl ? 'Tahoma, Arial, sans-serif' : 'Arial, sans-serif'; + const encodedId = details.skillId + .split('/') + .map(encodeURIComponent) + .join('/'); + const skillUrl = `${SITE_URL}/${locale}/skill/${encodedId}`; + + return ` + + + + + + + + + + + + + + SkillHub + + + + + + + ${t.greeting} + + + ${t.intro(details.skillName, details.repositoryUrl)} + + + ${t.status} + + + + + + + ${t.cta} + + + + + + + ${t.footer} + + + + + + + + +`; +} + +/** + * Send email notification when a skill has been indexed + */ +export async function sendSkillIndexedEmail( + to: string, + locale: 'en' | 'fa', + details: SkillIndexedDetails +): Promise { + const resend = getResend(); + if (!resend) return false; + + try { + const t = translations[locale]; + const html = buildHtml(locale, details); + + const result = await resend.emails.send({ + from: FROM_EMAIL, + to, + subject: t.subject, + html, + }); + + if (result.error) { + console.error('[Email] Resend error (indexed):', result.error); + return false; + } + + console.log( + `[Email] Sent skill-indexed notification to ${to} for ${details.skillId}` + ); + return true; + } catch (error) { + console.error('[Email] Failed to send indexed email:', error); + return false; + } +} diff --git a/services/indexer/src/meilisearch-sync.ts b/services/indexer/src/meilisearch-sync.ts index c394f60..792d70c 100644 --- a/services/indexer/src/meilisearch-sync.ts +++ b/services/indexer/src/meilisearch-sync.ts @@ -113,6 +113,12 @@ async function initializeIndex(): Promise { 'githubStars:desc', ]); + // Configure separator tokens so hyphens split into individual words + // This means "pdf-converter" is searchable as "pdf converter" + await index.updateSettings({ + separatorTokens: ['-'], + }); + indexInitialized = true; console.log('Meilisearch skills index initialized'); } catch (error) { diff --git a/services/indexer/src/skill-indexer.ts b/services/indexer/src/skill-indexer.ts index c9e3338..06f00ce 100644 --- a/services/indexer/src/skill-indexer.ts +++ b/services/indexer/src/skill-indexer.ts @@ -4,7 +4,8 @@ */ import type { SourceFormat } from 'skillhub-core'; -import { createDb, skillQueries, categoryQueries, type Database } from '@skillhub/db'; +import { createDb, skillQueries, categoryQueries, addRequestQueries, userQueries, type Database } from '@skillhub/db'; +import { sendSkillIndexedEmail } from './email-notify.js'; import type { GitHubCrawler } from './crawler.js'; import { SkillAnalyzer } from './analyzer.js'; import { syncSkillToMeilisearch } from './meilisearch-sync.js'; @@ -117,6 +118,41 @@ export async function indexSkill( indexedAt: new Date(), }); + // Check for matching add-requests and mark as indexed + try { + const matchingRequests = await addRequestQueries.findApprovedByRepo( + database, + source.owner, + source.repo + ); + + for (const request of matchingRequests) { + await addRequestQueries.updateStatus(database, request.id, { + status: 'indexed', + indexedSkillId: skillId, + }); + + // Send email notification + const user = await userQueries.getById(database, request.userId); + if (user?.email) { + const locale = (user.preferredLocale === 'fa' ? 'fa' : 'en') as 'en' | 'fa'; + await sendSkillIndexedEmail( + user.email, + locale, + { + skillId, + skillName: analysis.skill.metadata.name, + repositoryUrl: `https://github.com/${source.owner}/${source.repo}`, + } + ).catch((err: unknown) => { + console.warn(`[Indexer] Failed to send indexed email for ${skillId}:`, err); + }); + } + } + } catch (error) { + console.warn(`[Indexer] Failed to check add-requests for ${skillId}:`, error); + } + // Link skill to categories based on keywords try { const categories = await categoryQueries.linkSkillToCategories(
{translations.success.description}
+ {removePending ? translations.success.pendingDescription : translations.success.description} +
+ ${t.intro(details.skillName, details.repositoryUrl)} +
+ ${t.status} +
+ ${t.footer} +