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: {
|
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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
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": {
|
"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": {
|
||||||
|
|||||||
@@ -719,6 +719,8 @@
|
|||||||
"success": {
|
"success": {
|
||||||
"title": "مهارت مسدود شد",
|
"title": "مهارت مسدود شد",
|
||||||
"description": "مهارت شما از ایندکس شدن مسدود شد. دیگر در SkillHub نمایش داده نخواهد شد.",
|
"description": "مهارت شما از ایندکس شدن مسدود شد. دیگر در SkillHub نمایش داده نخواهد شد.",
|
||||||
|
"pendingTitle": "درخواست ثبت شد",
|
||||||
|
"pendingDescription": "مخزن در GitHub یافت نشد. درخواست حذف شما برای بررسی ادمین ثبت شد.",
|
||||||
"viewRequests": "مشاهده درخواستهای من"
|
"viewRequests": "مشاهده درخواستهای من"
|
||||||
},
|
},
|
||||||
"addSuccess": {
|
"addSuccess": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
15
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
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',
|
'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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user