sync: claim removal fix, auto-crawl email, progress bar, search optimization

- Fix claim removal request flow
- Add email notification on auto-crawl skill discovery
- Add progress bar component
- Optimize Meilisearch sync and search indexing
- Improve skill parser and queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-22 04:58:01 +03:30
parent b05cb21a65
commit 0713607693
15 changed files with 352 additions and 14 deletions

View File

@@ -64,6 +64,8 @@ export default async function ClaimPage({
success: { success: {
title: t('success.title'), title: t('success.title'),
description: t('success.description'), description: t('success.description'),
pendingTitle: t('success.pendingTitle'),
pendingDescription: t('success.pendingDescription'),
viewRequests: t('success.viewRequests'), viewRequests: t('success.viewRequests'),
}, },
addSuccess: { addSuccess: {

View File

@@ -6,6 +6,7 @@ import { locales, localeDirection, type Locale } from '@/i18n';
import { Providers } from '../providers'; import { Providers } from '../providers';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { QueryNotification } from '@/components/QueryNotification'; import { QueryNotification } from '@/components/QueryNotification';
import { ProgressBar } from '@/components/ProgressBar';
import '../globals.css'; import '../globals.css';
export async function generateMetadata({ export async function generateMetadata({
@@ -70,6 +71,7 @@ export default async function LocaleLayout({
<Suspense fallback={null}> <Suspense fallback={null}>
<QueryNotification /> <QueryNotification />
</Suspense> </Suspense>
<ProgressBar />
{children} {children}
</NextIntlClientProvider> </NextIntlClientProvider>
</Providers> </Providers>

View File

@@ -1,7 +1,7 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth'; 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 { sanitizeReason } from '@/lib/sanitize';
import { sendClaimSubmittedEmail } from '@/lib/email'; import { sendClaimSubmittedEmail } from '@/lib/email';
@@ -389,6 +389,26 @@ export async function POST(request: NextRequest) {
hasSkillMd: validation.hasSkillMd, 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 // Build appropriate response message
let message: string; let message: string;
if (validation.skillPaths.length > 1) { if (validation.skillPaths.length > 1) {

View File

@@ -124,6 +124,7 @@ export async function POST(request: NextRequest) {
// Check repository ownership using public API (no token needed for public repos) // Check repository ownership using public API (no token needed for public repos)
let isOwner = false; let isOwner = false;
let repoDeleted = false;
let githubError: string | null = null; let githubError: string | null = null;
try { try {
@@ -144,7 +145,7 @@ export async function POST(request: NextRequest) {
isOwner = true; isOwner = true;
} }
} else if (repoResponse.status === 404) { } else if (repoResponse.status === 404) {
githubError = 'Repository not found on GitHub'; repoDeleted = true;
} else if (repoResponse.status === 403) { } else if (repoResponse.status === 403) {
githubError = 'GitHub API rate limit exceeded'; githubError = 'GitHub API rate limit exceeded';
} }
@@ -160,23 +161,34 @@ export async function POST(request: NextRequest) {
); );
} }
if (!isOwner) { if (!isOwner && !repoDeleted) {
return NextResponse.json( return NextResponse.json(
{ error: 'You are not the owner of this repository', code: 'NOT_OWNER' }, { error: 'You are not the owner of this repository', code: 'NOT_OWNER' },
{ status: 403 } { 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 sanitizedReason = reason ? sanitizeReason(reason) : 'No reason provided';
const requestId = await removalRequestQueries.create(db, { const requestId = await removalRequestQueries.create(db, {
userId: dbUser.id, userId: dbUser.id,
skillId, skillId,
reason: sanitizedReason, 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); await skillQueries.block(db, skillId);
// Update the request status to approved // Update the request status to approved

View File

@@ -55,6 +55,8 @@ interface ClaimFormProps {
success: { success: {
title: string; title: string;
description: string; description: string;
pendingTitle: string;
pendingDescription: string;
viewRequests: string; viewRequests: string;
}; };
addSuccess: { addSuccess: {
@@ -161,6 +163,7 @@ export function ClaimForm({ translations }: ClaimFormProps) {
const [isSubmittingRemove, setIsSubmittingRemove] = useState(false); const [isSubmittingRemove, setIsSubmittingRemove] = useState(false);
const [removeError, setRemoveError] = useState(''); const [removeError, setRemoveError] = useState('');
const [removeSuccess, setRemoveSuccess] = useState(false); const [removeSuccess, setRemoveSuccess] = useState(false);
const [removePending, setRemovePending] = useState(false);
const [removalRequests, setRemovalRequests] = useState<RemovalRequest[]>([]); const [removalRequests, setRemovalRequests] = useState<RemovalRequest[]>([]);
const [loadingRemovalRequests, setLoadingRemovalRequests] = useState(false); const [loadingRemovalRequests, setLoadingRemovalRequests] = useState(false);
@@ -267,6 +270,7 @@ export function ClaimForm({ translations }: ClaimFormProps) {
} }
setRemoveSuccess(true); setRemoveSuccess(true);
setRemovePending(data.pending || false);
setSkillId(''); setSkillId('');
setRemoveReason(''); setRemoveReason('');
fetchRemovalRequests(); fetchRemovalRequests();
@@ -438,11 +442,13 @@ export function ClaimForm({ translations }: ClaimFormProps) {
<div className="card p-8 text-center"> <div className="card p-8 text-center">
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-success" /> <CheckCircle className="w-12 h-12 mx-auto mb-4 text-success" />
<h2 className="text-xl font-semibold text-text-primary mb-2"> <h2 className="text-xl font-semibold text-text-primary mb-2">
{translations.success.title} {removePending ? translations.success.pendingTitle : translations.success.title}
</h2> </h2>
<p className="text-text-secondary mb-6">{translations.success.description}</p> <p className="text-text-secondary mb-6">
{removePending ? translations.success.pendingDescription : translations.success.description}
</p>
<button <button
onClick={() => setRemoveSuccess(false)} onClick={() => { setRemoveSuccess(false); setRemovePending(false); }}
className="btn-secondary" className="btn-secondary"
> >
{translations.success.viewRequests} {translations.success.viewRequests}

View File

@@ -0,0 +1,14 @@
'use client';
import { AppProgressBar } from 'next-nprogress-bar';
export function ProgressBar() {
return (
<AppProgressBar
height="3px"
color="#0284c7"
options={{ showSpinner: false }}
shallowRouting
/>
);
}

View File

@@ -719,6 +719,8 @@
"success": { "success": {
"title": "Skill Blocked", "title": "Skill Blocked",
"description": "Your skill has been blocked from indexing. It will no longer appear in SkillHub.", "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" "viewRequests": "View My Requests"
}, },
"addSuccess": { "addSuccess": {

View File

@@ -719,6 +719,8 @@
"success": { "success": {
"title": "مهارت مسدود شد", "title": "مهارت مسدود شد",
"description": "مهارت شما از ایندکس شدن مسدود شد. دیگر در SkillHub نمایش داده نخواهد شد.", "description": "مهارت شما از ایندکس شدن مسدود شد. دیگر در SkillHub نمایش داده نخواهد شد.",
"pendingTitle": "درخواست ثبت شد",
"pendingDescription": "مخزن در GitHub یافت نشد. درخواست حذف شما برای بررسی ادمین ثبت شد.",
"viewRequests": "مشاهده درخواست‌های من" "viewRequests": "مشاهده درخواست‌های من"
}, },
"addSuccess": { "addSuccess": {

View File

@@ -27,6 +27,7 @@
"next": "^15.0.0", "next": "^15.0.0",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"next-intl": "^3.4.0", "next-intl": "^3.4.0",
"next-nprogress-bar": "^2.4.7",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

View File

@@ -23,6 +23,7 @@ const MAX_NAME_LENGTH = 64;
*/ */
function sanitizeUtf8(input: string): string { function sanitizeUtf8(input: string): string {
// Remove null bytes and C0 control characters (except \t \n \r) // 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, ''); let result = input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
// Encode to buffer and decode back to strip invalid sequences // Encode to buffer and decode back to strip invalid sequences
result = Buffer.from(result, 'utf8').toString('utf8'); result = Buffer.from(result, 'utf8').toString('utf8');

View File

@@ -104,18 +104,39 @@ export const skillQueries = {
// Also match hyphenated version (spaces replaced with hyphens) // Also match hyphenated version (spaces replaced with hyphens)
const hyphenated = words.join('-'); const hyphenated = words.join('-');
const wordConditions = words.map(word => 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( conditions.push(
sql`( sql`(
(${sql.join(wordConditions, sql` AND `)}) (${sql.join(wordConditions, sql` AND `)})
OR ${skills.name} ILIKE ${`%${hyphenated}%`} OR ${skills.name} ILIKE ${`%${hyphenated}%`}
OR ${skills.description} 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 { } else {
conditions.push( 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) { if (words.length > 1) {
const hyphenated = words.join('-'); const hyphenated = words.join('-');
const wordConditions = words.map(word => 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( conditions.push(
sql`( sql`(
(${sql.join(wordConditions, sql` AND `)}) (${sql.join(wordConditions, sql` AND `)})
OR ${skills.name} ILIKE ${`%${hyphenated}%`} OR ${skills.name} ILIKE ${`%${hyphenated}%`}
OR ${skills.description} 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 { } else {
conditions.push( 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)); .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 * 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) * - If skillPath provided: check if that path is in any pending request (exact or in comma-list)

15
pnpm-lock.yaml generated
View File

@@ -187,6 +187,9 @@ importers:
next-intl: next-intl:
specifier: ^3.4.0 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) 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: next-themes:
specifier: ^0.4.4 specifier: ^0.4.4
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 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 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 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: next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies: peerDependencies:
@@ -4371,6 +4377,9 @@ packages:
resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 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: oauth4webapi@3.8.3:
resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==}
@@ -9607,6 +9616,10 @@ snapshots:
react: 18.3.1 react: 18.3.1
use-intl: 3.26.5(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): next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
@@ -9660,6 +9673,8 @@ snapshots:
dependencies: dependencies:
path-key: 4.0.0 path-key: 4.0.0
nprogress-v2@1.1.10: {}
oauth4webapi@3.8.3: {} oauth4webapi@3.8.3: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}

View File

@@ -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 <onboarding@resend.dev>';
// 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 <strong>${name}</strong> from <strong>${repo}</strong> 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) =>
`مهارت <strong>${name}</strong> از مخزن <strong>${repo}</strong> با موفقیت در 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 `<!DOCTYPE html>
<html lang="${locale}" dir="${dir}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:${fontFamily};">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:32px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;">
<!-- Header -->
<tr>
<td style="background-color:#0284c7;padding:24px 32px;border-radius:8px 8px 0 0;">
<h1 style="margin:0;color:#ffffff;font-size:22px;font-family:${fontFamily};">SkillHub</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="background-color:#ffffff;padding:32px;border-radius:0 0 8px 8px;">
<h2 style="margin:0 0 16px;color:#18181b;font-size:20px;font-family:${fontFamily};">
${t.greeting}
</h2>
<p style="margin:0 0 16px;color:#3f3f46;font-size:16px;line-height:1.6;font-family:${fontFamily};">
${t.intro(details.skillName, details.repositoryUrl)}
</p>
<p style="margin:0 0 24px;color:#3f3f46;font-size:16px;line-height:1.6;font-family:${fontFamily};">
${t.status}
</p>
<!-- CTA Button -->
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td style="background-color:#0284c7;border-radius:6px;padding:12px 24px;">
<a href="${skillUrl}" style="color:#ffffff;text-decoration:none;font-size:16px;font-weight:bold;font-family:${fontFamily};">
${t.cta}
</a>
</td>
</tr>
</table>
<!-- Footer -->
<p style="margin:0;color:#a1a1aa;font-size:13px;line-height:1.5;font-family:${fontFamily};">
${t.footer}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
/**
* Send email notification when a skill has been indexed
*/
export async function sendSkillIndexedEmail(
to: string,
locale: 'en' | 'fa',
details: SkillIndexedDetails
): Promise<boolean> {
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;
}
}

View File

@@ -113,6 +113,12 @@ async function initializeIndex(): Promise<void> {
'githubStars:desc', '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; indexInitialized = true;
console.log('Meilisearch skills index initialized'); console.log('Meilisearch skills index initialized');
} catch (error) { } catch (error) {

View File

@@ -4,7 +4,8 @@
*/ */
import type { SourceFormat } from 'skillhub-core'; 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 type { GitHubCrawler } from './crawler.js';
import { SkillAnalyzer } from './analyzer.js'; import { SkillAnalyzer } from './analyzer.js';
import { syncSkillToMeilisearch } from './meilisearch-sync.js'; import { syncSkillToMeilisearch } from './meilisearch-sync.js';
@@ -117,6 +118,41 @@ export async function indexSkill(
indexedAt: new Date(), 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 // Link skill to categories based on keywords
try { try {
const categories = await categoryQueries.linkSkillToCategories( const categories = await categoryQueries.linkSkillToCategories(