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

@@ -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',
]);
// 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) {

View File

@@ -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(