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:
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
<Suspense fallback={null}>
|
||||
<QueryNotification />
|
||||
</Suspense>
|
||||
<ProgressBar />
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</Providers>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<RemovalRequest[]>([]);
|
||||
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) {
|
||||
<div className="card p-8 text-center">
|
||||
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-success" />
|
||||
<h2 className="text-xl font-semibold text-text-primary mb-2">
|
||||
{translations.success.title}
|
||||
{removePending ? translations.success.pendingTitle : translations.success.title}
|
||||
</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
|
||||
onClick={() => setRemoveSuccess(false)}
|
||||
onClick={() => { setRemoveSuccess(false); setRemovePending(false); }}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{translations.success.viewRequests}
|
||||
|
||||
14
apps/web/components/ProgressBar.tsx
Normal file
14
apps/web/components/ProgressBar.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -719,6 +719,8 @@
|
||||
"success": {
|
||||
"title": "مهارت مسدود شد",
|
||||
"description": "مهارت شما از ایندکس شدن مسدود شد. دیگر در SkillHub نمایش داده نخواهد شد.",
|
||||
"pendingTitle": "درخواست ثبت شد",
|
||||
"pendingDescription": "مخزن در GitHub یافت نشد. درخواست حذف شما برای بررسی ادمین ثبت شد.",
|
||||
"viewRequests": "مشاهده درخواستهای من"
|
||||
},
|
||||
"addSuccess": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
157
services/indexer/src/email-notify.ts
Normal file
157
services/indexer/src/email-notify.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user