chore: import upstream snapshot with attribution
Component Security Validation / Security Audit (push) Has been cancelled
Deploy to Cloudflare Pages / deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:58 +08:00
commit bb5c75ce05
8824 changed files with 1946442 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
# API - Vercel Serverless Functions
Critical infrastructure for claude-code-templates component ecosystem.
## ⚠️ CRITICAL ENDPOINTS
These endpoints are essential for component download metrics. **DO NOT BREAK THEM.**
### `/api/track-download-supabase` 🔴
Tracks every component installation from the CLI tool.
**Used by**: `cli-tool/bin/create-claude-config.js`
**Called on**: Every `--agent`, `--command`, `--mcp`, `--hook`, `--setting`, `--skill` installation
**Database**: Supabase (component_downloads, download_stats)
### `/api/discord/interactions` 🟡
Discord bot for component discovery and search.
**Features**: `/search`, `/info`, `/install`, `/popular`, `/random`
### `/api/claude-code-check` 🟢
Monitors Claude Code releases and sends Discord notifications.
**Frequency**: Every 4 hours (Vercel Cron)
**Database**: Neon (claude_code_versions, claude_code_changes)
## 🧪 Testing
**ALWAYS run tests before deploying:**
```bash
# Run all tests
npm test
# Run only critical endpoint tests
npm run test:api
# Watch mode
npm run test:watch
# With coverage
npm run test:coverage
```
## 🚀 Deployment
### Pre-Deployment Checklist
```bash
# 1. Run validation script (from project root)
./scripts/predeploy-check.sh
# 2. If checks pass, deploy
vercel --prod
```
### Manual Deploy Steps
```bash
# 1. Install dependencies
npm install
# 2. Run tests
npm run test:api
# 3. Deploy
cd ..
vercel --prod
```
## 📁 File Structure
```
api/
├── track-download-supabase.js # Component download tracking (CRITICAL)
├── claude-code-check.js # Claude Code changelog monitor
├── _parser-claude.js # Changelog parser utility
├── discord/
│ └── interactions.js # Discord bot handler
├── claude-code-monitor/
│ ├── README.md # Detailed docs
│ ├── check-version.js # Version checker
│ ├── discord-notifier.js # Discord notifications
│ ├── parser.js # Changelog parser
│ └── webhook.js # NPM webhook handler
├── __tests__/
│ └── endpoints.test.js # Critical endpoint tests
├── jest.config.cjs # Jest configuration
├── package.json # Dependencies & scripts
└── README.md # This file
```
## 🔧 Environment Variables
Required in Vercel Dashboard:
```bash
# Supabase
SUPABASE_URL=xxx
SUPABASE_SERVICE_ROLE_KEY=xxx
# Neon Database
NEON_DATABASE_URL=xxx
# Discord
DISCORD_APP_ID=xxx
DISCORD_BOT_TOKEN=xxx
DISCORD_PUBLIC_KEY=xxx
DISCORD_WEBHOOK_URL_CHANGELOG=xxx
```
## 🐛 Troubleshooting
### Tests Failing?
```bash
# Test against production
API_BASE_URL=https://aitmpl.com npm run test:api
# Check specific endpoint
curl -X POST https://aitmpl.com/api/track-download-supabase \
-H "Content-Type: application/json" \
-d '{"type":"agent","name":"test","path":"test/path"}'
```
### Endpoint Not Found After Deploy?
1. Check Vercel function logs: `vercel logs aitmpl.com --follow`
2. Verify file is in `/api` root (not nested)
3. Ensure proper export: `export default async function handler(req, res) {}`
### No Download Tracking Data?
1. Check Vercel logs
2. Verify environment variables are set
3. Test endpoint manually (see above)
4. Check Supabase table: `select * from component_downloads order by created_at desc limit 10;`
## 📊 Monitoring
### Vercel Dashboard
https://vercel.com/dashboard → aitmpl → Functions
### Real-time Logs
```bash
vercel logs aitmpl.com --follow
```
### Database Queries
**Supabase**:
```sql
SELECT type, name, COUNT(*) as downloads
FROM component_downloads
WHERE download_timestamp > NOW() - INTERVAL '7 days'
GROUP BY type, name
ORDER BY downloads DESC;
```
**Neon**:
```sql
SELECT version, published_at, discord_notified
FROM claude_code_versions
ORDER BY published_at DESC;
```
## 🆘 Emergency Rollback
```bash
# 1. List recent deployments
vercel ls
# 2. Promote previous working deployment
vercel promote <previous-deployment-url>
```
## 📖 More Info
See `../CLAUDE.md` section "API Architecture & Deployment" for detailed documentation.
+421
View File
@@ -0,0 +1,421 @@
/**
* API Endpoints Test Suite
*
* CRITICAL: These tests MUST pass before deploying to production.
* Tracking endpoints are essential for component metrics.
*
* Run: npm run test:api
*/
const axios = require('axios');
// Configuration
const BASE_URL = process.env.API_BASE_URL || 'https://aitmpl.com';
const TIMEOUT = 30000; // 30 seconds
describe('API Endpoints - Critical Tests', () => {
describe('🔴 CRITICAL: Component Download Tracking', () => {
test('POST /api/track-download-supabase should be available', async () => {
const response = await axios.post(
`${BASE_URL}/api/track-download-supabase`,
{
type: 'agent',
name: 'test-agent',
path: 'agents/test',
category: 'test',
cliVersion: '1.0.0'
},
{
timeout: TIMEOUT,
validateStatus: (status) => status < 500 // Accept any non-5xx status
}
);
// Endpoint must respond (can be 200, 400, etc. but NOT 500)
expect(response.status).toBeLessThan(500);
expect(response.status).toBeGreaterThanOrEqual(200);
}, TIMEOUT);
test('POST /api/track-download-supabase should reject requests without data', async () => {
const response = await axios.post(
`${BASE_URL}/api/track-download-supabase`,
{},
{
timeout: TIMEOUT,
validateStatus: () => true // Accept any status
}
);
// Should return 400 Bad Request
expect(response.status).toBe(400);
expect(response.data).toHaveProperty('error');
}, TIMEOUT);
test('POST /api/track-download-supabase should validate component type', async () => {
const response = await axios.post(
`${BASE_URL}/api/track-download-supabase`,
{
type: 'invalid-type',
name: 'test',
path: 'test/path'
},
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
expect(response.status).toBe(400);
expect(response.data.error).toContain('type');
}, TIMEOUT);
});
describe('🟡 Discord Bot Integration', () => {
test('POST /api/discord/interactions should be available', async () => {
const response = await axios.post(
`${BASE_URL}/api/discord/interactions`,
{},
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
// Endpoint should respond (even if Discord validation fails)
expect(response.status).toBeLessThan(500);
}, TIMEOUT);
});
describe('🟢 Claude Code Changelog Monitor', () => {
test('GET /api/claude-code-check should be available', async () => {
const response = await axios.get(
`${BASE_URL}/api/claude-code-check`,
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
// Endpoint should respond
expect(response.status).toBeLessThan(500);
}, TIMEOUT);
});
describe('🔵 Command Usage Tracking', () => {
test('POST /api/track-command-usage should be available', async () => {
const response = await axios.post(
`${BASE_URL}/api/track-command-usage`,
{
command: 'analytics',
cliVersion: '1.26.3',
nodeVersion: 'v18.0.0',
platform: 'darwin',
arch: 'arm64',
sessionId: 'test-session-123',
metadata: { tunnel: false }
},
{
timeout: TIMEOUT,
validateStatus: (status) => status < 500
}
);
// Endpoint must respond (can be 200, 400, etc. but NOT 500)
expect(response.status).toBeLessThan(500);
expect(response.status).toBeGreaterThanOrEqual(200);
}, TIMEOUT);
test('POST /api/track-command-usage should reject invalid commands', async () => {
const response = await axios.post(
`${BASE_URL}/api/track-command-usage`,
{
command: 'invalid-command',
cliVersion: '1.26.3',
nodeVersion: 'v18.0.0',
platform: 'darwin'
},
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
expect(response.status).toBe(400);
expect(response.data).toHaveProperty('error');
expect(response.data.error).toContain('Invalid command');
}, TIMEOUT);
test('POST /api/track-command-usage should reject requests without command', async () => {
const response = await axios.post(
`${BASE_URL}/api/track-command-usage`,
{
cliVersion: '1.26.3',
platform: 'darwin'
},
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
expect(response.status).toBe(400);
expect(response.data).toHaveProperty('error');
}, TIMEOUT);
});
describe('📊 API Health Check', () => {
test('All critical endpoints should respond within 30s', async () => {
const startTime = Date.now();
const endpoints = [
'/api/track-download-supabase',
'/api/track-command-usage',
'/api/discord/interactions',
'/api/claude-code-check'
];
for (const endpoint of endpoints) {
const method = endpoint.includes('track-') || endpoint.includes('discord')
? 'post'
: 'get';
try {
await axios[method](
`${BASE_URL}${endpoint}`,
method === 'post' ? {} : undefined,
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
} catch (error) {
// If it fails due to timeout or connection, test should fail
if (error.code === 'ECONNABORTED' || error.code === 'ENOTFOUND') {
throw new Error(`Endpoint ${endpoint} not responding: ${error.message}`);
}
}
}
const totalTime = Date.now() - startTime;
expect(totalTime).toBeLessThan(30000);
}, 45000); // Total timeout of 45s for this test
});
});
describe('API Endpoints - Functional Tests', () => {
describe('Component Download Tracking - Data Validation', () => {
const validTypes = ['agent', 'command', 'setting', 'hook', 'mcp', 'skill', 'template'];
test('should accept all valid component types', async () => {
for (const type of validTypes) {
const response = await axios.post(
`${BASE_URL}/api/track-download-supabase`,
{
type,
name: `test-${type}`,
path: `${type}s/test`
},
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
// Should be 200 (success) or 500 if DB fails, but NOT 400 (validation)
expect([200, 500]).toContain(response.status);
}
}, TIMEOUT * validTypes.length);
test('should reject names that are too long', async () => {
const longName = 'a'.repeat(300); // More than 255 characters
const response = await axios.post(
`${BASE_URL}/api/track-download-supabase`,
{
type: 'agent',
name: longName,
path: 'agents/test'
},
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
expect(response.status).toBe(400);
}, TIMEOUT);
});
describe('Claude Code Monitor - Parser Tests', () => {
test('GET /api/claude-code-check should return valid structure', async () => {
const response = await axios.get(
`${BASE_URL}/api/claude-code-check`,
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
if (response.status === 200) {
// If successful, should have correct structure
expect(response.data).toHaveProperty('status');
if (response.data.status === 'success') {
expect(response.data).toHaveProperty('version');
expect(response.data).toHaveProperty('changes');
expect(response.data).toHaveProperty('discord');
}
}
}, TIMEOUT);
});
describe('Command Usage Tracking - Data Validation', () => {
const validCommands = [
'chats',
'analytics',
'health-check',
'plugins',
'sandbox',
'agents',
'chats-mobile',
'studio',
'command-stats',
'hook-stats',
'mcp-stats'
];
test('should accept all valid commands', async () => {
for (const command of validCommands) {
const response = await axios.post(
`${BASE_URL}/api/track-command-usage`,
{
command,
cliVersion: '1.26.3',
nodeVersion: 'v18.0.0',
platform: 'darwin',
arch: 'arm64',
sessionId: 'test-session',
metadata: { test: true }
},
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
// Should be 200 (success) or 500 if DB fails, but NOT 400 (validation)
expect([200, 500]).toContain(response.status);
}
}, TIMEOUT * validCommands.length);
test('should handle metadata correctly', async () => {
const response = await axios.post(
`${BASE_URL}/api/track-command-usage`,
{
command: 'analytics',
cliVersion: '1.26.3',
nodeVersion: 'v18.0.0',
platform: 'darwin',
arch: 'arm64',
sessionId: 'test-session',
metadata: {
tunnel: true,
customData: 'test-value'
}
},
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
// Should accept metadata as JSONB
expect([200, 500]).toContain(response.status);
}, TIMEOUT);
test('should reject commands that are too long', async () => {
const longCommand = 'a'.repeat(150);
const response = await axios.post(
`${BASE_URL}/api/track-command-usage`,
{
command: longCommand,
cliVersion: '1.26.3',
platform: 'darwin'
},
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
expect(response.status).toBe(400);
}, TIMEOUT);
test('should handle missing optional fields', async () => {
const response = await axios.post(
`${BASE_URL}/api/track-command-usage`,
{
command: 'analytics'
// Missing optional fields like cliVersion, sessionId, metadata
},
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
// Should still work with just command name
expect([200, 500]).toContain(response.status);
}, TIMEOUT);
});
});
describe('API Security & CORS', () => {
test('endpoints should have correct CORS headers', async () => {
const response = await axios.options(
`${BASE_URL}/api/track-download-supabase`,
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
expect(response.headers['access-control-allow-origin']).toBeDefined();
}, TIMEOUT);
test('endpoints should handle incorrect HTTP methods', async () => {
const response = await axios.get(
`${BASE_URL}/api/track-download-supabase`,
{
timeout: TIMEOUT,
validateStatus: () => true
}
);
// track-download only accepts POST, should return 405
expect(response.status).toBe(405);
}, TIMEOUT);
});
+26
View File
@@ -0,0 +1,26 @@
import { verifyToken } from '@clerk/backend';
/**
* Extract and verify Clerk JWT from Authorization header.
* Returns the userId on success, or sends an error response and returns null.
*/
export async function authenticateRequest(req, res) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid Authorization header' });
return null;
}
const token = authHeader.slice(7);
try {
const payload = await verifyToken(token, {
secretKey: process.env.CLERK_SECRET_KEY,
});
return payload.sub;
} catch (err) {
console.error('Clerk token verification failed:', err.message);
res.status(401).json({ error: 'Invalid or expired token' });
return null;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { neon } from '@neondatabase/serverless';
export function getNeonClient() {
const connectionString = process.env.NEON_DATABASE_URL;
if (!connectionString) {
throw new Error('NEON_DATABASE_URL not configured');
}
return neon(connectionString);
}
+288
View File
@@ -0,0 +1,288 @@
// Parser del CHANGELOG.md de Claude Code
/**
* Extrae la sección de una versión específica del changelog
* @param {string} changelog - Contenido completo del CHANGELOG.md
* @param {string} version - Versión a extraer (ej: "2.0.31")
* @returns {object} - Información parseada de la versión
*/
export function parseVersionChangelog(changelog, version) {
// Normalizar versión (remover 'v' si existe)
const cleanVersion = version.replace(/^v/, '');
// Buscar el inicio de esta versión
const versionRegex = new RegExp(`^##\\s+(?:v)?${cleanVersion.replace(/\./g, '\\.')}`, 'm');
const match = changelog.match(versionRegex);
if (!match) {
return {
version: cleanVersion,
content: null,
changes: [],
error: 'Version not found in changelog'
};
}
// Encontrar el índice de inicio
const startIndex = match.index;
// Encontrar el índice de fin (siguiente versión o fin del documento)
const nextVersionRegex = /^##\s+(?:v)?\d+\.\d+\.\d+/m;
const remainingChangelog = changelog.substring(startIndex + match[0].length);
const nextMatch = remainingChangelog.match(nextVersionRegex);
const endIndex = nextMatch
? startIndex + match[0].length + nextMatch.index
: changelog.length;
// Extraer contenido de esta versión
const versionContent = changelog.substring(startIndex, endIndex).trim();
// Parsear los cambios
const changes = parseChanges(versionContent);
return {
version: cleanVersion,
content: versionContent,
changes,
changeCount: changes.length
};
}
/**
* Parsea los cambios individuales de una sección de versión
* @param {string} content - Contenido de la sección de versión
* @returns {Array} - Array de cambios parseados
*/
function parseChanges(content) {
const changes = [];
// Dividir por líneas
const lines = content.split('\n');
let currentCategory = null;
for (const line of lines) {
const trimmed = line.trim();
// Ignorar líneas vacías y headers de versión
if (!trimmed || trimmed.startsWith('##')) {
continue;
}
// Detectar categorías (opcional, por si usan headers)
if (trimmed.startsWith('###')) {
currentCategory = trimmed.replace(/^###\s*/, '').trim();
continue;
}
// Detectar bullets de cambios
if (trimmed.startsWith('-') || trimmed.startsWith('*')) {
const description = trimmed.replace(/^[-*]\s*/, '').trim();
if (!description) continue;
// Intentar clasificar el tipo de cambio
const changeType = classifyChange(description);
changes.push({
type: changeType,
description,
category: currentCategory || detectCategory(description),
raw: line
});
}
}
return changes;
}
/**
* Clasifica un cambio por tipo basándose en keywords
* @param {string} description - Descripción del cambio
* @returns {string} - Tipo de cambio
*/
function classifyChange(description) {
const lower = description.toLowerCase();
// Breaking changes
if (lower.includes('breaking') || lower.includes('removed') || lower.includes('deprecated')) {
return 'breaking';
}
// Features
if (
lower.includes('add') ||
lower.includes('new') ||
lower.includes('introduce') ||
lower.includes('support for') ||
lower.startsWith('✨') ||
lower.startsWith('🎉')
) {
return 'feature';
}
// Fixes
if (
lower.includes('fix') ||
lower.includes('resolve') ||
lower.includes('correct') ||
lower.includes('patch') ||
lower.startsWith('🐛')
) {
return 'fix';
}
// Improvements
if (
lower.includes('improve') ||
lower.includes('enhance') ||
lower.includes('optimize') ||
lower.includes('better') ||
lower.includes('refactor') ||
lower.startsWith('⚡') ||
lower.startsWith('♻️')
) {
return 'improvement';
}
// Deprecations
if (lower.includes('deprecate')) {
return 'deprecation';
}
// Performance
if (lower.includes('performance') || lower.includes('speed') || lower.includes('faster')) {
return 'performance';
}
// Documentation
if (lower.includes('docs') || lower.includes('documentation')) {
return 'documentation';
}
// Default: other
return 'other';
}
/**
* Detecta la categoría de un cambio basándose en keywords
* @param {string} description - Descripción del cambio
* @returns {string|null} - Categoría detectada
*/
function detectCategory(description) {
const lower = description.toLowerCase();
const categories = {
'Plugin System': ['plugin', 'plugins', 'marketplace'],
'CLI': ['cli', 'command', 'terminal', 'bash'],
'Performance': ['performance', 'speed', 'faster', 'optimize', 'cache'],
'UI/UX': ['ui', 'ux', 'interface', 'display', 'output'],
'API': ['api', 'endpoint', 'rest', 'graphql'],
'Models': ['model', 'sonnet', 'opus', 'haiku', 'claude'],
'MCP': ['mcp', 'model context protocol'],
'Agents': ['agent', 'subagent', 'explore'],
'Settings': ['setting', 'config', 'configuration'],
'Hooks': ['hook', 'trigger', 'event'],
'Security': ['security', 'auth', 'authentication', 'permission'],
'Documentation': ['docs', 'documentation', 'readme'],
'Windows': ['windows', 'win32'],
'macOS': ['macos', 'darwin', 'mac'],
'Linux': ['linux', 'unix']
};
for (const [category, keywords] of Object.entries(categories)) {
if (keywords.some(keyword => lower.includes(keyword))) {
return category;
}
}
return null;
}
/**
* Genera un resumen de los cambios
* @param {Array} changes - Array de cambios
* @returns {object} - Resumen estadístico
*/
export function generateSummary(changes) {
const summary = {
total: changes.length,
byType: {},
byCategory: {},
highlights: []
};
// Contar por tipo
changes.forEach(change => {
summary.byType[change.type] = (summary.byType[change.type] || 0) + 1;
if (change.category) {
summary.byCategory[change.category] = (summary.byCategory[change.category] || 0) + 1;
}
// Detectar highlights (breaking changes o features importantes)
if (change.type === 'breaking' || change.type === 'feature') {
summary.highlights.push(change);
}
});
return summary;
}
/**
* Formatea los cambios para Discord
* @param {Array} changes - Array de cambios
* @param {number} maxLength - Longitud máxima del texto
* @returns {object} - Cambios agrupados para Discord
*/
export function formatForDiscord(changes, maxLength = 1024) {
const grouped = {
features: [],
fixes: [],
improvements: [],
breaking: [],
other: []
};
// Agrupar cambios
changes.forEach(change => {
switch (change.type) {
case 'feature':
grouped.features.push(change.description);
break;
case 'fix':
grouped.fixes.push(change.description);
break;
case 'improvement':
case 'performance':
grouped.improvements.push(change.description);
break;
case 'breaking':
case 'deprecation':
grouped.breaking.push(change.description);
break;
default:
grouped.other.push(change.description);
}
});
// Truncar si es necesario
const truncate = (items, max) => {
const text = items.map(item => `${item}`).join('\n');
if (text.length > max) {
const truncated = text.substring(0, max - 20);
const lastNewline = truncated.lastIndexOf('\n');
return truncated.substring(0, lastNewline) + '\n... [Read more in changelog]';
}
return text;
};
return {
features: truncate(grouped.features, maxLength),
fixes: truncate(grouped.fixes, maxLength),
improvements: truncate(grouped.improvements, maxLength),
breaking: truncate(grouped.breaking, maxLength),
other: truncate(grouped.other, maxLength)
};
}
+302
View File
@@ -0,0 +1,302 @@
// Endpoint principal: Verifica nueva versión y notifica a Discord
// Este endpoint hace todo el proceso completo en una sola llamada
import { neon } from '@neondatabase/serverless';
import axios from 'axios';
import { parseVersionChangelog, formatForDiscord, generateSummary } from './_parser-claude.js';
const NPM_PACKAGE = '@anthropic-ai/claude-code';
const CHANGELOG_URL = 'https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md';
// Obtener la última versión de NPM
async function getLatestNPMVersion() {
const response = await axios.get(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`);
return {
version: response.data.version,
publishedAt: response.data.time?.modified || new Date().toISOString(),
npmUrl: `https://www.npmjs.com/package/${NPM_PACKAGE}/v/${response.data.version}`
};
}
// Enviar a Discord
async function sendToDiscord(versionData, parsed, formatted, summary) {
const webhookUrl = process.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL;
if (!webhookUrl) {
throw new Error('Discord webhook URL not configured');
}
const embed = {
title: `🚀 Claude Code ${versionData.version} Released`,
description: `A new version of Claude Code is available with **${summary.total} changes**!`,
url: versionData.githubUrl,
color: 0x8B5CF6, // Purple
fields: [],
footer: {
text: 'Claude Code Changelog Monitor',
icon_url: 'https://avatars.githubusercontent.com/u/100788936?s=200&v=4'
},
timestamp: new Date().toISOString()
};
// Breaking changes
if (formatted.breaking && formatted.breaking.length > 0) {
embed.fields.push({
name: '⚠️ Breaking Changes',
value: formatted.breaking,
inline: false
});
}
// Features
if (formatted.features && formatted.features.length > 0) {
embed.fields.push({
name: '✨ New Features',
value: formatted.features,
inline: false
});
}
// Improvements
if (formatted.improvements && formatted.improvements.length > 0) {
embed.fields.push({
name: '⚡ Improvements',
value: formatted.improvements,
inline: false
});
}
// Bug Fixes
if (formatted.fixes && formatted.fixes.length > 0) {
embed.fields.push({
name: '🐛 Bug Fixes',
value: formatted.fixes,
inline: false
});
}
// Installation
embed.fields.push({
name: '📦 Installation',
value: `\`\`\`bash\nnpm install -g @anthropic-ai/claude-code@${versionData.version}\n\`\`\``,
inline: false
});
// Links
embed.fields.push({
name: '🔗 Links',
value: `[NPM Package](${versionData.npmUrl}) • [Full Changelog](${versionData.githubUrl})`,
inline: false
});
const payload = {
username: 'Claude Code Monitor',
avatar_url: 'https://raw.githubusercontent.com/anthropics/claude-code/main/assets/icon.png',
embeds: [embed]
};
const response = await axios.post(webhookUrl, payload);
return { success: true, status: response.status, payload };
}
// Handler principal
export default async function handler(req, res) {
// CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).json({ ok: true });
}
try {
const sql = neon(process.env.NEON_DATABASE_URL);
console.log('🔍 Checking for new Claude Code version...');
// 1. Obtener última versión de NPM
const latestVersion = await getLatestNPMVersion();
console.log(`📦 Latest NPM version: ${latestVersion.version}`);
// 2. Verificar si ya fue procesada
const existing = await sql`
SELECT id, discord_notified
FROM claude_code_versions
WHERE version = ${latestVersion.version}
`;
if (existing.length > 0 && existing[0].discord_notified) {
console.log(`✓ Version ${latestVersion.version} already processed and notified`);
await sql`
UPDATE monitoring_metadata
SET last_check_at = NOW(), last_version_found = ${latestVersion.version}
WHERE id = 1
`;
return res.status(200).json({
status: 'already_processed',
version: latestVersion.version,
message: 'Version already notified to Discord'
});
}
console.log(`🆕 New version detected: ${latestVersion.version}`);
// 3. Obtener changelog
const changelogResponse = await axios.get(CHANGELOG_URL);
const fullChangelog = changelogResponse.data;
// 4. Parsear changelog de esta versión
const parsed = parseVersionChangelog(fullChangelog, latestVersion.version);
if (!parsed.content) {
throw new Error(`Could not find version ${latestVersion.version} in changelog`);
}
console.log(`📝 Parsed ${parsed.changeCount} changes`);
// 5. Formatear para Discord
const formatted = formatForDiscord(parsed.changes);
const summary = generateSummary(parsed.changes);
// 6. Guardar en base de datos
const githubUrl = `https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md#${latestVersion.version.replace(/\./g, '')}`;
const versionData = {
version: latestVersion.version,
publishedAt: latestVersion.publishedAt,
npmUrl: latestVersion.npmUrl,
githubUrl,
changelogContent: fullChangelog.substring(0, 50000)
};
let versionId;
if (existing.length > 0) {
// Ya existe pero no fue notificada
versionId = existing[0].id;
console.log(`📝 Updating existing version record (ID: ${versionId})`);
} else {
// Crear nueva entrada
const insertResult = await sql`
INSERT INTO claude_code_versions (
version,
published_at,
changelog_content,
npm_url,
github_url,
discord_notified
) VALUES (
${versionData.version},
${versionData.publishedAt},
${versionData.changelogContent},
${versionData.npmUrl},
${versionData.githubUrl},
false
)
RETURNING id
`;
versionId = insertResult[0].id;
console.log(`💾 Version saved to database (ID: ${versionId})`);
}
// 7. Guardar cambios individuales
for (const change of parsed.changes) {
await sql`
INSERT INTO claude_code_changes (
version_id,
change_type,
description,
category
) VALUES (
${versionId},
${change.type},
${change.description},
${change.category}
)
`;
}
console.log(`💾 Saved ${parsed.changes.length} individual changes`);
// 8. Enviar a Discord
console.log('📢 Sending Discord notification...');
const discordResult = await sendToDiscord(versionData, parsed, formatted, summary);
console.log('✅ Discord notification sent successfully!');
// 9. Guardar log de notificación
await sql`
INSERT INTO discord_notifications_log (
version_id,
webhook_url,
payload,
response_status,
response_body
) VALUES (
${versionId},
${process.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL},
${JSON.stringify(discordResult.payload)},
${discordResult.status},
${'Success'}
)
`;
// 10. Marcar como notificada
await sql`
UPDATE claude_code_versions
SET discord_notified = true, discord_notification_sent_at = NOW()
WHERE id = ${versionId}
`;
// 11. Actualizar metadata
await sql`
UPDATE monitoring_metadata
SET
last_check_at = NOW(),
last_version_found = ${latestVersion.version},
check_count = check_count + 1
WHERE id = 1
`;
console.log('🎉 Process completed successfully!');
return res.status(200).json({
status: 'success',
version: latestVersion.version,
versionId,
changes: {
total: summary.total,
byType: summary.byType
},
discord: {
sent: true,
status: discordResult.status
}
});
} catch (error) {
console.error('❌ Error:', error);
// Actualizar metadata con error
try {
const sql = neon(process.env.NEON_DATABASE_URL);
await sql`
UPDATE monitoring_metadata
SET
last_check_at = NOW(),
error_count = error_count + 1,
last_error = ${error.message}
WHERE id = 1
`;
} catch (metaError) {
console.error('Failed to update metadata:', metaError);
}
return res.status(500).json({
error: 'Internal server error',
message: error.message,
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
}
}
+398
View File
@@ -0,0 +1,398 @@
# Claude Code Changelog Monitor
Sistema automatizado para monitorear releases de Claude Code y enviar notificaciones a Discord.
## 🚀 Características
-**Detección automática** de nuevas versiones en NPM
-**Parseo inteligente** del CHANGELOG.md
-**Notificaciones Discord** con embeds formateados
-**Base de datos Neon** para tracking y logs
-**Clasificación automática** de cambios (features, fixes, improvements)
-**Sin cron jobs** - trigger directo desde NPM webhooks o manual
## 📋 Arquitectura
```
NPM Release
[Vercel Function] /api/claude-code-monitor
[Fetch CHANGELOG.md] ← GitHub
[Parse Changes] → Clasificar por tipo
[Save to Neon DB]
[Send to Discord] → Webhook
[Log Result]
```
## 🛠️ Setup
### 1. Crear Base de Datos en Neon
1. Ve a [Neon Console](https://console.neon.tech/)
2. Crea un nuevo proyecto
3. Copia la connection string
4. Ejecuta el script de migración:
```bash
psql "YOUR_NEON_CONNECTION_STRING" < database/migrations/001_create_claude_code_versions.sql
```
### 2. Configurar Variables de Entorno
En Vercel, agrega estas variables:
```bash
# Neon Database
NEON_DATABASE_URL=postgresql://user:password@host/database?sslmode=require
# Discord Webhook (específico para Claude Code changelog)
DISCORD_WEBHOOK_URL_CHANGELOG=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN
# O usa el webhook general (fallback)
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN
```
### 3. Deploy a Vercel
```bash
npm install
vercel --prod
```
## 📡 Endpoints
### `GET/POST /api/claude-code-monitor`
Endpoint principal que hace todo el proceso:
1. Verifica última versión en NPM
2. Descarga CHANGELOG.md
3. Parsea cambios
4. Guarda en Neon DB
5. Envía a Discord
6. Registra logs
**Respuesta exitosa:**
```json
{
"status": "success",
"version": "2.0.31",
"versionId": 1,
"changes": {
"total": 15,
"byType": {
"feature": 8,
"fix": 5,
"improvement": 2
}
},
"discord": {
"sent": true,
"status": 200
}
}
```
**Respuesta si ya fue procesada:**
```json
{
"status": "already_processed",
"version": "2.0.31",
"message": "Version already notified to Discord"
}
```
### `POST /api/claude-code-monitor/webhook`
Webhook para recibir notificaciones de NPM (alternativa).
### `POST /api/claude-code-monitor/discord-notifier`
Procesa y notifica una versión ya guardada en la DB.
**Body:**
```json
{
"versionId": 1
}
```
## 🔄 Configurar Trigger Automático
### Opción 1: NPM Webhooks (Recomendada)
NPM puede enviar webhooks cuando se publica un paquete, pero requiere cuenta Pro.
Si tienes NPM Pro:
1. Ve a la configuración del paquete
2. Agrega webhook: `https://your-domain.vercel.app/api/claude-code-monitor/webhook`
### Opción 2: GitHub Actions (Gratis)
Crea `.github/workflows/check-claude-code.yml`:
```yaml
name: Check Claude Code Updates
on:
schedule:
- cron: '0 */4 * * *' # Cada 4 horas
workflow_dispatch: # Manual trigger
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Trigger Vercel Function
run: |
curl -X POST https://your-domain.vercel.app/api/claude-code-monitor
```
### Opción 3: Vercel Cron Jobs
En `vercel.json`:
```json
{
"crons": [
{
"path": "/api/claude-code-monitor",
"schedule": "0 */4 * * *"
}
]
}
```
### Opción 4: Manual
Simplemente abre en el navegador o haz un GET request:
```bash
curl https://your-domain.vercel.app/api/claude-code-monitor
```
## 📊 Schema de Base de Datos
### `claude_code_versions`
Almacena todas las versiones detectadas.
| Campo | Tipo | Descripción |
| ------------------------------- | --------- | -------------------------------- |
| id | SERIAL | ID único |
| version | VARCHAR | Número de versión (ej: "2.0.31") |
| published_at | TIMESTAMP | Fecha de publicación |
| changelog_content | TEXT | Contenido completo del changelog |
| npm_url | VARCHAR | URL del paquete en NPM |
| github_url | VARCHAR | URL del changelog en GitHub |
| discord_notified | BOOLEAN | Si ya se notificó a Discord |
| discord_notification_sent_at | TIMESTAMP | Cuándo se envió la notificación |
### `claude_code_changes`
Cambios individuales parseados.
| Campo | Tipo | Descripción |
| ----------- | ------- | ----------------------------------------- |
| id | SERIAL | ID único |
| version_id | INTEGER | FK a claude_code_versions |
| change_type | VARCHAR | feature, fix, improvement, breaking, etc. |
| description | TEXT | Descripción del cambio |
| category | VARCHAR | Plugin System, CLI, Performance, etc. |
### `discord_notifications_log`
Log de todas las notificaciones enviadas.
| Campo | Tipo | Descripción |
| --------------- | --------- | ----------------------------- |
| id | SERIAL | ID único |
| version_id | INTEGER | FK a claude_code_versions |
| webhook_url | VARCHAR | URL del webhook usado |
| payload | JSONB | Payload completo enviado |
| response_status | INTEGER | HTTP status code de respuesta |
| response_body | TEXT | Respuesta del webhook |
| error_message | TEXT | Error si hubo |
| sent_at | TIMESTAMP | Cuándo se envió |
### `monitoring_metadata`
Metadata del sistema de monitoreo.
| Campo | Tipo | Descripción |
| ------------------ | --------- | -------------------------------- |
| id | SERIAL | ID único (siempre 1) |
| last_check_at | TIMESTAMP | Última verificación |
| last_version_found | VARCHAR | Última versión encontrada |
| check_count | INTEGER | Número de verificaciones |
| error_count | INTEGER | Número de errores |
| last_error | TEXT | Último error (si hubo) |
## 🧪 Testing
### Test Manual
```bash
# Verificar endpoint
curl https://your-domain.vercel.app/api/claude-code-monitor
# Ver respuesta completa
curl -v https://your-domain.vercel.app/api/claude-code-monitor
```
### Test Local
```bash
# Instalar dependencias
cd api
npm install
# Configurar .env
echo "NEON_DATABASE_URL=your_connection_string" > .env
echo "DISCORD_WEBHOOK_URL=your_webhook_url" >> .env
# Ejecutar con Vercel Dev
vercel dev
# En otra terminal
curl http://localhost:3000/api/claude-code-monitor
```
## 📝 Parser de Changelog
El parser clasifica automáticamente los cambios:
### Tipos de Cambios
- **feature**: Add, New, Introduce, Support for
- **fix**: Fix, Resolve, Correct, Patch
- **improvement**: Improve, Enhance, Optimize, Better
- **breaking**: Breaking, Removed, Deprecated
- **performance**: Performance, Speed, Faster
- **documentation**: Docs, Documentation
### Categorías Detectadas
- Plugin System
- CLI
- Performance
- UI/UX
- API
- Models (Sonnet, Opus, Haiku)
- MCP (Model Context Protocol)
- Agents/Subagents
- Settings
- Hooks
- Security
- Platform-specific (Windows, macOS, Linux)
## 🎨 Formato de Discord
El embed incluye:
- **Título**: 🚀 Claude Code [version] Released
- **Color**: Purple (#8B5CF6) - color de Claude
- **Fields**:
- ⚠️ Breaking Changes (si hay)
- ✨ New Features
- ⚡ Improvements
- 🐛 Bug Fixes
- 📦 Installation command
- 🔗 Links (NPM + GitHub)
## 🐛 Troubleshooting
### Error: "NEON_DATABASE_URL not configured"
**Solución**: Agrega la variable de entorno en Vercel Settings → Environment Variables
### Error: "Discord webhook URL not configured"
**Solución**: Agrega `DISCORD_WEBHOOK_URL_CHANGELOG` o `DISCORD_WEBHOOK_URL`
### Error: "Version not found in changelog"
**Causa**: La versión en NPM aún no está en el CHANGELOG.md de GitHub
**Solución**: Esperar a que Anthropic actualice el changelog, o verificar manualmente
### Notificación duplicada
**Causa**: El sistema está configurado con múltiples triggers
**Solución**: Revisa que no tengas cron jobs duplicados en GitHub Actions + Vercel
### Base de datos no conecta
**Solución**:
```bash
# Test connection string
psql "$NEON_DATABASE_URL" -c "SELECT 1"
# Verificar que el proyecto de Neon esté activo
# Verificar que la IP de Vercel no esté bloqueada
```
## 📊 Queries Útiles
```sql
-- Ver últimas versiones procesadas
SELECT version, published_at, discord_notified
FROM claude_code_versions
ORDER BY published_at DESC
LIMIT 10;
-- Ver estadísticas de cambios por tipo
SELECT
v.version,
c.change_type,
COUNT(*) as count
FROM claude_code_versions v
JOIN claude_code_changes c ON c.version_id = v.id
GROUP BY v.version, c.change_type
ORDER BY v.published_at DESC;
-- Ver logs de Discord
SELECT
v.version,
dl.response_status,
dl.sent_at,
dl.error_message
FROM discord_notifications_log dl
JOIN claude_code_versions v ON v.id = dl.version_id
ORDER BY dl.sent_at DESC;
-- Ver metadata de monitoreo
SELECT * FROM monitoring_metadata;
```
## 🚀 Próximas Mejoras
- [ ] Command Discord `/claude-changelog [version]`
- [ ] Comparación entre versiones
- [ ] Estadísticas de adopción
- [ ] Alertas para breaking changes
- [ ] Integración con Slack/Telegram
- [ ] Dashboard web para visualizar releases
## 📚 Referencias
- [Claude Code GitHub](https://github.com/anthropics/claude-code)
- [Claude Code NPM](https://www.npmjs.com/package/@anthropic-ai/claude-code)
- [Neon Database Docs](https://neon.tech/docs)
- [Discord Webhooks](https://discord.com/developers/docs/resources/webhook)
- [Vercel Functions](https://vercel.com/docs/functions)
---
**Made with ❤️ for the Claude Code community**
+302
View File
@@ -0,0 +1,302 @@
// Endpoint principal: Verifica nueva versión y notifica a Discord
// Este endpoint hace todo el proceso completo en una sola llamada
import { neon } from '@neondatabase/serverless';
import axios from 'axios';
import { parseVersionChangelog, formatForDiscord, generateSummary } from './parser.js';
const NPM_PACKAGE = '@anthropic-ai/claude-code';
const CHANGELOG_URL = 'https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md';
// Obtener la última versión de NPM
async function getLatestNPMVersion() {
const response = await axios.get(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`);
return {
version: response.data.version,
publishedAt: response.data.time?.modified || new Date().toISOString(),
npmUrl: `https://www.npmjs.com/package/${NPM_PACKAGE}/v/${response.data.version}`
};
}
// Enviar a Discord
async function sendToDiscord(versionData, parsed, formatted, summary) {
const webhookUrl = process.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL;
if (!webhookUrl) {
throw new Error('Discord webhook URL not configured');
}
const embed = {
title: `🚀 Claude Code ${versionData.version} Released`,
description: `A new version of Claude Code is available with **${summary.total} changes**!`,
url: versionData.githubUrl,
color: 0x8B5CF6, // Purple
fields: [],
footer: {
text: 'Claude Code Changelog Monitor',
icon_url: 'https://avatars.githubusercontent.com/u/100788936?s=200&v=4'
},
timestamp: new Date().toISOString()
};
// Breaking changes
if (formatted.breaking && formatted.breaking.length > 0) {
embed.fields.push({
name: '⚠️ Breaking Changes',
value: formatted.breaking,
inline: false
});
}
// Features
if (formatted.features && formatted.features.length > 0) {
embed.fields.push({
name: '✨ New Features',
value: formatted.features,
inline: false
});
}
// Improvements
if (formatted.improvements && formatted.improvements.length > 0) {
embed.fields.push({
name: '⚡ Improvements',
value: formatted.improvements,
inline: false
});
}
// Bug Fixes
if (formatted.fixes && formatted.fixes.length > 0) {
embed.fields.push({
name: '🐛 Bug Fixes',
value: formatted.fixes,
inline: false
});
}
// Installation
embed.fields.push({
name: '📦 Installation',
value: `\`\`\`bash\nnpm install -g @anthropic-ai/claude-code@${versionData.version}\n\`\`\``,
inline: false
});
// Links
embed.fields.push({
name: '🔗 Links',
value: `[NPM Package](${versionData.npmUrl}) • [Full Changelog](${versionData.githubUrl})`,
inline: false
});
const payload = {
username: 'Claude Code Monitor',
avatar_url: 'https://raw.githubusercontent.com/anthropics/claude-code/main/assets/icon.png',
embeds: [embed]
};
const response = await axios.post(webhookUrl, payload);
return { success: true, status: response.status, payload };
}
// Handler principal
export default async function handler(req, res) {
// CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).json({ ok: true });
}
try {
const sql = neon(process.env.NEON_DATABASE_URL);
console.log('🔍 Checking for new Claude Code version...');
// 1. Obtener última versión de NPM
const latestVersion = await getLatestNPMVersion();
console.log(`📦 Latest NPM version: ${latestVersion.version}`);
// 2. Verificar si ya fue procesada
const existing = await sql`
SELECT id, discord_notified
FROM claude_code_versions
WHERE version = ${latestVersion.version}
`;
if (existing.length > 0 && existing[0].discord_notified) {
console.log(`✓ Version ${latestVersion.version} already processed and notified`);
await sql`
UPDATE monitoring_metadata
SET last_check_at = NOW(), last_version_found = ${latestVersion.version}
WHERE id = 1
`;
return res.status(200).json({
status: 'already_processed',
version: latestVersion.version,
message: 'Version already notified to Discord'
});
}
console.log(`🆕 New version detected: ${latestVersion.version}`);
// 3. Obtener changelog
const changelogResponse = await axios.get(CHANGELOG_URL);
const fullChangelog = changelogResponse.data;
// 4. Parsear changelog de esta versión
const parsed = parseVersionChangelog(fullChangelog, latestVersion.version);
if (!parsed.content) {
throw new Error(`Could not find version ${latestVersion.version} in changelog`);
}
console.log(`📝 Parsed ${parsed.changeCount} changes`);
// 5. Formatear para Discord
const formatted = formatForDiscord(parsed.changes);
const summary = generateSummary(parsed.changes);
// 6. Guardar en base de datos
const githubUrl = `https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md#${latestVersion.version.replace(/\./g, '')}`;
const versionData = {
version: latestVersion.version,
publishedAt: latestVersion.publishedAt,
npmUrl: latestVersion.npmUrl,
githubUrl,
changelogContent: fullChangelog.substring(0, 50000)
};
let versionId;
if (existing.length > 0) {
// Ya existe pero no fue notificada
versionId = existing[0].id;
console.log(`📝 Updating existing version record (ID: ${versionId})`);
} else {
// Crear nueva entrada
const insertResult = await sql`
INSERT INTO claude_code_versions (
version,
published_at,
changelog_content,
npm_url,
github_url,
discord_notified
) VALUES (
${versionData.version},
${versionData.publishedAt},
${versionData.changelogContent},
${versionData.npmUrl},
${versionData.githubUrl},
false
)
RETURNING id
`;
versionId = insertResult[0].id;
console.log(`💾 Version saved to database (ID: ${versionId})`);
}
// 7. Guardar cambios individuales
for (const change of parsed.changes) {
await sql`
INSERT INTO claude_code_changes (
version_id,
change_type,
description,
category
) VALUES (
${versionId},
${change.type},
${change.description},
${change.category}
)
`;
}
console.log(`💾 Saved ${parsed.changes.length} individual changes`);
// 8. Enviar a Discord
console.log('📢 Sending Discord notification...');
const discordResult = await sendToDiscord(versionData, parsed, formatted, summary);
console.log('✅ Discord notification sent successfully!');
// 9. Guardar log de notificación
await sql`
INSERT INTO discord_notifications_log (
version_id,
webhook_url,
payload,
response_status,
response_body
) VALUES (
${versionId},
${process.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL},
${JSON.stringify(discordResult.payload)},
${discordResult.status},
${'Success'}
)
`;
// 10. Marcar como notificada
await sql`
UPDATE claude_code_versions
SET discord_notified = true, discord_notification_sent_at = NOW()
WHERE id = ${versionId}
`;
// 11. Actualizar metadata
await sql`
UPDATE monitoring_metadata
SET
last_check_at = NOW(),
last_version_found = ${latestVersion.version},
check_count = check_count + 1
WHERE id = 1
`;
console.log('🎉 Process completed successfully!');
return res.status(200).json({
status: 'success',
version: latestVersion.version,
versionId,
changes: {
total: summary.total,
byType: summary.byType
},
discord: {
sent: true,
status: discordResult.status
}
});
} catch (error) {
console.error('❌ Error:', error);
// Actualizar metadata con error
try {
const sql = neon(process.env.NEON_DATABASE_URL);
await sql`
UPDATE monitoring_metadata
SET
last_check_at = NOW(),
error_count = error_count + 1,
last_error = ${error.message}
WHERE id = 1
`;
} catch (metaError) {
console.error('Failed to update metadata:', metaError);
}
return res.status(500).json({
error: 'Internal server error',
message: error.message,
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
}
}
+261
View File
@@ -0,0 +1,261 @@
// Notificador de Discord para Claude Code Changelog
import { neon } from '@neondatabase/serverless';
import axios from 'axios';
import { parseVersionChangelog, formatForDiscord, generateSummary } from './parser.js';
/**
* Envía notificación a Discord sobre una nueva versión
* @param {object} versionData - Datos de la versión
* @returns {object} - Resultado del envío
*/
export async function sendDiscordNotification(versionData) {
const { version, changelog, npmUrl, githubUrl } = versionData;
// Parsear changelog
const parsed = parseVersionChangelog(changelog, version);
if (!parsed.content) {
throw new Error('Failed to parse changelog for version ' + version);
}
// Formatear para Discord
const formatted = formatForDiscord(parsed.changes);
const summary = generateSummary(parsed.changes);
// Construir embed
const embed = buildDiscordEmbed({
version,
changes: formatted,
summary,
npmUrl,
githubUrl
});
// Enviar a Discord
const webhookUrl = process.env.DISCORD_WEBHOOK_URL_CHANGELOG || process.env.DISCORD_WEBHOOK_URL;
if (!webhookUrl) {
throw new Error('Discord webhook URL not configured');
}
const payload = {
username: 'Claude Code Monitor',
avatar_url: 'https://raw.githubusercontent.com/anthropics/claude-code/main/assets/icon.png',
embeds: [embed]
};
try {
const response = await axios.post(webhookUrl, payload);
return {
success: true,
status: response.status,
webhookUrl,
payload
};
} catch (error) {
console.error('Discord webhook error:', error.response?.data || error.message);
throw new Error(`Discord notification failed: ${error.message}`);
}
}
/**
* Construye el embed de Discord
*/
function buildDiscordEmbed({ version, changes, summary, npmUrl, githubUrl }) {
const embed = {
title: `🚀 Claude Code ${version} Released`,
description: `A new version of Claude Code is available with **${summary.total} changes**!`,
url: githubUrl,
color: 0x8B5CF6, // Purple (Claude color)
fields: [],
footer: {
text: 'Claude Code Changelog Monitor',
icon_url: 'https://avatars.githubusercontent.com/u/100788936?s=200&v=4'
},
timestamp: new Date().toISOString()
};
// Breaking changes (prioritario)
if (changes.breaking && changes.breaking.length > 0) {
embed.fields.push({
name: '⚠️ Breaking Changes',
value: changes.breaking || 'None',
inline: false
});
}
// Features
if (changes.features && changes.features.length > 0) {
embed.fields.push({
name: '✨ New Features',
value: changes.features || 'None',
inline: false
});
}
// Improvements
if (changes.improvements && changes.improvements.length > 0) {
embed.fields.push({
name: '⚡ Improvements',
value: changes.improvements || 'None',
inline: false
});
}
// Bug Fixes
if (changes.fixes && changes.fixes.length > 0) {
embed.fields.push({
name: '🐛 Bug Fixes',
value: changes.fixes || 'None',
inline: false
});
}
// Links
embed.fields.push({
name: '📦 Installation',
value: `\`\`\`bash\nnpm install -g @anthropic-ai/claude-code@${version}\n\`\`\``,
inline: false
});
embed.fields.push({
name: '🔗 Links',
value: `[NPM Package](${npmUrl}) • [Full Changelog](${githubUrl})`,
inline: false
});
return embed;
}
/**
* Guarda el log de la notificación en la base de datos
*/
async function logNotification(sql, versionId, notificationResult) {
const { success, status, webhookUrl, payload } = notificationResult;
await sql`
INSERT INTO discord_notifications_log (
version_id,
webhook_url,
payload,
response_status,
response_body,
error_message
) VALUES (
${versionId},
${webhookUrl},
${JSON.stringify(payload)},
${status},
${success ? 'Success' : 'Failed'},
${success ? null : 'Failed to send'}
)
`;
}
/**
* Marca la versión como notificada en Discord
*/
async function markAsNotified(sql, versionId) {
await sql`
UPDATE claude_code_versions
SET
discord_notified = true,
discord_notification_sent_at = NOW()
WHERE id = ${versionId}
`;
}
/**
* Procesa y notifica una versión específica
* Esta es la función principal que se llama desde el webhook
*/
export async function processAndNotify(versionId) {
const sql = neon(process.env.NEON_DATABASE_URL);
// Obtener datos de la versión
const versionResult = await sql`
SELECT *
FROM claude_code_versions
WHERE id = ${versionId}
`;
if (versionResult.length === 0) {
throw new Error(`Version ID ${versionId} not found`);
}
const versionData = versionResult[0];
// Verificar si ya fue notificada
if (versionData.discord_notified) {
return {
status: 'already_notified',
version: versionData.version,
message: 'Version already notified to Discord'
};
}
// Enviar notificación
const notificationResult = await sendDiscordNotification({
version: versionData.version,
changelog: versionData.changelog_content,
npmUrl: versionData.npm_url,
githubUrl: versionData.github_url
});
// Guardar log
await logNotification(sql, versionId, notificationResult);
// Marcar como notificada
await markAsNotified(sql, versionId);
return {
status: 'notified',
version: versionData.version,
notificationResult
};
}
/**
* Handler de Vercel para procesar notificaciones
*/
export default async function handler(req, res) {
// CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).json({ ok: true });
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { versionId } = req.body;
if (!versionId) {
return res.status(400).json({ error: 'versionId required' });
}
console.log(`📢 Processing Discord notification for version ID: ${versionId}`);
const result = await processAndNotify(versionId);
console.log(`✅ Notification processed: ${result.status}`);
return res.status(200).json(result);
} catch (error) {
console.error('❌ Discord notification error:', error);
return res.status(500).json({
error: 'Failed to send Discord notification',
message: error.message,
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
}
}
+288
View File
@@ -0,0 +1,288 @@
// Parser del CHANGELOG.md de Claude Code
/**
* Extrae la sección de una versión específica del changelog
* @param {string} changelog - Contenido completo del CHANGELOG.md
* @param {string} version - Versión a extraer (ej: "2.0.31")
* @returns {object} - Información parseada de la versión
*/
export function parseVersionChangelog(changelog, version) {
// Normalizar versión (remover 'v' si existe)
const cleanVersion = version.replace(/^v/, '');
// Buscar el inicio de esta versión
const versionRegex = new RegExp(`^##\\s+(?:v)?${cleanVersion.replace(/\./g, '\\.')}`, 'm');
const match = changelog.match(versionRegex);
if (!match) {
return {
version: cleanVersion,
content: null,
changes: [],
error: 'Version not found in changelog'
};
}
// Encontrar el índice de inicio
const startIndex = match.index;
// Encontrar el índice de fin (siguiente versión o fin del documento)
const nextVersionRegex = /^##\s+(?:v)?\d+\.\d+\.\d+/m;
const remainingChangelog = changelog.substring(startIndex + match[0].length);
const nextMatch = remainingChangelog.match(nextVersionRegex);
const endIndex = nextMatch
? startIndex + match[0].length + nextMatch.index
: changelog.length;
// Extraer contenido de esta versión
const versionContent = changelog.substring(startIndex, endIndex).trim();
// Parsear los cambios
const changes = parseChanges(versionContent);
return {
version: cleanVersion,
content: versionContent,
changes,
changeCount: changes.length
};
}
/**
* Parsea los cambios individuales de una sección de versión
* @param {string} content - Contenido de la sección de versión
* @returns {Array} - Array de cambios parseados
*/
function parseChanges(content) {
const changes = [];
// Dividir por líneas
const lines = content.split('\n');
let currentCategory = null;
for (const line of lines) {
const trimmed = line.trim();
// Ignorar líneas vacías y headers de versión
if (!trimmed || trimmed.startsWith('##')) {
continue;
}
// Detectar categorías (opcional, por si usan headers)
if (trimmed.startsWith('###')) {
currentCategory = trimmed.replace(/^###\s*/, '').trim();
continue;
}
// Detectar bullets de cambios
if (trimmed.startsWith('-') || trimmed.startsWith('*')) {
const description = trimmed.replace(/^[-*]\s*/, '').trim();
if (!description) continue;
// Intentar clasificar el tipo de cambio
const changeType = classifyChange(description);
changes.push({
type: changeType,
description,
category: currentCategory || detectCategory(description),
raw: line
});
}
}
return changes;
}
/**
* Clasifica un cambio por tipo basándose en keywords
* @param {string} description - Descripción del cambio
* @returns {string} - Tipo de cambio
*/
function classifyChange(description) {
const lower = description.toLowerCase();
// Breaking changes
if (lower.includes('breaking') || lower.includes('removed') || lower.includes('deprecated')) {
return 'breaking';
}
// Features
if (
lower.includes('add') ||
lower.includes('new') ||
lower.includes('introduce') ||
lower.includes('support for') ||
lower.startsWith('✨') ||
lower.startsWith('🎉')
) {
return 'feature';
}
// Fixes
if (
lower.includes('fix') ||
lower.includes('resolve') ||
lower.includes('correct') ||
lower.includes('patch') ||
lower.startsWith('🐛')
) {
return 'fix';
}
// Improvements
if (
lower.includes('improve') ||
lower.includes('enhance') ||
lower.includes('optimize') ||
lower.includes('better') ||
lower.includes('refactor') ||
lower.startsWith('⚡') ||
lower.startsWith('♻️')
) {
return 'improvement';
}
// Deprecations
if (lower.includes('deprecate')) {
return 'deprecation';
}
// Performance
if (lower.includes('performance') || lower.includes('speed') || lower.includes('faster')) {
return 'performance';
}
// Documentation
if (lower.includes('docs') || lower.includes('documentation')) {
return 'documentation';
}
// Default: other
return 'other';
}
/**
* Detecta la categoría de un cambio basándose en keywords
* @param {string} description - Descripción del cambio
* @returns {string|null} - Categoría detectada
*/
function detectCategory(description) {
const lower = description.toLowerCase();
const categories = {
'Plugin System': ['plugin', 'plugins', 'marketplace'],
'CLI': ['cli', 'command', 'terminal', 'bash'],
'Performance': ['performance', 'speed', 'faster', 'optimize', 'cache'],
'UI/UX': ['ui', 'ux', 'interface', 'display', 'output'],
'API': ['api', 'endpoint', 'rest', 'graphql'],
'Models': ['model', 'sonnet', 'opus', 'haiku', 'claude'],
'MCP': ['mcp', 'model context protocol'],
'Agents': ['agent', 'subagent', 'explore'],
'Settings': ['setting', 'config', 'configuration'],
'Hooks': ['hook', 'trigger', 'event'],
'Security': ['security', 'auth', 'authentication', 'permission'],
'Documentation': ['docs', 'documentation', 'readme'],
'Windows': ['windows', 'win32'],
'macOS': ['macos', 'darwin', 'mac'],
'Linux': ['linux', 'unix']
};
for (const [category, keywords] of Object.entries(categories)) {
if (keywords.some(keyword => lower.includes(keyword))) {
return category;
}
}
return null;
}
/**
* Genera un resumen de los cambios
* @param {Array} changes - Array de cambios
* @returns {object} - Resumen estadístico
*/
export function generateSummary(changes) {
const summary = {
total: changes.length,
byType: {},
byCategory: {},
highlights: []
};
// Contar por tipo
changes.forEach(change => {
summary.byType[change.type] = (summary.byType[change.type] || 0) + 1;
if (change.category) {
summary.byCategory[change.category] = (summary.byCategory[change.category] || 0) + 1;
}
// Detectar highlights (breaking changes o features importantes)
if (change.type === 'breaking' || change.type === 'feature') {
summary.highlights.push(change);
}
});
return summary;
}
/**
* Formatea los cambios para Discord
* @param {Array} changes - Array de cambios
* @param {number} maxLength - Longitud máxima del texto
* @returns {object} - Cambios agrupados para Discord
*/
export function formatForDiscord(changes, maxLength = 1024) {
const grouped = {
features: [],
fixes: [],
improvements: [],
breaking: [],
other: []
};
// Agrupar cambios
changes.forEach(change => {
switch (change.type) {
case 'feature':
grouped.features.push(change.description);
break;
case 'fix':
grouped.fixes.push(change.description);
break;
case 'improvement':
case 'performance':
grouped.improvements.push(change.description);
break;
case 'breaking':
case 'deprecation':
grouped.breaking.push(change.description);
break;
default:
grouped.other.push(change.description);
}
});
// Truncar si es necesario
const truncate = (items, max) => {
const text = items.map(item => `${item}`).join('\n');
if (text.length > max) {
const truncated = text.substring(0, max - 20);
const lastNewline = truncated.lastIndexOf('\n');
return truncated.substring(0, lastNewline) + '\n... [Read more in changelog]';
}
return text;
};
return {
features: truncate(grouped.features, maxLength),
fixes: truncate(grouped.fixes, maxLength),
improvements: truncate(grouped.improvements, maxLength),
breaking: truncate(grouped.breaking, maxLength),
other: truncate(grouped.other, maxLength)
};
}
+196
View File
@@ -0,0 +1,196 @@
// Vercel Function: Monitor de Claude Code Changelog
// Se activa con webhooks de NPM o puede ser llamado manualmente
import { neon } from '@neondatabase/serverless';
import axios from 'axios';
const NPM_PACKAGE = '@anthropic-ai/claude-code';
const CHANGELOG_URL = 'https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md';
// Inicializar cliente de Neon
function getNeonClient() {
const connectionString = process.env.NEON_DATABASE_URL;
if (!connectionString) {
throw new Error('NEON_DATABASE_URL not configured');
}
return neon(connectionString);
}
// Obtener la última versión de NPM
async function getLatestNPMVersion() {
try {
const response = await axios.get(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`);
return {
version: response.data.version,
publishedAt: response.data.time?.modified || new Date().toISOString(),
npmUrl: `https://www.npmjs.com/package/${NPM_PACKAGE}/v/${response.data.version}`
};
} catch (error) {
console.error('Error fetching NPM version:', error);
throw new Error(`Failed to fetch NPM version: ${error.message}`);
}
}
// Verificar si la versión ya fue procesada
async function isVersionProcessed(sql, version) {
const result = await sql`
SELECT id, discord_notified
FROM claude_code_versions
WHERE version = ${version}
`;
return result.length > 0 ? result[0] : null;
}
// Guardar nueva versión en la base de datos
async function saveVersion(sql, versionData) {
const { version, publishedAt, npmUrl, changelogContent, githubUrl } = versionData;
const result = await sql`
INSERT INTO claude_code_versions (
version,
published_at,
changelog_content,
npm_url,
github_url,
discord_notified
) VALUES (
${version},
${publishedAt},
${changelogContent},
${npmUrl},
${githubUrl},
false
)
RETURNING id, version
`;
return result[0];
}
// Actualizar metadata de monitoreo
async function updateMonitoringMetadata(sql, version, errorMessage = null) {
await sql`
UPDATE monitoring_metadata
SET
last_check_at = NOW(),
last_version_found = ${version},
check_count = check_count + 1,
error_count = error_count + ${errorMessage ? 1 : 0},
last_error = ${errorMessage}
WHERE id = 1
`;
}
// Handler principal
export default async function handler(req, res) {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).json({ ok: true });
}
// Aceptar GET y POST
if (req.method !== 'POST' && req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const sql = getNeonClient();
console.log('🔍 Checking for new Claude Code version...');
// Obtener última versión de NPM
const latestVersion = await getLatestNPMVersion();
console.log(`📦 Latest NPM version: ${latestVersion.version}`);
// Verificar si ya fue procesada
const existingVersion = await isVersionProcessed(sql, latestVersion.version);
if (existingVersion) {
console.log(`✓ Version ${latestVersion.version} already processed`);
// Si ya existe pero no fue notificada a Discord, podemos reintentarlo
if (!existingVersion.discord_notified) {
console.log('⚠️ Version exists but Discord notification pending');
// Actualizar metadata
await updateMonitoringMetadata(sql, latestVersion.version);
return res.status(200).json({
status: 'pending_notification',
version: latestVersion.version,
message: 'Version exists, Discord notification pending',
versionId: existingVersion.id
});
}
// Actualizar metadata
await updateMonitoringMetadata(sql, latestVersion.version);
return res.status(200).json({
status: 'already_processed',
version: latestVersion.version,
message: 'Version already processed and notified'
});
}
console.log(`🆕 New version detected: ${latestVersion.version}`);
// Obtener el changelog desde GitHub
console.log('📄 Fetching CHANGELOG.md...');
const changelogResponse = await axios.get(CHANGELOG_URL);
const fullChangelog = changelogResponse.data;
// Guardar la nueva versión (sin parsear aún)
const githubUrl = `https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md#${latestVersion.version.replace(/\./g, '')}`;
const savedVersion = await saveVersion(sql, {
version: latestVersion.version,
publishedAt: latestVersion.publishedAt,
npmUrl: latestVersion.npmUrl,
githubUrl,
changelogContent: fullChangelog.substring(0, 50000) // Limitar a 50KB
});
console.log(`✅ Version ${savedVersion.version} saved to database (ID: ${savedVersion.id})`);
// Actualizar metadata
await updateMonitoringMetadata(sql, latestVersion.version);
// Responder con éxito
return res.status(200).json({
status: 'new_version_detected',
version: savedVersion.version,
versionId: savedVersion.id,
message: 'New version saved, ready for processing',
data: {
version: latestVersion.version,
publishedAt: latestVersion.publishedAt,
npmUrl: latestVersion.npmUrl,
githubUrl
}
});
} catch (error) {
console.error('❌ Error in webhook handler:', error);
// Intentar actualizar metadata con el error
try {
const sql = getNeonClient();
await updateMonitoringMetadata(sql, null, error.message);
} catch (metaError) {
console.error('Failed to update metadata:', metaError);
}
return res.status(500).json({
error: 'Internal server error',
message: error.message,
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
}
}
+83
View File
@@ -0,0 +1,83 @@
import { authenticateRequest } from './_lib/auth.js';
import { getNeonClient } from './_lib/neon.js';
export default async function handler(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.status(200).json({ ok: true });
}
const userId = await authenticateRequest(req, res);
if (!userId) return;
const sql = getNeonClient();
try {
if (req.method === 'GET') {
const collections = await sql`
SELECT * FROM user_collections
WHERE clerk_user_id = ${userId}
ORDER BY position ASC, created_at ASC
`;
const items = collections.length > 0
? await sql`
SELECT * FROM collection_items
WHERE collection_id = ANY(${collections.map(c => c.id)})
ORDER BY added_at ASC
`
: [];
const itemsByCollection = {};
for (const item of items) {
if (!itemsByCollection[item.collection_id]) {
itemsByCollection[item.collection_id] = [];
}
itemsByCollection[item.collection_id].push(item);
}
const result = collections.map(c => ({
...c,
collection_items: itemsByCollection[c.id] || [],
}));
return res.status(200).json({ collections: result });
}
if (req.method === 'POST') {
const { name } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'Collection name is required' });
}
if (name.length > 100) {
return res.status(400).json({ error: 'Collection name too long (max 100 characters)' });
}
const maxPos = await sql`
SELECT COALESCE(MAX(position), -1) AS max_pos
FROM user_collections
WHERE clerk_user_id = ${userId}
`;
const newPosition = maxPos[0].max_pos + 1;
const rows = await sql`
INSERT INTO user_collections (clerk_user_id, name, position)
VALUES (${userId}, ${name.trim()}, ${newPosition})
RETURNING *
`;
const collection = { ...rows[0], collection_items: [] };
return res.status(201).json({ collection });
}
return res.status(405).json({ error: 'Method not allowed', allowed: ['GET', 'POST'] });
} catch (error) {
console.error('Collections error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}
+73
View File
@@ -0,0 +1,73 @@
import { authenticateRequest } from '../_lib/auth.js';
import { getNeonClient } from '../_lib/neon.js';
export default async function handler(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'PATCH, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.status(200).json({ ok: true });
}
const userId = await authenticateRequest(req, res);
if (!userId) return;
const { id } = req.query;
if (!id) {
return res.status(400).json({ error: 'Collection ID is required' });
}
const sql = getNeonClient();
try {
// Verify ownership
const existing = await sql`
SELECT id FROM user_collections
WHERE id = ${id} AND clerk_user_id = ${userId}
`;
if (existing.length === 0) {
return res.status(404).json({ error: 'Collection not found' });
}
if (req.method === 'PATCH') {
const { name } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'Collection name is required' });
}
if (name.length > 100) {
return res.status(400).json({ error: 'Collection name too long (max 100 characters)' });
}
const rows = await sql`
UPDATE user_collections
SET name = ${name.trim()}, updated_at = NOW()
WHERE id = ${id} AND clerk_user_id = ${userId}
RETURNING *
`;
const items = await sql`
SELECT * FROM collection_items
WHERE collection_id = ${id}
ORDER BY added_at ASC
`;
const collection = { ...rows[0], collection_items: items };
return res.status(200).json({ collection });
}
if (req.method === 'DELETE') {
await sql`DELETE FROM collection_items WHERE collection_id = ${id}`;
await sql`DELETE FROM user_collections WHERE id = ${id} AND clerk_user_id = ${userId}`;
return res.status(200).json({ success: true });
}
return res.status(405).json({ error: 'Method not allowed', allowed: ['PATCH', 'DELETE'] });
} catch (error) {
console.error('Collection [id] error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}
+112
View File
@@ -0,0 +1,112 @@
import { authenticateRequest } from '../_lib/auth.js';
import { getNeonClient } from '../_lib/neon.js';
export default async function handler(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, DELETE, PATCH, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.status(200).json({ ok: true });
}
const userId = await authenticateRequest(req, res);
if (!userId) return;
const sql = getNeonClient();
try {
if (req.method === 'POST') {
const { collectionId, componentType, componentPath, componentName, componentCategory } = req.body;
if (!collectionId || !componentType || !componentPath || !componentName) {
return res.status(400).json({ error: 'collectionId, componentType, componentPath, and componentName are required' });
}
// Verify collection ownership
const col = await sql`
SELECT id FROM user_collections
WHERE id = ${collectionId} AND clerk_user_id = ${userId}
`;
if (col.length === 0) {
return res.status(404).json({ error: 'Collection not found' });
}
// Check for duplicate
const dup = await sql`
SELECT id FROM collection_items
WHERE collection_id = ${collectionId} AND component_path = ${componentPath}
`;
if (dup.length > 0) {
return res.status(409).json({ error: 'Component already in this collection' });
}
const rows = await sql`
INSERT INTO collection_items (collection_id, component_type, component_path, component_name, component_category)
VALUES (${collectionId}, ${componentType}, ${componentPath}, ${componentName}, ${componentCategory || null})
RETURNING *
`;
return res.status(201).json({ item: rows[0] });
}
if (req.method === 'DELETE') {
const { itemId, collectionId } = req.body;
if (!itemId || !collectionId) {
return res.status(400).json({ error: 'itemId and collectionId are required' });
}
// Verify collection ownership
const col = await sql`
SELECT id FROM user_collections
WHERE id = ${collectionId} AND clerk_user_id = ${userId}
`;
if (col.length === 0) {
return res.status(404).json({ error: 'Collection not found' });
}
await sql`
DELETE FROM collection_items
WHERE id = ${itemId} AND collection_id = ${collectionId}
`;
return res.status(200).json({ success: true });
}
if (req.method === 'PATCH') {
const { itemId, fromCollectionId, toCollectionId } = req.body;
if (!itemId || !fromCollectionId || !toCollectionId) {
return res.status(400).json({ error: 'itemId, fromCollectionId, and toCollectionId are required' });
}
// Verify ownership of both collections
const cols = await sql`
SELECT id FROM user_collections
WHERE id = ANY(${[fromCollectionId, toCollectionId]}) AND clerk_user_id = ${userId}
`;
if (cols.length < 2) {
return res.status(404).json({ error: 'One or both collections not found' });
}
const rows = await sql`
UPDATE collection_items
SET collection_id = ${toCollectionId}
WHERE id = ${itemId} AND collection_id = ${fromCollectionId}
RETURNING *
`;
if (rows.length === 0) {
return res.status(404).json({ error: 'Item not found in source collection' });
}
return res.status(200).json({ item: rows[0] });
}
return res.status(405).json({ error: 'Method not allowed', allowed: ['POST', 'DELETE', 'PATCH'] });
} catch (error) {
console.error('Collection items error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}
+199
View File
@@ -0,0 +1,199 @@
import { verifyKey, InteractionType, InteractionResponseType } from 'discord-interactions';
import axios from 'axios';
const componentTypes = {
skills: { icon: '🎨', color: 0x9B59B6 },
agents: { icon: '🤖', color: 0xFF6B6B },
commands: { icon: '⚡', color: 0x4ECDC4 },
mcps: { icon: '🔌', color: 0x95E1D3 },
settings: { icon: '⚙️', color: 0xF9CA24 },
hooks: { icon: '🪝', color: 0x6C5CE7 },
templates: { icon: '📋', color: 0xA8E6CF },
plugins: { icon: '🧩', color: 0xFFD93D },
};
let cachedComponents = null;
let cacheTimestamp = null;
const CACHE_DURATION = 5 * 60 * 1000;
async function getComponents() {
const now = Date.now();
if (cachedComponents && cacheTimestamp && (now - cacheTimestamp) < CACHE_DURATION) {
return cachedComponents;
}
const response = await axios.get('https://aitmpl.com/components.json', { timeout: 10000 });
cachedComponents = response.data;
cacheTimestamp = now;
return cachedComponents;
}
function searchComponents(components, query, type = null) {
const results = [];
const lowerQuery = query.toLowerCase();
const typesToSearch = type ? [type] : Object.keys(componentTypes);
for (const componentType of typesToSearch) {
const componentList = components[componentType] || [];
for (const component of componentList) {
if (component.name.toLowerCase().includes(lowerQuery) || component.category?.toLowerCase().includes(lowerQuery)) {
results.push({
...component,
type: componentType,
score: component.name.toLowerCase() === lowerQuery ? 100 : component.name.toLowerCase().startsWith(lowerQuery) ? 50 : 20
});
}
}
}
return results.sort((a, b) => b.score - a.score).slice(0, 10);
}
function createEmbed(component, type = 'info') {
const typeConfig = componentTypes[component.type];
const icon = typeConfig?.icon || '📦';
const color = typeConfig?.color || 0x00D9FF;
const typeLabel = component.type === 'agents' ? 'agent' : component.type === 'commands' ? 'command' : component.type === 'mcps' ? 'mcp' : component.type === 'settings' ? 'setting' : component.type === 'hooks' ? 'hook' : component.type;
const category = component.category || 'general';
const url = `https://www.aitmpl.com/component/${typeLabel}/${category}/${component.name}`;
if (type === 'install') {
const flagName = component.type === 'templates' ? 'template' : component.type;
const installCommand = `npx claude-code-templates@latest --${flagName} ${component.name}`;
return {
title: `${icon} Install ${component.name}`,
description: 'Copy and paste this command in your terminal:',
color: 0x00D9FF,
url: url,
fields: [
{ name: 'Installation Command', value: `\`\`\`bash\n${installCommand}\n\`\`\``, inline: false },
{ name: 'Component Page', value: `[View on aitmpl.com](${url})`, inline: false }
],
timestamp: new Date().toISOString(),
};
}
return {
title: `${icon} ${component.name}`,
url: url,
description: component.description || component.content?.substring(0, 200) || 'No description',
color: color,
fields: [
{ name: 'Type', value: `\`${component.type}\``, inline: true },
{ name: 'Category', value: component.category || 'N/A', inline: true },
{ name: 'Downloads', value: `${component.downloads || 0}`, inline: true },
{ name: 'Component Page', value: `[View on aitmpl.com](${url})`, inline: false }
],
timestamp: new Date().toISOString(),
};
}
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const signature = req.headers['x-signature-ed25519'];
const timestamp = req.headers['x-signature-timestamp'];
const rawBody = JSON.stringify(req.body);
const publicKey = process.env.DISCORD_PUBLIC_KEY;
if (!publicKey) {
return res.status(500).json({ error: 'Server configuration error' });
}
const isValidRequest = verifyKey(rawBody, signature, timestamp, publicKey);
if (!isValidRequest) {
return res.status(401).json({ error: 'Invalid request signature' });
}
const interaction = req.body;
if (interaction.type === InteractionType.PING) {
return res.status(200).json({ type: InteractionResponseType.PONG });
}
if (interaction.type === InteractionType.APPLICATION_COMMAND) {
try {
const components = await getComponents();
const commandName = interaction.data.name;
const options = interaction.data.options || [];
let response;
if (commandName === 'search') {
const query = options.find(o => o.name === 'query')?.value;
const type = options.find(o => o.name === 'type')?.value;
const results = searchComponents(components, query, type);
response = {
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [{
title: `🔍 Search Results for "${query}"`,
description: `Found ${results.length} result(s)`,
color: 0x00D9FF,
fields: results.map((c, i) => {
const typeLabel = c.type === 'agents' ? 'agent' : c.type === 'commands' ? 'command' : c.type === 'mcps' ? 'mcp' : c.type === 'settings' ? 'setting' : c.type === 'hooks' ? 'hook' : c.type;
const category = c.category || 'general';
const url = `https://www.aitmpl.com/component/${typeLabel}/${category}/${c.name}`;
return {
name: `${i + 1}. ${componentTypes[c.type].icon} ${c.name}`,
value: `**Type:** ${c.type} | **Downloads:** ${c.downloads || 0}\n[View on aitmpl.com](${url})`,
inline: false
};
}),
timestamp: new Date().toISOString()
}]
}
};
} else if (commandName === 'info' || commandName === 'install') {
const name = options.find(o => o.name === 'name')?.value;
const type = options.find(o => o.name === 'type')?.value;
let component = null;
const types = type ? [type] : Object.keys(componentTypes);
for (const t of types) {
const found = components[t]?.find(c => c.name === name);
if (found) {
component = { ...found, type: t };
break;
}
}
if (!component) {
response = {
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: `Component "${name}" not found. Use \`/search\` to find components.`, flags: 64 }
};
} else {
response = {
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { embeds: [createEmbed(component, commandName)] }
};
}
} else if (commandName === 'popular' || commandName === 'random') {
const type = options.find(o => o.name === 'type')?.value;
const componentList = components[type] || [];
let component;
if (commandName === 'popular') {
const sorted = [...componentList].sort((a, b) => (b.downloads || 0) - (a.downloads || 0));
component = sorted[0];
} else {
component = componentList[Math.floor(Math.random() * componentList.length)];
}
if (component) {
response = {
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { embeds: [createEmbed({ ...component, type })] }
};
}
}
return res.status(200).json(response);
} catch (error) {
console.error('Error:', error);
return res.status(200).json({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: '❌ An error occurred', flags: 64 }
});
}
}
return res.status(400).json({ error: 'Unknown interaction type' });
}
+150
View File
@@ -0,0 +1,150 @@
// API Health Check Endpoint - Neon Database
// Monitors critical API endpoints and logs results
// Called by Vercel Cron every 15 minutes
import { neon } from '@neondatabase/serverless';
function getNeonClient() {
const connectionString = process.env.NEON_DATABASE_URL;
if (!connectionString) {
throw new Error('NEON_DATABASE_URL not configured');
}
return neon(connectionString);
}
const ENDPOINTS_TO_CHECK = [
{ url: 'https://www.aitmpl.com/api/track-download-supabase', method: 'OPTIONS' },
{ url: 'https://www.aitmpl.com/api/track-command-usage', method: 'OPTIONS' },
{ url: 'https://www.aitmpl.com/api/track-website-events', method: 'OPTIONS' }
];
const TIMEOUT_MS = 10000;
async function checkEndpoint(endpoint) {
const startTime = Date.now();
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
const response = await fetch(endpoint.url, {
method: endpoint.method,
signal: controller.signal
});
clearTimeout(timeoutId);
const responseTimeMs = Date.now() - startTime;
return {
endpoint: endpoint.url,
method: endpoint.method,
statusCode: response.status,
responseTimeMs,
errorMessage: null
};
} catch (error) {
const responseTimeMs = Date.now() - startTime;
return {
endpoint: endpoint.url,
method: endpoint.method,
statusCode: 0,
responseTimeMs,
errorMessage: error.name === 'AbortError' ? 'Timeout' : error.message
};
}
}
async function sendDiscordAlert(failures) {
const webhookUrl = process.env.DISCORD_WEBHOOK_URL_CHANGELOG;
if (!webhookUrl || failures.length === 0) return;
const failureList = failures.map(f =>
`- \`${f.endpoint}\`: ${f.errorMessage || `HTTP ${f.statusCode}`} (${f.responseTimeMs}ms)`
).join('\n');
try {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: 'API Health Alert',
description: `The following endpoints are experiencing issues:\n${failureList}`,
color: 0xff4444,
timestamp: new Date().toISOString()
}]
})
});
} catch (error) {
console.error('Failed to send Discord alert:', error.message);
}
}
export default async function handler(req, res) {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).json({ ok: true });
}
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed', allowed: ['GET'] });
}
try {
// Check all endpoints in parallel
const results = await Promise.all(ENDPOINTS_TO_CHECK.map(checkEndpoint));
const sql = getNeonClient();
// Log each result
for (const result of results) {
await sql`
INSERT INTO api_health_logs (
endpoint, method, status_code, response_time_ms, error_message
) VALUES (
${result.endpoint},
${result.method},
${result.statusCode},
${result.responseTimeMs},
${result.errorMessage}
)
`;
}
// Identify failures (status >= 500, status 0 for network errors, or timeout > 10s)
const failures = results.filter(r =>
r.statusCode === 0 || r.statusCode >= 500 || r.responseTimeMs >= TIMEOUT_MS
);
// Send Discord alert if there are failures
if (failures.length > 0) {
await sendDiscordAlert(failures);
}
const allHealthy = failures.length === 0;
res.status(200).json({
success: true,
healthy: allHealthy,
results: results.map(r => ({
endpoint: r.endpoint,
status: r.statusCode,
responseTime: r.responseTimeMs,
error: r.errorMessage
})),
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Health check error:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Health check failed',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
+1
View File
@@ -0,0 +1 @@
<h1>AITMPL.COM</h1>
+23
View File
@@ -0,0 +1,23 @@
/**
* Jest Configuration for API Tests
*
* This configuration is designed to test production API endpoints
* to ensure critical functionality doesn't break during deployments.
*/
module.exports = {
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.js'],
collectCoverageFrom: [
'*.js',
'!node_modules/**',
'!__tests__/**',
'!jest.config.cjs',
'!coverage/**'
],
coverageDirectory: 'coverage',
verbose: true,
testTimeout: 30000, // 30 seconds default timeout
bail: false, // Run all tests even if some fail
maxWorkers: 4, // Run tests in parallel
};
+4463
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"type": "module",
"name": "claude-code-templates-api",
"version": "1.0.0",
"description": "API endpoints for claude-code-templates",
"scripts": {
"test": "jest --config jest.config.cjs",
"test:api": "jest __tests__/endpoints.test.js --config jest.config.cjs",
"test:watch": "jest --watch --config jest.config.cjs",
"test:coverage": "jest --coverage --config jest.config.cjs"
},
"dependencies": {
"@vercel/postgres": "^0.10.0",
"@supabase/supabase-js": "^2.45.0",
"@neondatabase/serverless": "^0.10.1",
"@clerk/backend": "^1.0.0",
"axios": "^1.6.2",
"discord-interactions": "^3.4.0"
},
"devDependencies": {
"jest": "^29.7.0",
"@types/jest": "^29.5.11"
}
}
+148
View File
@@ -0,0 +1,148 @@
// Command Usage Tracking API Endpoint - Neon Database
// Tracks CLI command executions for community analytics
import { neon } from '@neondatabase/serverless';
// Initialize Neon client
function getNeonClient() {
const connectionString = process.env.NEON_DATABASE_URL;
if (!connectionString) {
throw new Error('NEON_DATABASE_URL not configured');
}
return neon(connectionString);
}
// Validate command data
function validateCommandData(data) {
const { command, cliVersion, nodeVersion, platform } = data;
if (!command) {
return { valid: false, error: 'Command name is required' };
}
// Valid commands to track
const validCommands = [
'chats',
'analytics',
'health-check',
'plugins',
'sandbox',
'agents',
'chats-mobile',
'studio',
'command-stats',
'hook-stats',
'mcp-stats',
'skills-manager',
'2025-year-in-review'
];
if (!validCommands.includes(command)) {
return { valid: false, error: 'Invalid command name' };
}
if (command.length > 100) {
return { valid: false, error: 'Command name too long' };
}
return { valid: true };
}
// Get client information from request
function getClientInfo(req) {
const userAgent = req.headers['user-agent'] || '';
const ip = req.headers['x-forwarded-for']?.split(',')[0] ||
req.headers['x-real-ip'] ||
'unknown';
return { userAgent, ip };
}
// Main handler
export default async function handler(req, res) {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, User-Agent');
// Handle preflight requests
if (req.method === 'OPTIONS') {
return res.status(200).json({ ok: true });
}
// Only accept POST requests
if (req.method !== 'POST') {
return res.status(405).json({
error: 'Method not allowed',
allowed: ['POST']
});
}
try {
// Extract and validate request body
const {
command,
cliVersion,
nodeVersion,
platform,
arch,
sessionId,
metadata
} = req.body;
const validation = validateCommandData({ command, cliVersion, nodeVersion, platform });
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
// Get client information
const clientInfo = getClientInfo(req);
// Initialize Neon client
const sql = getNeonClient();
// Insert command log
await sql`
INSERT INTO command_usage_logs (
command_name,
cli_version,
node_version,
platform,
arch,
session_id,
metadata
) VALUES (
${command},
${cliVersion || 'unknown'},
${nodeVersion || 'unknown'},
${platform || 'unknown'},
${arch || 'unknown'},
${sessionId || null},
${metadata ? JSON.stringify(metadata) : null}
)
`;
// Return success response
res.status(200).json({
success: true,
message: 'Command execution tracked successfully',
data: {
command,
timestamp: new Date().toISOString()
}
});
} catch (error) {
console.error('Command tracking error:', error);
// Return error response
res.status(500).json({
error: 'Internal server error',
message: 'Failed to track command execution',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
+150
View File
@@ -0,0 +1,150 @@
// Download tracking API endpoint using Supabase client
import { createClient } from '@supabase/supabase-js';
// Initialize Supabase client
function getSupabaseClient() {
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
throw new Error('Missing Supabase configuration');
}
return createClient(supabaseUrl, supabaseServiceKey);
}
// Validate component data
function validateComponentData(data) {
const { type, name, path, category } = data;
if (!type || !name) {
return { valid: false, error: 'Component type and name are required' };
}
const validTypes = ['agent', 'command', 'setting', 'hook', 'mcp', 'skill', 'template'];
if (!validTypes.includes(type)) {
return { valid: false, error: 'Invalid component type' };
}
if (name.length > 255) {
return { valid: false, error: 'Component name too long' };
}
return { valid: true };
}
// Get IP address from request
function getClientIP(req) {
return req.headers['x-forwarded-for']?.split(',')[0] ||
req.headers['x-real-ip'] ||
req.connection?.remoteAddress ||
req.socket?.remoteAddress ||
'127.0.0.1';
}
// Get country from Vercel geo headers
function getCountry(req) {
return req.headers['x-vercel-ip-country'] || null;
}
export default async function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, User-Agent');
// Handle preflight requests
if (req.method === 'OPTIONS') {
return res.status(200).json({ ok: true });
}
// Only accept POST requests
if (req.method !== 'POST') {
return res.status(405).json({
error: 'Method not allowed',
allowed: ['POST']
});
}
try {
// Validate request body
const { type, name, path, category, cliVersion } = req.body;
const validation = validateComponentData({ type, name, path, category });
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
// Get client information
const ipAddress = getClientIP(req);
const country = getCountry(req);
const userAgent = req.headers['user-agent'];
// Initialize Supabase client
const supabase = getSupabaseClient();
// Insert download record
const { data: insertData, error: insertError } = await supabase
.from('component_downloads')
.insert({
component_type: type,
component_name: name,
component_path: path,
category: category,
user_agent: userAgent,
ip_address: ipAddress,
country: country,
cli_version: cliVersion,
download_timestamp: new Date().toISOString(),
created_at: new Date().toISOString()
});
if (insertError) {
console.error('Supabase insert error:', insertError);
throw new Error(`Database insert failed: ${insertError.message}`);
}
// Update aggregated stats (upsert)
const { error: upsertError } = await supabase
.from('download_stats')
.upsert(
{
component_type: type,
component_name: name,
total_downloads: 1,
last_download: new Date().toISOString(),
updated_at: new Date().toISOString()
},
{
onConflict: 'component_type,component_name',
ignoreDuplicates: false
}
);
if (upsertError) {
console.error('Supabase upsert error:', upsertError);
// Don't fail the request for stats update errors
}
// Return success response
res.status(200).json({
success: true,
message: 'Download tracked successfully',
data: {
type,
name,
timestamp: new Date().toISOString()
}
});
} catch (error) {
console.error('Download tracking error:', error);
// Return error response
res.status(500).json({
error: 'Internal server error',
message: 'Failed to track download',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
+108
View File
@@ -0,0 +1,108 @@
// Installation Outcome Tracking API Endpoint - Neon Database
// Tracks CLI installation results (success/failure) for reliability analytics
import { neon } from '@neondatabase/serverless';
function getNeonClient() {
const connectionString = process.env.NEON_DATABASE_URL;
if (!connectionString) {
throw new Error('NEON_DATABASE_URL not configured');
}
return neon(connectionString);
}
function validateOutcomeData(data) {
const { componentType, componentName, outcome } = data;
if (!componentType || !componentName || !outcome) {
return { valid: false, error: 'componentType, componentName, and outcome are required' };
}
const validTypes = ['agent', 'command', 'mcp', 'setting', 'hook', 'skill', 'template'];
if (!validTypes.includes(componentType)) {
return { valid: false, error: 'Invalid component type' };
}
const validOutcomes = ['success', 'failure', 'partial'];
if (!validOutcomes.includes(outcome)) {
return { valid: false, error: 'Invalid outcome. Must be: success, failure, or partial' };
}
if (componentName.length > 255) {
return { valid: false, error: 'Component name too long' };
}
return { valid: true };
}
export default async function handler(req, res) {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, User-Agent');
if (req.method === 'OPTIONS') {
return res.status(200).json({ ok: true });
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed', allowed: ['POST'] });
}
try {
const {
componentType,
componentName,
outcome,
errorType,
errorMessage,
durationMs,
cliVersion,
nodeVersion,
platform,
arch,
batchId
} = req.body;
const validation = validateOutcomeData({ componentType, componentName, outcome });
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
const sql = getNeonClient();
await sql`
INSERT INTO installation_outcomes (
component_type, component_name, outcome,
error_type, error_message, duration_ms,
cli_version, node_version, platform, arch, batch_id
) VALUES (
${componentType},
${componentName},
${outcome},
${errorType || null},
${errorMessage ? errorMessage.substring(0, 1000) : null},
${durationMs || null},
${cliVersion || 'unknown'},
${nodeVersion || 'unknown'},
${platform || 'unknown'},
${arch || 'unknown'},
${batchId || null}
)
`;
res.status(200).json({
success: true,
message: 'Installation outcome tracked',
data: { componentType, componentName, outcome, timestamp: new Date().toISOString() }
});
} catch (error) {
console.error('Installation outcome tracking error:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Failed to track installation outcome',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
+114
View File
@@ -0,0 +1,114 @@
// Website Events Tracking API Endpoint - Neon Database
// Tracks search, cart, and component view events from the website
import { neon } from '@neondatabase/serverless';
function getNeonClient() {
const connectionString = process.env.NEON_DATABASE_URL;
if (!connectionString) {
throw new Error('NEON_DATABASE_URL not configured');
}
return neon(connectionString);
}
const VALID_EVENT_TYPES = [
'search',
'cart_add',
'cart_remove',
'cart_checkout',
'component_view',
'copy_command'
];
const MAX_EVENTS_PER_BATCH = 50;
function validateEventsData(data) {
const { events } = data;
if (!events || !Array.isArray(events) || events.length === 0) {
return { valid: false, error: 'events array is required and must not be empty' };
}
if (events.length > MAX_EVENTS_PER_BATCH) {
return { valid: false, error: `Maximum ${MAX_EVENTS_PER_BATCH} events per batch` };
}
for (const event of events) {
if (!event.event_type || !VALID_EVENT_TYPES.includes(event.event_type)) {
return { valid: false, error: `Invalid event_type: ${event.event_type}` };
}
}
return { valid: true };
}
export default async function handler(req, res) {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).json({ ok: true });
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed', allowed: ['POST'] });
}
try {
const {
events,
session_id,
visitor_id,
screen_width,
referrer
} = req.body;
const validation = validateEventsData({ events });
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
// Extract country from Vercel header
const country = req.headers['x-vercel-ip-country'] || null;
const sql = getNeonClient();
// Insert each event
let inserted = 0;
for (const event of events) {
await sql`
INSERT INTO website_events (
event_type, event_data, page_path,
referrer, session_id, visitor_id,
country, screen_width
) VALUES (
${event.event_type},
${event.event_data ? JSON.stringify(event.event_data) : null},
${event.page_path || null},
${referrer ? referrer.substring(0, 1000) : null},
${session_id || null},
${visitor_id || null},
${country},
${screen_width || null}
)
`;
inserted++;
}
res.status(200).json({
success: true,
message: `${inserted} events tracked`,
data: { count: inserted, timestamp: new Date().toISOString() }
});
} catch (error) {
console.error('Website events tracking error:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Failed to track website events',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}