diff --git a/.cursor/rules/claude-desktop-config.json b/.cursor/rules/claude-desktop-config.json index 8524116..4db5d49 100644 --- a/.cursor/rules/claude-desktop-config.json +++ b/.cursor/rules/claude-desktop-config.json @@ -1,8 +1,8 @@ { - "mcpServers": { - "playwright": { - "command": "npx", - "args": ["-y", "@executeautomation/playwright-mcp-server"] - } - } - } \ No newline at end of file + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["-y", "@executeautomation/playwright-mcp-server"] + } + } +} diff --git a/.cursor/rules/mcp.json b/.cursor/rules/mcp.json index 8058beb..34f651e 100644 --- a/.cursor/rules/mcp.json +++ b/.cursor/rules/mcp.json @@ -1,7 +1,7 @@ { - "mcpServers": { - "context7": { - "url": "https://mcp.context7.com/mcp" - } - } - } \ No newline at end of file + "mcpServers": { + "context7": { + "url": "https://mcp.context7.com/mcp" + } + } +} diff --git a/apps/api/config/clerkConfig.js b/apps/api/config/clerkConfig.js index c8357a1..66d0f57 100755 --- a/apps/api/config/clerkConfig.js +++ b/apps/api/config/clerkConfig.js @@ -4,14 +4,19 @@ import dotenv from 'dotenv'; dotenv.config({ path: '.env.local' }); dotenv.config(); - const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY; console.log('🔍 ClerkConfig - WEBHOOK_SECRET available:', !!WEBHOOK_SECRET); -console.log('🔍 ClerkConfig - WEBHOOK_SECRET length:', WEBHOOK_SECRET ? WEBHOOK_SECRET.length : 0); +console.log( + '🔍 ClerkConfig - WEBHOOK_SECRET length:', + WEBHOOK_SECRET ? WEBHOOK_SECRET.length : 0, +); console.log('🔍 ClerkConfig - CLERK_SECRET_KEY available:', !!CLERK_SECRET_KEY); -console.log('🔍 ClerkConfig - CLERK_SECRET_KEY length:', CLERK_SECRET_KEY ? CLERK_SECRET_KEY.length : 0); +console.log( + '🔍 ClerkConfig - CLERK_SECRET_KEY length:', + CLERK_SECRET_KEY ? CLERK_SECRET_KEY.length : 0, +); console.log('🔍 ClerkConfig - About to check WEBHOOK_SECRET:', WEBHOOK_SECRET); // Temporarily comment out the webhook secret check for debugging @@ -23,8 +28,8 @@ console.log('🔍 ClerkConfig - About to check WEBHOOK_SECRET:', WEBHOOK_SECRET) // } if (!CLERK_SECRET_KEY) { - console.error('❌ Missing Clerk API Secret Key!'); - process.exit(1); + console.error('❌ Missing Clerk API Secret Key!'); + process.exit(1); } export { WEBHOOK_SECRET, CLERK_SECRET_KEY }; diff --git a/apps/api/controllers/categoryController.js b/apps/api/controllers/categoryController.js index 4367ab1..690d95d 100644 --- a/apps/api/controllers/categoryController.js +++ b/apps/api/controllers/categoryController.js @@ -1,23 +1,24 @@ import { PrismaClient } from '@prisma/client'; + const prisma = new PrismaClient(); export const getCategories = async (req, res) => { - try { - const lang = req.query.lang || 'ru'; - const categories = await prisma.category.findMany({ - orderBy: { name: 'asc' }, - include: { translations: true } - }); - const result = categories.map(category => { - const translation = category.translations.find(t => t.lang === lang); - return { - id: category.id, - label: translation?.name || category.name - }; - }); - res.json(result); - } catch (error) { - console.error('Ошибка при получении категорий:', error); - res.status(500).json({ error: 'Ошибка при получении категорий' }); - } -}; \ No newline at end of file + try { + const lang = req.query.lang || 'ru'; + const categories = await prisma.category.findMany({ + orderBy: { name: 'asc' }, + include: { translations: true }, + }); + const result = categories.map((category) => { + const translation = category.translations.find((t) => t.lang === lang); + return { + id: category.id, + label: translation?.name || category.name, + }; + }); + res.json(result); + } catch (error) { + console.error('Ошибка при получении категорий:', error); + res.status(500).json({ error: 'Ошибка при получении категорий' }); + } +}; diff --git a/apps/api/controllers/cityController.js b/apps/api/controllers/cityController.js index 28c7e24..b4568f9 100755 --- a/apps/api/controllers/cityController.js +++ b/apps/api/controllers/cityController.js @@ -1,13 +1,13 @@ import { getCitiesService } from '../services/cityService.js'; export const getCities = async (req, res) => { - const lang = req.query.lang || 'ru'; - const result = await getCitiesService(lang); + const lang = req.query.lang || 'ru'; + const result = await getCitiesService(lang); - if (result.error) { - console.error("❌ Ошибка в getCitiesService:", result.error); - return res.status(500).json({ error: result.error }); - } + if (result.error) { + console.error('❌ Ошибка в getCitiesService:', result.error); + return res.status(500).json({ error: result.error }); + } - res.status(200).json(result.cities); + res.status(200).json(result.cities); }; diff --git a/apps/api/controllers/jobController.js b/apps/api/controllers/jobController.js index 7809412..afc910e 100755 --- a/apps/api/controllers/jobController.js +++ b/apps/api/controllers/jobController.js @@ -1,49 +1,56 @@ -import { getJobByIdService } from "../services/getJobById.js"; -import { createJobService } from "../services/createJobService.js"; +import { createJobService } from '../services/createJobService.js'; +import { getJobByIdService } from '../services/getJobById.js'; export const getJobById = async (req, res) => { - const { id } = req.params; + const { id } = req.params; - if (!id || isNaN(Number(id))) { - return res.status(400).json({ error: "ID вакансии обязателен и должен быть числом" }); - } + if (!id || isNaN(Number(id))) { + return res + .status(400) + .json({ error: 'ID вакансии обязателен и должен быть числом' }); + } - try { - const result = await getJobByIdService(Number(id)); // Передаем число + try { + const result = await getJobByIdService(Number(id)); // Передаем число - if (result.error) { - return res.status(404).json({ error: result.error }); - } + if (result.error) { + return res.status(404).json({ error: result.error }); + } - console.log('🔍 getJobById - Job data:', result.job); - res.status(200).json(result.job); - } catch (error) { - console.error("Ошибка получения объявления:", error.message); - res.status(500).json({ error: "Ошибка получения объявления", details: error.message }); - } + console.log('🔍 getJobById - Job data:', result.job); + res.status(200).json(result.job); + } catch (error) { + console.error('Ошибка получения объявления:', error.message); + res + .status(500) + .json({ error: 'Ошибка получения объявления', details: error.message }); + } }; export const createJob = async (req, res) => { - const jobData = req.body; - - try { - const result = await createJobService(jobData); - - if (result.error) { - // Check if upgrade is required - if (result.upgradeRequired) { - return res.status(403).json({ - error: result.error, - upgradeRequired: true, - message: "Для размещения большего количества объявлений перейдите на Premium тариф" - }); - } - return res.status(400).json({ error: result.error }); - } - - res.status(201).json(result); - } catch (error) { - console.error("Ошибка создания объявления:", error.message); - res.status(500).json({ error: "Ошибка создания объявления", details: error.message }); - } + const jobData = req.body; + + try { + const result = await createJobService(jobData); + + if (result.error) { + // Check if upgrade is required + if (result.upgradeRequired) { + return res.status(403).json({ + error: result.error, + upgradeRequired: true, + message: + 'Для размещения большего количества объявлений перейдите на Premium тариф', + }); + } + return res.status(400).json({ error: result.error }); + } + + res.status(201).json(result); + } catch (error) { + console.error('Ошибка создания объявления:', error.message); + res + .status(500) + .json({ error: 'Ошибка создания объявления', details: error.message }); + } }; diff --git a/apps/api/controllers/jobsController.js b/apps/api/controllers/jobsController.js index 5cb50ed..2a98fe7 100755 --- a/apps/api/controllers/jobsController.js +++ b/apps/api/controllers/jobsController.js @@ -1,97 +1,106 @@ // controllers/jobsController.js -import { createJobService } from '../services/jobCreateService.js'; + import { updateJobService } from '../services/editFormService.js'; -import { deleteJobService } from '../services/jobDeleteService.js'; -import { getJobsService} from '../services/jobService.js'; import { boostJobService } from '../services/jobBoostService.js'; - +import { createJobService } from '../services/jobCreateService.js'; +import { deleteJobService } from '../services/jobDeleteService.js'; +import { getJobsService } from '../services/jobService.js'; export const createJob = async (req, res) => { - console.log('🔍 createJob controller - Request body:', req.body); - console.log('🔍 createJob controller - imageUrl in request:', req.body.imageUrl); - console.log('🔍 createJob controller - Authenticated user:', req.user); - - // Use the authenticated user's clerkUserId instead of the one from request body - const jobData = { - ...req.body, - userId: req.user?.clerkUserId - }; - - const result = await createJobService(jobData); - if (result.errors) return res.status(400).json({ success: false, errors: result.errors }); - if (result.error) return res.status(400).json({ error: result.error }); - - console.log('🔍 createJob controller - Job created:', result.job); - res.status(201).json(result.job); + console.log('🔍 createJob controller - Request body:', req.body); + console.log( + '🔍 createJob controller - imageUrl in request:', + req.body.imageUrl, + ); + console.log('🔍 createJob controller - Authenticated user:', req.user); + + // Use the authenticated user's clerkUserId instead of the one from request body + const jobData = { + ...req.body, + userId: req.user?.clerkUserId, + }; + + const result = await createJobService(jobData); + if (result.errors) + return res.status(400).json({ success: false, errors: result.errors }); + if (result.error) return res.status(400).json({ error: result.error }); + + console.log('🔍 createJob controller - Job created:', result.job); + res.status(201).json(result.job); }; export const updateJob = async (req, res) => { - console.log('🔍 updateJob controller - Request body:', req.body); - console.log('🔍 updateJob controller - imageUrl in request:', req.body.imageUrl); - console.log('🔍 updateJob controller - Authenticated user:', req.user); - - // Include the authenticated user's clerkUserId in the update data - const updateData = { - ...req.body, - userId: req.user?.clerkUserId - }; - - const result = await updateJobService(req.params.id, updateData); - if (result.error) return res.status(400).json({ error: result.error }); - if (result.errors) return res.status(400).json({ success: false, errors: result.errors }); - - console.log('🔍 updateJob controller - Job updated:', result.updatedJob); - res.status(200).json(result.updatedJob); - }; - - export const deleteJob = async (req, res) => { - console.log('🔍 deleteJob controller - Authenticated user:', req.user); - - const result = await deleteJobService(req.params.id, req.user?.clerkUserId); - if (result.error) return res.status(400).json({ error: result.error }); - res.status(200).json({ message: 'Объявление удалено' }); - }; - - export const getJobs = async (req, res) => { - const lang = req.query.lang || 'ru'; - const { page, limit, category, city, salary, shuttle, meals } = req.query; - - // Pass all query parameters to the service - const filters = { - page: page ? parseInt(page) : 1, - limit: limit ? parseInt(limit) : 10, - category, - city, - salary: salary ? parseInt(salary) : undefined, - shuttle: shuttle === 'true', - meals: meals === 'true' - }; - - const result = await getJobsService(filters); - if (result.error) return res.status(500).json({ error: result.error }); - - const jobs = result.jobs.map(job => { - let categoryLabel = job.category?.name; - if (job.category?.translations?.length) { - const translation = job.category.translations.find(t => t.lang === lang); - if (translation) categoryLabel = translation.name; - } - return { - ...job, - category: job.category ? { ...job.category, label: categoryLabel } : null - }; - }); - - // Return both jobs and pagination info - res.status(200).json({ - jobs, - pagination: result.pagination - }); + console.log('🔍 updateJob controller - Request body:', req.body); + console.log( + '🔍 updateJob controller - imageUrl in request:', + req.body.imageUrl, + ); + console.log('🔍 updateJob controller - Authenticated user:', req.user); + + // Include the authenticated user's clerkUserId in the update data + const updateData = { + ...req.body, + userId: req.user?.clerkUserId, + }; + + const result = await updateJobService(req.params.id, updateData); + if (result.error) return res.status(400).json({ error: result.error }); + if (result.errors) + return res.status(400).json({ success: false, errors: result.errors }); + + console.log('🔍 updateJob controller - Job updated:', result.updatedJob); + res.status(200).json(result.updatedJob); +}; + +export const deleteJob = async (req, res) => { + console.log('🔍 deleteJob controller - Authenticated user:', req.user); + + const result = await deleteJobService(req.params.id, req.user?.clerkUserId); + if (result.error) return res.status(400).json({ error: result.error }); + res.status(200).json({ message: 'Объявление удалено' }); }; - export const boostJob = async (req, res) => { - const result = await boostJobService(req.params.id); - if (result.error) return res.status(400).json({ error: result.error }); - res.status(200).json(result.boostedJob); - }; - \ No newline at end of file +export const getJobs = async (req, res) => { + const lang = req.query.lang || 'ru'; + const { page, limit, category, city, salary, shuttle, meals } = req.query; + + // Pass all query parameters to the service + const filters = { + page: page ? parseInt(page) : 1, + limit: limit ? parseInt(limit) : 10, + category, + city, + salary: salary ? parseInt(salary) : undefined, + shuttle: shuttle === 'true', + meals: meals === 'true', + }; + + const result = await getJobsService(filters); + if (result.error) return res.status(500).json({ error: result.error }); + + const jobs = result.jobs.map((job) => { + let categoryLabel = job.category?.name; + if (job.category?.translations?.length) { + const translation = job.category.translations.find( + (t) => t.lang === lang, + ); + if (translation) categoryLabel = translation.name; + } + return { + ...job, + category: job.category ? { ...job.category, label: categoryLabel } : null, + }; + }); + + // Return both jobs and pagination info + res.status(200).json({ + jobs, + pagination: result.pagination, + }); +}; + +export const boostJob = async (req, res) => { + const result = await boostJobService(req.params.id); + if (result.error) return res.status(400).json({ error: result.error }); + res.status(200).json(result.boostedJob); +}; diff --git a/apps/api/controllers/messages.js b/apps/api/controllers/messages.js index 070926b..7ad0739 100644 --- a/apps/api/controllers/messages.js +++ b/apps/api/controllers/messages.js @@ -1,156 +1,162 @@ import { PrismaClient } from '@prisma/client'; import { sendEmail } from '../utils/mailer.js'; + // import nodemailer from 'nodemailer'; // Для реальной отправки email const prisma = new PrismaClient(); // Создать сообщение (и отправить email) export const createMessage = async (req, res) => { - try { - const { clerkUserId, title, body, type = 'system', fromAdminId } = req.body; - if (!clerkUserId || !title || !body) { - return res.status(400).json({ error: 'clerkUserId, title и body обязательны' }); - } - // Создаём сообщение в базе - const message = await prisma.message.create({ - data: { clerkUserId, title, body, type, fromAdminId }, - }); - // Получаем email пользователя - const user = await prisma.user.findUnique({ where: { clerkUserId } }); - if (user && user.email) { - // Отправляем email через nodemailer - try { - await sendEmail(user.email, title, `

${title}

${body}

`); - } catch (e) { - console.error('Ошибка отправки email:', e); - } - } - return res.json({ success: true, message }); - } catch (error) { - console.error('Ошибка создания сообщения:', error); - return res.status(500).json({ error: 'Ошибка создания сообщения' }); - } + try { + const { clerkUserId, title, body, type = 'system', fromAdminId } = req.body; + if (!clerkUserId || !title || !body) { + return res + .status(400) + .json({ error: 'clerkUserId, title и body обязательны' }); + } + // Создаём сообщение в базе + const message = await prisma.message.create({ + data: { clerkUserId, title, body, type, fromAdminId }, + }); + // Получаем email пользователя + const user = await prisma.user.findUnique({ where: { clerkUserId } }); + if (user && user.email) { + // Отправляем email через nodemailer + try { + await sendEmail(user.email, title, `

${title}

${body}

`); + } catch (e) { + console.error('Ошибка отправки email:', e); + } + } + return res.json({ success: true, message }); + } catch (error) { + console.error('Ошибка создания сообщения:', error); + return res.status(500).json({ error: 'Ошибка создания сообщения' }); + } }; // Получить все сообщения пользователя (по clerkUserId) export const getUserMessages = async (req, res) => { - try { - const { clerkUserId } = req.query; - if (!clerkUserId) return res.status(400).json({ error: 'clerkUserId обязателен' }); - const messages = await prisma.message.findMany({ - where: { clerkUserId }, - orderBy: { createdAt: 'desc' }, - }); - return res.json({ messages }); - } catch (error) { - console.error('Ошибка получения сообщений:', error); - return res.status(500).json({ error: 'Ошибка получения сообщений' }); - } + try { + const { clerkUserId } = req.query; + if (!clerkUserId) + return res.status(400).json({ error: 'clerkUserId обязателен' }); + const messages = await prisma.message.findMany({ + where: { clerkUserId }, + orderBy: { createdAt: 'desc' }, + }); + return res.json({ messages }); + } catch (error) { + console.error('Ошибка получения сообщений:', error); + return res.status(500).json({ error: 'Ошибка получения сообщений' }); + } }; // Отметить сообщение как прочитанное export const markMessageRead = async (req, res) => { - try { - const { id } = req.params; - const message = await prisma.message.update({ - where: { id }, - data: { isRead: true }, - }); - return res.json({ success: true, message }); - } catch (error) { - console.error('Ошибка отметки сообщения как прочитанного:', error); - return res.status(500).json({ error: 'Ошибка отметки сообщения' }); - } + try { + const { id } = req.params; + const message = await prisma.message.update({ + where: { id }, + data: { isRead: true }, + }); + return res.json({ success: true, message }); + } catch (error) { + console.error('Ошибка отметки сообщения как прочитанного:', error); + return res.status(500).json({ error: 'Ошибка отметки сообщения' }); + } }; // Массовая рассылка сообщений и email export const broadcastMessage = async (req, res) => { - try { - const { title, body, clerkUserIds } = req.body; - if (!title || !body) { - return res.status(400).json({ error: 'title и body обязательны' }); - } - - let users; - if (Array.isArray(clerkUserIds) && clerkUserIds.length > 0) { - users = await prisma.user.findMany({ where: { clerkUserId: { in: clerkUserIds } } }); - } else { - users = await prisma.user.findMany(); - } - let sent = 0; - for (const user of users) { - await prisma.message.create({ - data: { - clerkUserId: user.clerkUserId, - title, - body, - type: 'admin', - } - }); - if (user.email) { - try { - await sendEmail(user.email, title, body); - } catch (e) { - console.error('Ошибка отправки email:', e); - } - } - sent++; - } - return res.json({ success: true, count: sent }); - } catch (error) { - console.error('Ошибка рассылки:', error); - return res.status(500).json({ error: 'Ошибка рассылки' }); - } + try { + const { title, body, clerkUserIds } = req.body; + if (!title || !body) { + return res.status(400).json({ error: 'title и body обязательны' }); + } + + let users; + if (Array.isArray(clerkUserIds) && clerkUserIds.length > 0) { + users = await prisma.user.findMany({ + where: { clerkUserId: { in: clerkUserIds } }, + }); + } else { + users = await prisma.user.findMany(); + } + let sent = 0; + for (const user of users) { + await prisma.message.create({ + data: { + clerkUserId: user.clerkUserId, + title, + body, + type: 'admin', + }, + }); + if (user.email) { + try { + await sendEmail(user.email, title, body); + } catch (e) { + console.error('Ошибка отправки email:', e); + } + } + sent++; + } + return res.json({ success: true, count: sent }); + } catch (error) { + console.error('Ошибка рассылки:', error); + return res.status(500).json({ error: 'Ошибка рассылки' }); + } }; // Удалить сообщение export const deleteMessage = async (req, res) => { - try { - const { id } = req.params; - console.log('Attempting to delete message with ID:', id); - - if (!id) { - console.log('No message ID provided'); - return res.status(400).json({ error: 'Message ID is required' }); - } - - // Проверяем, существует ли сообщение - const message = await prisma.message.findUnique({ - where: { id } - }); - - console.log('Message found:', message ? 'Yes' : 'No'); - - if (!message) { - console.log('Message not found for ID:', id); - return res.status(404).json({ error: 'Сообщение не найдено' }); - } - - // Удаляем сообщение - const deletedMessage = await prisma.message.delete({ - where: { id } - }); - - console.log('Message deleted successfully:', deletedMessage.id); - - return res.json({ success: true, message: 'Сообщение удалено' }); - } catch (error) { - console.error('Error in deleteMessage:', error); - - // Handle specific Prisma errors - if (error.code === 'P2025') { - return res.status(404).json({ error: 'Сообщение не найдено' }); - } - - if (error.code === 'P2002') { - return res.status(400).json({ error: 'Invalid message ID format' }); - } - - return res.status(500).json({ error: 'Ошибка удаления сообщения' }); - } + try { + const { id } = req.params; + console.log('Attempting to delete message with ID:', id); + + if (!id) { + console.log('No message ID provided'); + return res.status(400).json({ error: 'Message ID is required' }); + } + + // Проверяем, существует ли сообщение + const message = await prisma.message.findUnique({ + where: { id }, + }); + + console.log('Message found:', message ? 'Yes' : 'No'); + + if (!message) { + console.log('Message not found for ID:', id); + return res.status(404).json({ error: 'Сообщение не найдено' }); + } + + // Удаляем сообщение + const deletedMessage = await prisma.message.delete({ + where: { id }, + }); + + console.log('Message deleted successfully:', deletedMessage.id); + + return res.json({ success: true, message: 'Сообщение удалено' }); + } catch (error) { + console.error('Error in deleteMessage:', error); + + // Handle specific Prisma errors + if (error.code === 'P2025') { + return res.status(404).json({ error: 'Сообщение не найдено' }); + } + + if (error.code === 'P2002') { + return res.status(400).json({ error: 'Invalid message ID format' }); + } + + return res.status(500).json({ error: 'Ошибка удаления сообщения' }); + } }; // Заготовка для отправки email (реализовать через nodemailer) // async function sendEmail(to, subject, text) { // // ... -// } \ No newline at end of file +// } diff --git a/apps/api/controllers/newsletterController.js b/apps/api/controllers/newsletterController.js index 39ef222..b7c112f 100644 --- a/apps/api/controllers/newsletterController.js +++ b/apps/api/controllers/newsletterController.js @@ -1,6 +1,10 @@ import { PrismaClient } from '@prisma/client'; import { sendInitialCandidatesToNewSubscriber } from '../services/candidateNotificationService.js'; -import { sendVerificationCode, storeVerificationCode, verifyCode } from '../services/snsService.js'; +import { + sendVerificationCode, + storeVerificationCode, + verifyCode, +} from '../services/snsService.js'; const prisma = new PrismaClient(); @@ -8,502 +12,504 @@ const prisma = new PrismaClient(); * Subscribe a user to the newsletter */ export async function subscribeToNewsletter(req, res) { - try { - const { - email, - firstName, - lastName, - language = 'ru', - preferences = {}, - // Filter preferences - preferredCities = [], - preferredCategories = [], - preferredEmployment = [], - preferredLanguages = [], - preferredGender = null, - preferredDocumentTypes = [], - onlyDemanded = false - } = req.body; - - // Validate email - if (!email || !email.trim()) { - return res.status(400).json({ - success: false, - message: 'Email is required' - }); - } - - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return res.status(400).json({ - success: false, - message: 'Invalid email format' - }); - } - - // Check if already subscribed - const existingSubscriber = await prisma.newsletterSubscriber.findUnique({ - where: { email: email.trim().toLowerCase() } - }); - - if (existingSubscriber) { - return res.status(409).json({ - success: false, - message: 'This email is already subscribed to the newsletter' - }); - } - - // Create new subscriber with filter preferences - const subscriber = await prisma.newsletterSubscriber.create({ - data: { - email: email.trim().toLowerCase(), - firstName: firstName?.trim() || null, - lastName: lastName?.trim() || null, - language, - preferences, - isActive: true, - // Filter preferences - preferredCities, - preferredCategories, - preferredEmployment, - preferredLanguages, - preferredGender, - preferredDocumentTypes, - onlyDemanded - } - }); - - console.log('✅ New newsletter subscriber:', subscriber.email); - - // Send candidates to new subscriber - try { - await sendInitialCandidatesToNewSubscriber(subscriber); - } catch (emailError) { - console.error('❌ Failed to send candidates email:', emailError); - // Don't fail the subscription if email fails - } - - res.status(201).json({ - success: true, - message: 'Successfully subscribed to newsletter', - subscriber: { - id: subscriber.id, - email: subscriber.email, - firstName: subscriber.firstName, - lastName: subscriber.lastName, - preferredCities: subscriber.preferredCities, - preferredCategories: subscriber.preferredCategories, - preferredEmployment: subscriber.preferredEmployment, - preferredLanguages: subscriber.preferredLanguages, - preferredGender: subscriber.preferredGender, - preferredDocumentTypes: subscriber.preferredDocumentTypes, - onlyDemanded: subscriber.onlyDemanded - } - }); - - } catch (error) { - console.error('❌ Newsletter subscription error:', error); - res.status(500).json({ - success: false, - message: 'Failed to subscribe to newsletter' - }); - } + try { + const { + email, + firstName, + lastName, + language = 'ru', + preferences = {}, + // Filter preferences + preferredCities = [], + preferredCategories = [], + preferredEmployment = [], + preferredLanguages = [], + preferredGender = null, + preferredDocumentTypes = [], + onlyDemanded = false, + } = req.body; + + // Validate email + if (!email || !email.trim()) { + return res.status(400).json({ + success: false, + message: 'Email is required', + }); + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return res.status(400).json({ + success: false, + message: 'Invalid email format', + }); + } + + // Check if already subscribed + const existingSubscriber = await prisma.newsletterSubscriber.findUnique({ + where: { email: email.trim().toLowerCase() }, + }); + + if (existingSubscriber) { + return res.status(409).json({ + success: false, + message: 'This email is already subscribed to the newsletter', + }); + } + + // Create new subscriber with filter preferences + const subscriber = await prisma.newsletterSubscriber.create({ + data: { + email: email.trim().toLowerCase(), + firstName: firstName?.trim() || null, + lastName: lastName?.trim() || null, + language, + preferences, + isActive: true, + // Filter preferences + preferredCities, + preferredCategories, + preferredEmployment, + preferredLanguages, + preferredGender, + preferredDocumentTypes, + onlyDemanded, + }, + }); + + console.log('✅ New newsletter subscriber:', subscriber.email); + + // Send candidates to new subscriber + try { + await sendInitialCandidatesToNewSubscriber(subscriber); + } catch (emailError) { + console.error('❌ Failed to send candidates email:', emailError); + // Don't fail the subscription if email fails + } + + res.status(201).json({ + success: true, + message: 'Successfully subscribed to newsletter', + subscriber: { + id: subscriber.id, + email: subscriber.email, + firstName: subscriber.firstName, + lastName: subscriber.lastName, + preferredCities: subscriber.preferredCities, + preferredCategories: subscriber.preferredCategories, + preferredEmployment: subscriber.preferredEmployment, + preferredLanguages: subscriber.preferredLanguages, + preferredGender: subscriber.preferredGender, + preferredDocumentTypes: subscriber.preferredDocumentTypes, + onlyDemanded: subscriber.onlyDemanded, + }, + }); + } catch (error) { + console.error('❌ Newsletter subscription error:', error); + res.status(500).json({ + success: false, + message: 'Failed to subscribe to newsletter', + }); + } } /** * Unsubscribe from newsletter */ export async function unsubscribeFromNewsletter(req, res) { - try { - const { email } = req.body; - - if (!email) { - return res.status(400).json({ - success: false, - message: 'Email is required' - }); - } - - const subscriber = await prisma.newsletterSubscriber.findUnique({ - where: { email: email.trim().toLowerCase() } - }); - - if (!subscriber) { - return res.status(404).json({ - success: false, - message: 'Subscriber not found' - }); - } - - // Delete subscriber from database - await prisma.newsletterSubscriber.delete({ - where: { id: subscriber.id } - }); - - console.log('✅ Successfully deleted subscriber:', subscriber.email); - - res.json({ - success: true, - message: 'Successfully unsubscribed from newsletter' - }); - - } catch (error) { - console.error('❌ Newsletter unsubscribe error:', error); - res.status(500).json({ - success: false, - message: 'Failed to unsubscribe from newsletter' - }); - } + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + success: false, + message: 'Email is required', + }); + } + + const subscriber = await prisma.newsletterSubscriber.findUnique({ + where: { email: email.trim().toLowerCase() }, + }); + + if (!subscriber) { + return res.status(404).json({ + success: false, + message: 'Subscriber not found', + }); + } + + // Delete subscriber from database + await prisma.newsletterSubscriber.delete({ + where: { id: subscriber.id }, + }); + + console.log('✅ Successfully deleted subscriber:', subscriber.email); + + res.json({ + success: true, + message: 'Successfully unsubscribed from newsletter', + }); + } catch (error) { + console.error('❌ Newsletter unsubscribe error:', error); + res.status(500).json({ + success: false, + message: 'Failed to unsubscribe from newsletter', + }); + } } /** * Get all newsletter subscribers (admin only) */ export async function getNewsletterSubscribers(req, res) { - try { - const subscribers = await prisma.newsletterSubscriber.findMany({ - orderBy: { createdAt: 'desc' }, - select: { - id: true, - email: true, - firstName: true, - lastName: true, - language: true, - createdAt: true - } - }); - - res.json({ - success: true, - subscribers, - total: subscribers.length - }); - - } catch (error) { - console.error('❌ Error getting newsletter subscribers:', error); - res.status(500).json({ - success: false, - message: 'Failed to get newsletter subscribers' - }); - } + try { + const subscribers = await prisma.newsletterSubscriber.findMany({ + orderBy: { createdAt: 'desc' }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + language: true, + createdAt: true, + }, + }); + + res.json({ + success: true, + subscribers, + total: subscribers.length, + }); + } catch (error) { + console.error('❌ Error getting newsletter subscribers:', error); + res.status(500).json({ + success: false, + message: 'Failed to get newsletter subscribers', + }); + } } /** * Check if email is already subscribed to newsletter */ export async function checkSubscriptionStatus(req, res) { - try { - const { email } = req.query; - - if (!email) { - return res.status(400).json({ - success: false, - message: 'Email is required' - }); - } - - const subscriber = await prisma.newsletterSubscriber.findUnique({ - where: { email: email.trim().toLowerCase() }, - select: { - id: true, - email: true, - firstName: true, - lastName: true, - language: true, - createdAt: true, - // Filter preferences - preferredCities: true, - preferredCategories: true, - preferredEmployment: true, - preferredLanguages: true, - preferredGender: true, - preferredDocumentTypes: true, - onlyDemanded: true - } - }); - - if (subscriber) { - res.json({ - success: true, - isSubscribed: true, - subscriber - }); - } else { - res.json({ - success: true, - isSubscribed: false, - subscriber: null - }); - } - - } catch (error) { - console.error('❌ Error checking subscription status:', error); - res.status(500).json({ - success: false, - message: 'Failed to check subscription status' - }); - } + try { + const { email } = req.query; + + if (!email) { + return res.status(400).json({ + success: false, + message: 'Email is required', + }); + } + + const subscriber = await prisma.newsletterSubscriber.findUnique({ + where: { email: email.trim().toLowerCase() }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + language: true, + createdAt: true, + // Filter preferences + preferredCities: true, + preferredCategories: true, + preferredEmployment: true, + preferredLanguages: true, + preferredGender: true, + preferredDocumentTypes: true, + onlyDemanded: true, + }, + }); + + if (subscriber) { + res.json({ + success: true, + isSubscribed: true, + subscriber, + }); + } else { + res.json({ + success: true, + isSubscribed: false, + subscriber: null, + }); + } + } catch (error) { + console.error('❌ Error checking subscription status:', error); + res.status(500).json({ + success: false, + message: 'Failed to check subscription status', + }); + } } /** * Update newsletter subscription preferences */ export async function updateNewsletterPreferences(req, res) { - try { - const { email } = req.params; - const { - preferredCities = [], - preferredCategories = [], - preferredEmployment = [], - preferredLanguages = [], - preferredGender = null, - preferredDocumentTypes = [], - onlyDemanded = false - } = req.body; - - if (!email) { - return res.status(400).json({ - success: false, - message: 'Email is required' - }); - } - - const subscriber = await prisma.newsletterSubscriber.findUnique({ - where: { email: email.trim().toLowerCase() } - }); - - if (!subscriber) { - return res.status(404).json({ - success: false, - message: 'Subscriber not found' - }); - } - - // Update subscriber preferences - const updatedSubscriber = await prisma.newsletterSubscriber.update({ - where: { id: subscriber.id }, - data: { - preferredCities, - preferredCategories, - preferredEmployment, - preferredLanguages, - preferredGender, - preferredDocumentTypes, - onlyDemanded - } - }); - - console.log('✅ Updated newsletter preferences for:', updatedSubscriber.email); - - res.json({ - success: true, - message: 'Newsletter preferences updated successfully', - subscriber: { - id: updatedSubscriber.id, - email: updatedSubscriber.email, - firstName: updatedSubscriber.firstName, - lastName: updatedSubscriber.lastName, - preferredCities: updatedSubscriber.preferredCities, - preferredCategories: updatedSubscriber.preferredCategories, - preferredEmployment: updatedSubscriber.preferredEmployment, - preferredLanguages: updatedSubscriber.preferredLanguages, - preferredGender: updatedSubscriber.preferredGender, - preferredDocumentTypes: updatedSubscriber.preferredDocumentTypes, - onlyDemanded: updatedSubscriber.onlyDemanded - } - }); - - } catch (error) { - console.error('❌ Error updating newsletter preferences:', error); - res.status(500).json({ - success: false, - message: 'Failed to update newsletter preferences' - }); - } + try { + const { email } = req.params; + const { + preferredCities = [], + preferredCategories = [], + preferredEmployment = [], + preferredLanguages = [], + preferredGender = null, + preferredDocumentTypes = [], + onlyDemanded = false, + } = req.body; + + if (!email) { + return res.status(400).json({ + success: false, + message: 'Email is required', + }); + } + + const subscriber = await prisma.newsletterSubscriber.findUnique({ + where: { email: email.trim().toLowerCase() }, + }); + + if (!subscriber) { + return res.status(404).json({ + success: false, + message: 'Subscriber not found', + }); + } + + // Update subscriber preferences + const updatedSubscriber = await prisma.newsletterSubscriber.update({ + where: { id: subscriber.id }, + data: { + preferredCities, + preferredCategories, + preferredEmployment, + preferredLanguages, + preferredGender, + preferredDocumentTypes, + onlyDemanded, + }, + }); + + console.log( + '✅ Updated newsletter preferences for:', + updatedSubscriber.email, + ); + + res.json({ + success: true, + message: 'Newsletter preferences updated successfully', + subscriber: { + id: updatedSubscriber.id, + email: updatedSubscriber.email, + firstName: updatedSubscriber.firstName, + lastName: updatedSubscriber.lastName, + preferredCities: updatedSubscriber.preferredCities, + preferredCategories: updatedSubscriber.preferredCategories, + preferredEmployment: updatedSubscriber.preferredEmployment, + preferredLanguages: updatedSubscriber.preferredLanguages, + preferredGender: updatedSubscriber.preferredGender, + preferredDocumentTypes: updatedSubscriber.preferredDocumentTypes, + onlyDemanded: updatedSubscriber.onlyDemanded, + }, + }); + } catch (error) { + console.error('❌ Error updating newsletter preferences:', error); + res.status(500).json({ + success: false, + message: 'Failed to update newsletter preferences', + }); + } } /** * Send verification code for newsletter subscription */ export async function sendNewsletterVerificationCode(req, res) { - try { - const { - email, - firstName, - lastName, - language = 'ru', - preferences = {}, - preferredCities = [], - preferredCategories = [], - preferredEmployment = [], - preferredLanguages = [], - preferredGender = null, - preferredDocumentTypes = [], - onlyDemanded = false - } = req.body; - - // Validate email - if (!email || !email.trim()) { - return res.status(400).json({ - success: false, - message: 'Email is required' - }); - } - - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return res.status(400).json({ - success: false, - message: 'Invalid email format' - }); - } - - // Check if already subscribed (only active subscribers) - const existingSubscriber = await prisma.newsletterSubscriber.findFirst({ - where: { - email: email.trim().toLowerCase(), - isActive: true - } - }); - - if (existingSubscriber) { - return res.status(409).json({ - success: false, - message: 'This email is already subscribed to the newsletter' - }); - } - - // Generate verification code - const verificationCode = Math.floor(100000 + Math.random() * 900000).toString(); - console.log('🔢 Generated verification code:', verificationCode); - - try { - // Store verification code - console.log('💾 Storing verification code...'); - await storeVerificationCode(email.trim().toLowerCase(), verificationCode); - console.log('✅ Verification code stored successfully'); - - // Send verification code via SNS - console.log('📧 Sending verification code...'); - await sendVerificationCode(email.trim().toLowerCase(), verificationCode); - console.log('✅ Verification code sent successfully'); - } catch (error) { - console.error('❌ Error in verification process:', error); - throw error; - } - - console.log('✅ Verification code sent to:', email); - - res.json({ - success: true, - message: 'Verification code sent to your email', - email: email.trim().toLowerCase(), - // Store subscription data temporarily for verification - subscriptionData: { - email: email.trim().toLowerCase(), - firstName: firstName?.trim() || null, - lastName: lastName?.trim() || null, - language, - preferences, - preferredCities, - preferredCategories, - preferredEmployment, - preferredLanguages, - preferredGender, - preferredDocumentTypes, - onlyDemanded - } - }); - - } catch (error) { - console.error('❌ Error sending verification code:', error); - res.status(500).json({ - success: false, - message: 'Failed to send verification code' - }); - } + try { + const { + email, + firstName, + lastName, + language = 'ru', + preferences = {}, + preferredCities = [], + preferredCategories = [], + preferredEmployment = [], + preferredLanguages = [], + preferredGender = null, + preferredDocumentTypes = [], + onlyDemanded = false, + } = req.body; + + // Validate email + if (!email || !email.trim()) { + return res.status(400).json({ + success: false, + message: 'Email is required', + }); + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return res.status(400).json({ + success: false, + message: 'Invalid email format', + }); + } + + // Check if already subscribed (only active subscribers) + const existingSubscriber = await prisma.newsletterSubscriber.findFirst({ + where: { + email: email.trim().toLowerCase(), + isActive: true, + }, + }); + + if (existingSubscriber) { + return res.status(409).json({ + success: false, + message: 'This email is already subscribed to the newsletter', + }); + } + + // Generate verification code + const verificationCode = Math.floor( + 100000 + Math.random() * 900000, + ).toString(); + console.log('🔢 Generated verification code:', verificationCode); + + try { + // Store verification code + console.log('💾 Storing verification code...'); + await storeVerificationCode(email.trim().toLowerCase(), verificationCode); + console.log('✅ Verification code stored successfully'); + + // Send verification code via SNS + console.log('📧 Sending verification code...'); + await sendVerificationCode(email.trim().toLowerCase(), verificationCode); + console.log('✅ Verification code sent successfully'); + } catch (error) { + console.error('❌ Error in verification process:', error); + throw error; + } + + console.log('✅ Verification code sent to:', email); + + res.json({ + success: true, + message: 'Verification code sent to your email', + email: email.trim().toLowerCase(), + // Store subscription data temporarily for verification + subscriptionData: { + email: email.trim().toLowerCase(), + firstName: firstName?.trim() || null, + lastName: lastName?.trim() || null, + language, + preferences, + preferredCities, + preferredCategories, + preferredEmployment, + preferredLanguages, + preferredGender, + preferredDocumentTypes, + onlyDemanded, + }, + }); + } catch (error) { + console.error('❌ Error sending verification code:', error); + res.status(500).json({ + success: false, + message: 'Failed to send verification code', + }); + } } /** * Verify code and complete newsletter subscription */ export async function verifyNewsletterCode(req, res) { - try { - const { email, code, subscriptionData } = req.body; - - if (!email || !code) { - return res.status(400).json({ - success: false, - message: 'Email and verification code are required' - }); - } - - // Verify the code - const verificationResult = await verifyCode(email.trim().toLowerCase(), code); - - if (!verificationResult.valid) { - return res.status(400).json({ - success: false, - message: verificationResult.message - }); - } - - // Create subscriber - const subscriber = await prisma.newsletterSubscriber.create({ - data: { - email: email.trim().toLowerCase(), - firstName: subscriptionData?.firstName || null, - lastName: subscriptionData?.lastName || null, - language: subscriptionData?.language || 'ru', - preferences: subscriptionData?.preferences || {}, - isActive: true, - preferredCities: subscriptionData?.preferredCities || [], - preferredCategories: subscriptionData?.preferredCategories || [], - preferredEmployment: subscriptionData?.preferredEmployment || [], - preferredLanguages: subscriptionData?.preferredLanguages || [], - preferredGender: subscriptionData?.preferredGender || null, - preferredDocumentTypes: subscriptionData?.preferredDocumentTypes || [], - onlyDemanded: subscriptionData?.onlyDemanded || false - } - }); - - console.log('✅ Newsletter subscription verified and completed:', subscriber.email); - - // Send candidates to new subscriber - try { - await sendInitialCandidatesToNewSubscriber(subscriber); - } catch (emailError) { - console.error('❌ Failed to send candidates email:', emailError); - // Don't fail the subscription if email fails - } - - res.json({ - success: true, - message: 'Successfully subscribed to newsletter', - subscriber: { - id: subscriber.id, - email: subscriber.email, - firstName: subscriber.firstName, - lastName: subscriber.lastName, - preferredCities: subscriber.preferredCities, - preferredCategories: subscriber.preferredCategories, - preferredEmployment: subscriber.preferredEmployment, - preferredLanguages: subscriber.preferredLanguages, - preferredGender: subscriber.preferredGender, - preferredDocumentTypes: subscriber.preferredDocumentTypes, - onlyDemanded: subscriber.onlyDemanded - } - }); - - } catch (error) { - console.error('❌ Error verifying newsletter code:', error); - res.status(500).json({ - success: false, - message: 'Failed to verify code and complete subscription' - }); - } + try { + const { email, code, subscriptionData } = req.body; + + if (!email || !code) { + return res.status(400).json({ + success: false, + message: 'Email and verification code are required', + }); + } + + // Verify the code + const verificationResult = await verifyCode( + email.trim().toLowerCase(), + code, + ); + + if (!verificationResult.valid) { + return res.status(400).json({ + success: false, + message: verificationResult.message, + }); + } + + // Create subscriber + const subscriber = await prisma.newsletterSubscriber.create({ + data: { + email: email.trim().toLowerCase(), + firstName: subscriptionData?.firstName || null, + lastName: subscriptionData?.lastName || null, + language: subscriptionData?.language || 'ru', + preferences: subscriptionData?.preferences || {}, + isActive: true, + preferredCities: subscriptionData?.preferredCities || [], + preferredCategories: subscriptionData?.preferredCategories || [], + preferredEmployment: subscriptionData?.preferredEmployment || [], + preferredLanguages: subscriptionData?.preferredLanguages || [], + preferredGender: subscriptionData?.preferredGender || null, + preferredDocumentTypes: subscriptionData?.preferredDocumentTypes || [], + onlyDemanded: subscriptionData?.onlyDemanded || false, + }, + }); + + console.log( + '✅ Newsletter subscription verified and completed:', + subscriber.email, + ); + + // Send candidates to new subscriber + try { + await sendInitialCandidatesToNewSubscriber(subscriber); + } catch (emailError) { + console.error('❌ Failed to send candidates email:', emailError); + // Don't fail the subscription if email fails + } + + res.json({ + success: true, + message: 'Successfully subscribed to newsletter', + subscriber: { + id: subscriber.id, + email: subscriber.email, + firstName: subscriber.firstName, + lastName: subscriber.lastName, + preferredCities: subscriber.preferredCities, + preferredCategories: subscriber.preferredCategories, + preferredEmployment: subscriber.preferredEmployment, + preferredLanguages: subscriber.preferredLanguages, + preferredGender: subscriber.preferredGender, + preferredDocumentTypes: subscriber.preferredDocumentTypes, + onlyDemanded: subscriber.onlyDemanded, + }, + }); + } catch (error) { + console.error('❌ Error verifying newsletter code:', error); + res.status(500).json({ + success: false, + message: 'Failed to verify code and complete subscription', + }); + } } - - \ No newline at end of file diff --git a/apps/api/controllers/payments.js b/apps/api/controllers/payments.js index ccda787..ef9c106 100755 --- a/apps/api/controllers/payments.js +++ b/apps/api/controllers/payments.js @@ -1,348 +1,389 @@ -import stripe from '../utils/stripe.js'; import { PrismaClient } from '@prisma/client'; -import { sendTelegramNotification } from '../utils/telegram.js'; -import { CLERK_SECRET_KEY } from '../config/clerkConfig.js'; -import { sendPremiumDeluxeWelcomeEmail, sendProWelcomeEmail } from '../services/premiumEmailService.js'; import fetch from 'node-fetch'; -const prisma = new PrismaClient(); +import { CLERK_SECRET_KEY } from '../config/clerkConfig.js'; +import { + sendPremiumDeluxeWelcomeEmail, + sendProWelcomeEmail, +} from '../services/premiumEmailService.js'; +import stripe from '../utils/stripe.js'; +import { sendTelegramNotification } from '../utils/telegram.js'; +const prisma = new PrismaClient(); export const createCheckoutSession = async (req, res) => { - const { clerkUserId, priceId } = req.body; - - if (!clerkUserId) { - return res.status(400).json({ error: 'clerkUserId is required' }); - } - - let user, finalPriceId; - - try { - // 🔹 Получаем пользователя из базы - user = await prisma.user.findUnique({ - where: { clerkUserId }, - }); - - if (!user || !user.email) { - return res.status(404).json({ error: 'Пользователь не найден или отсутствует email' }); - } - - // ✅ Формируем ссылки для продакшена - FORCE NEW DOMAIN - const successUrl = "https://worknow.co.il/success?session_id={CHECKOUT_SESSION_ID}"; - const cancelUrl = "https://worknow.co.il/cancel"; - - // 🔹 Выбираем нужный priceId - const defaultPriceId = 'price_1Qt5J0COLiDbHvw1IQNl90uU'; // Pro plan recurring subscription price ID - finalPriceId = priceId || defaultPriceId; - - // 🔹 Проверяем существование price ID в Stripe - try { - await stripe.prices.retrieve(finalPriceId); - } catch { - // Fallback to default price ID - finalPriceId = defaultPriceId; - } - - // 🔹 Создаем Stripe Checkout Session - const session = await stripe.checkout.sessions.create({ - payment_method_types: ['card'], - mode: 'subscription', - customer_email: user.email, - line_items: [ - { - price: finalPriceId, - quantity: 1, - }, - ], - success_url: successUrl, - cancel_url: cancelUrl, - metadata: { clerkUserId, priceId: finalPriceId }, - }); - - res.json({ url: session.url }); - } catch (error) { - console.error('❌ Ошибка при создании Checkout Session:', error); - - // Provide more specific error messages - if (error.type === 'StripeInvalidRequestError' && error.message.includes('price')) { - res.status(400).json({ error: `Неверный price ID: ${priceId}. Пожалуйста, обратитесь к администратору.` }); - } else { - res.status(500).json({ error: 'Ошибка при создании сессии' }); - } - } + const { clerkUserId, priceId } = req.body; + + if (!clerkUserId) { + return res.status(400).json({ error: 'clerkUserId is required' }); + } + + let user, finalPriceId; + + try { + // 🔹 Получаем пользователя из базы + user = await prisma.user.findUnique({ + where: { clerkUserId }, + }); + + if (!user || !user.email) { + return res + .status(404) + .json({ error: 'Пользователь не найден или отсутствует email' }); + } + + // ✅ Формируем ссылки для продакшена - FORCE NEW DOMAIN + const successUrl = + 'https://worknow.co.il/success?session_id={CHECKOUT_SESSION_ID}'; + const cancelUrl = 'https://worknow.co.il/cancel'; + + // 🔹 Выбираем нужный priceId + const defaultPriceId = 'price_1Qt5J0COLiDbHvw1IQNl90uU'; // Pro plan recurring subscription price ID + finalPriceId = priceId || defaultPriceId; + + // 🔹 Проверяем существование price ID в Stripe + try { + await stripe.prices.retrieve(finalPriceId); + } catch { + // Fallback to default price ID + finalPriceId = defaultPriceId; + } + + // 🔹 Создаем Stripe Checkout Session + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + mode: 'subscription', + customer_email: user.email, + line_items: [ + { + price: finalPriceId, + quantity: 1, + }, + ], + success_url: successUrl, + cancel_url: cancelUrl, + metadata: { clerkUserId, priceId: finalPriceId }, + }); + + res.json({ url: session.url }); + } catch (error) { + console.error('❌ Ошибка при создании Checkout Session:', error); + + // Provide more specific error messages + if ( + error.type === 'StripeInvalidRequestError' && + error.message.includes('price') + ) { + res.status(400).json({ + error: `Неверный price ID: ${priceId}. Пожалуйста, обратитесь к администратору.`, + }); + } else { + res.status(500).json({ error: 'Ошибка при создании сессии' }); + } + } }; export const activatePremium = async (req, res) => { - const { sessionId } = req.body; - - console.log('🔍 activatePremium called with sessionId:', sessionId); - - try { - const session = await stripe.checkout.sessions.retrieve(sessionId); - const clerkUserId = session.metadata.clerkUserId; // Получаем ID пользователя - const subscriptionId = session.subscription; // ID подписки в Stripe - const priceId = session.metadata.priceId; - - console.log('🔍 Activating premium with session data:', { - sessionId, - clerkUserId, - subscriptionId, - priceId, - paymentStatus: session.payment_status - }); - - if (session.payment_status === 'paid') { - // Set premiumDeluxe flag for Deluxe subscriptions only (not Pro) - const premiumDeluxe = priceId === 'price_1RfHjiCOLiDbHvw1repgIbnK' || priceId === 'price_1Rfli2COLiDbHvw1xdMaguLf' || priceId === 'price_1RqXuoCOLiDbHvw1LLew4Mo8' || priceId === 'price_1RqXveCOLiDbHvw18RQxj2g6'; - - console.log('🔍 Updating user with premium data:', { - clerkUserId, - priceId, - premiumDeluxe, - willSetPremiumDeluxe: premiumDeluxe - }); - - const user = await prisma.user.update({ - where: { clerkUserId }, - data: { - isPremium: true, - premiumEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 дней подписки - isAutoRenewal: !!subscriptionId, - stripeSubscriptionId: subscriptionId || null, - premiumDeluxe: premiumDeluxe, - }, - include: { jobs: { include: { city: true } } }, // Подгружаем вакансии - }); - - // User updated successfully - - // 🔹 Отправляем уведомление в Telegram - await sendTelegramNotification(user, user.jobs); - - // Если deluxe — отправляем автоматическое сообщение и email - // Deluxe price IDs: price_1RfHjiCOLiDbHvw1repgIbnK, price_1Rfli2COLiDbHvw1xdMaguLf, price_1RqXuoCOLiDbHvw1LLew4Mo8, price_1RqXveCOLiDbHvw18RQxj2g6 - // Pro price ID: price_1Qt63NCOLiDbHvw13PRhpenX (excluded from deluxe condition) - if (priceId === 'price_1RfHjiCOLiDbHvw1repgIbnK' || priceId === 'price_1Rfli2COLiDbHvw1xdMaguLf' || priceId === 'price_1RqXuoCOLiDbHvw1LLew4Mo8' || priceId === 'price_1RqXveCOLiDbHvw18RQxj2g6') { - // Можно кастомизировать текст и контакты менеджера - await prisma.message.create({ - data: { - clerkUserId, - title: 'Добро пожаловать в Premium Deluxe!', - body: 'Для активации функции автопостинга напишите вашему персональному менеджеру: peterbaikov12@gmail.com', - type: 'system', - } - }); - - // Send Premium Deluxe welcome email - try { - const userName = user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : ''; - await sendPremiumDeluxeWelcomeEmail(user.email, userName); - console.log('✅ Premium Deluxe welcome email sent successfully to:', user.email); - } catch (emailError) { - console.error('❌ Failed to send Premium Deluxe welcome email:', emailError); - // Don't fail the entire process if email fails - } - } else { - // Pro subscription — поздравительное письмо и сообщение - const title = 'Спасибо за покупку Pro подписки на WorkNow!'; - const body = `Здравствуйте!

+ const { sessionId } = req.body; + + console.log('🔍 activatePremium called with sessionId:', sessionId); + + try { + const session = await stripe.checkout.sessions.retrieve(sessionId); + const clerkUserId = session.metadata.clerkUserId; // Получаем ID пользователя + const subscriptionId = session.subscription; // ID подписки в Stripe + const priceId = session.metadata.priceId; + + console.log('🔍 Activating premium with session data:', { + sessionId, + clerkUserId, + subscriptionId, + priceId, + paymentStatus: session.payment_status, + }); + + if (session.payment_status === 'paid') { + // Set premiumDeluxe flag for Deluxe subscriptions only (not Pro) + const premiumDeluxe = + priceId === 'price_1RfHjiCOLiDbHvw1repgIbnK' || + priceId === 'price_1Rfli2COLiDbHvw1xdMaguLf' || + priceId === 'price_1RqXuoCOLiDbHvw1LLew4Mo8' || + priceId === 'price_1RqXveCOLiDbHvw18RQxj2g6'; + + console.log('🔍 Updating user with premium data:', { + clerkUserId, + priceId, + premiumDeluxe, + willSetPremiumDeluxe: premiumDeluxe, + }); + + const user = await prisma.user.update({ + where: { clerkUserId }, + data: { + isPremium: true, + premiumEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 дней подписки + isAutoRenewal: !!subscriptionId, + stripeSubscriptionId: subscriptionId || null, + premiumDeluxe: premiumDeluxe, + }, + include: { jobs: { include: { city: true } } }, // Подгружаем вакансии + }); + + // User updated successfully + + // 🔹 Отправляем уведомление в Telegram + await sendTelegramNotification(user, user.jobs); + + // Если deluxe — отправляем автоматическое сообщение и email + // Deluxe price IDs: price_1RfHjiCOLiDbHvw1repgIbnK, price_1Rfli2COLiDbHvw1xdMaguLf, price_1RqXuoCOLiDbHvw1LLew4Mo8, price_1RqXveCOLiDbHvw18RQxj2g6 + // Pro price ID: price_1Qt63NCOLiDbHvw13PRhpenX (excluded from deluxe condition) + if ( + priceId === 'price_1RfHjiCOLiDbHvw1repgIbnK' || + priceId === 'price_1Rfli2COLiDbHvw1xdMaguLf' || + priceId === 'price_1RqXuoCOLiDbHvw1LLew4Mo8' || + priceId === 'price_1RqXveCOLiDbHvw18RQxj2g6' + ) { + // Можно кастомизировать текст и контакты менеджера + await prisma.message.create({ + data: { + clerkUserId, + title: 'Добро пожаловать в Premium Deluxe!', + body: 'Для активации функции автопостинга напишите вашему персональному менеджеру: peterbaikov12@gmail.com', + type: 'system', + }, + }); + + // Send Premium Deluxe welcome email + try { + const userName = user.firstName + ? `${user.firstName} ${user.lastName || ''}`.trim() + : ''; + await sendPremiumDeluxeWelcomeEmail(user.email, userName); + console.log( + '✅ Premium Deluxe welcome email sent successfully to:', + user.email, + ); + } catch (emailError) { + console.error( + '❌ Failed to send Premium Deluxe welcome email:', + emailError, + ); + // Don't fail the entire process if email fails + } + } else { + // Pro subscription — поздравительное письмо и сообщение + const title = 'Спасибо за покупку Pro подписки на WorkNow!'; + const body = `Здравствуйте!

Спасибо, что приобрели Pro подписку на WorkNow.
Ваша подписка активирована.
Чек об оплате был отправлен на ваш электронный адрес.

Если у вас возникнут вопросы — пишите в поддержку!`; - await prisma.message.create({ - data: { - clerkUserId, - title, - body, - type: 'system', - } - }); - - // Send Pro welcome email - try { - const userName = user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : ''; - await sendProWelcomeEmail(user.email, userName); - console.log('✅ Pro welcome email sent successfully to:', user.email); - } catch (emailError) { - console.error('❌ Failed to send Pro welcome email:', emailError); - // Don't fail the entire process if email fails - } - } - - // --- Обновляем publicMetadata в Clerk --- - const publicMetadata = { - isPremium: true, - premiumDeluxe: priceId === 'price_1RfHjiCOLiDbHvw1repgIbnK' || priceId === 'price_1Rfli2COLiDbHvw1xdMaguLf' || priceId === 'price_1RqXuoCOLiDbHvw1LLew4Mo8' || priceId === 'price_1RqXveCOLiDbHvw18RQxj2g6', - }; - await fetch(`https://api.clerk.com/v1/users/${clerkUserId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${CLERK_SECRET_KEY}`, - }, - body: JSON.stringify({ public_metadata: publicMetadata }), - }); - // --- конец обновления Clerk --- - - res.json({ success: true }); - } else { - res.status(400).json({ error: 'Платеж не прошел' }); - } - } catch (error) { - console.error('❌ Ошибка активации премиума:', error); - res.status(500).json({ error: 'Ошибка активации премиума' }); - } + await prisma.message.create({ + data: { + clerkUserId, + title, + body, + type: 'system', + }, + }); + + // Send Pro welcome email + try { + const userName = user.firstName + ? `${user.firstName} ${user.lastName || ''}`.trim() + : ''; + await sendProWelcomeEmail(user.email, userName); + console.log('✅ Pro welcome email sent successfully to:', user.email); + } catch (emailError) { + console.error('❌ Failed to send Pro welcome email:', emailError); + // Don't fail the entire process if email fails + } + } + + // --- Обновляем publicMetadata в Clerk --- + const publicMetadata = { + isPremium: true, + premiumDeluxe: + priceId === 'price_1RfHjiCOLiDbHvw1repgIbnK' || + priceId === 'price_1Rfli2COLiDbHvw1xdMaguLf' || + priceId === 'price_1RqXuoCOLiDbHvw1LLew4Mo8' || + priceId === 'price_1RqXveCOLiDbHvw18RQxj2g6', + }; + await fetch(`https://api.clerk.com/v1/users/${clerkUserId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${CLERK_SECRET_KEY}`, + }, + body: JSON.stringify({ public_metadata: publicMetadata }), + }); + // --- конец обновления Clerk --- + + res.json({ success: true }); + } else { + res.status(400).json({ error: 'Платеж не прошел' }); + } + } catch (error) { + console.error('❌ Ошибка активации премиума:', error); + res.status(500).json({ error: 'Ошибка активации премиума' }); + } }; export const cancelAutoRenewal = async (req, res) => { - - const { clerkUserId } = req.body; - - try { - const user = await prisma.user.findUnique({ - where: { clerkUserId }, - }); - - if (!user) { - console.error("❌ Ошибка: пользователь не найден."); - return res.status(404).json({ error: 'Пользователь не найден' }); - } - - if (!user.isAutoRenewal) { - console.warn("⚠️ Автопродление уже отключено."); - return res.status(400).json({ error: 'Автопродление уже отключено' }); - } - - if (!user.stripeSubscriptionId) { - // Нет Stripe-подписки, но автопродление включено — просто отключаем в базе - console.warn(`⚠️ У пользователя ${user.email} нет stripeSubscriptionId, но isAutoRenewal=true. Отключаем только в базе.`); - await prisma.user.update({ - where: { clerkUserId }, - data: { isAutoRenewal: false }, - }); - return res.json({ success: true, message: 'Автопродление подписки отключено (без Stripe).' }); - } - - // 🔹 Отключаем автопродление в Stripe - await stripe.subscriptions.update(user.stripeSubscriptionId, { - cancel_at_period_end: true, - }); - - // 🔹 Обновляем статус в базе - await prisma.user.update({ - where: { clerkUserId }, - data: { isAutoRenewal: false }, - }); - - res.json({ success: true, message: 'Автопродление подписки отключено.' }); - } catch (error) { - console.error('❌ Ошибка при отключении автообновления:', error); - res.status(500).json({ error: 'Ошибка при отключении автообновления' }); - } + const { clerkUserId } = req.body; + + try { + const user = await prisma.user.findUnique({ + where: { clerkUserId }, + }); + + if (!user) { + console.error('❌ Ошибка: пользователь не найден.'); + return res.status(404).json({ error: 'Пользователь не найден' }); + } + + if (!user.isAutoRenewal) { + console.warn('⚠️ Автопродление уже отключено.'); + return res.status(400).json({ error: 'Автопродление уже отключено' }); + } + + if (!user.stripeSubscriptionId) { + // Нет Stripe-подписки, но автопродление включено — просто отключаем в базе + console.warn( + `⚠️ У пользователя ${user.email} нет stripeSubscriptionId, но isAutoRenewal=true. Отключаем только в базе.`, + ); + await prisma.user.update({ + where: { clerkUserId }, + data: { isAutoRenewal: false }, + }); + return res.json({ + success: true, + message: 'Автопродление подписки отключено (без Stripe).', + }); + } + + // 🔹 Отключаем автопродление в Stripe + await stripe.subscriptions.update(user.stripeSubscriptionId, { + cancel_at_period_end: true, + }); + + // 🔹 Обновляем статус в базе + await prisma.user.update({ + where: { clerkUserId }, + data: { isAutoRenewal: false }, + }); + + res.json({ success: true, message: 'Автопродление подписки отключено.' }); + } catch (error) { + console.error('❌ Ошибка при отключении автообновления:', error); + res.status(500).json({ error: 'Ошибка при отключении автообновления' }); + } }; export const addPaymentHistory = async (req, res) => { - const { clerkUserId, month, amount, type, date } = req.body; - try { - const payment = await prisma.payment.create({ - data: { - clerkUserId, - month, - amount, - type, - date: new Date(date), - }, - }); - res.json({ success: true, payment }); - } catch (e) { - console.error('Ошибка при добавлении платежа:', e); - res.status(500).json({ error: 'Ошибка при добавлении платежа' }); - } + const { clerkUserId, month, amount, type, date } = req.body; + try { + const payment = await prisma.payment.create({ + data: { + clerkUserId, + month, + amount, + type, + date: new Date(date), + }, + }); + res.json({ success: true, payment }); + } catch (e) { + console.error('Ошибка при добавлении платежа:', e); + res.status(500).json({ error: 'Ошибка при добавлении платежа' }); + } }; export const getPaymentHistory = async (req, res) => { - const { clerkUserId } = req.query; - - if (!clerkUserId) { - return res.status(400).json({ error: 'clerkUserId обязателен' }); - } - - try { - const payments = await prisma.payment.findMany({ - where: { clerkUserId }, - orderBy: { date: 'desc' }, - }); - - res.json({ payments }); - } catch (error) { - console.error('Ошибка при получении истории платежей:', error); - res.status(500).json({ error: 'Ошибка при получении истории платежей' }); - } + const { clerkUserId } = req.query; + + if (!clerkUserId) { + return res.status(400).json({ error: 'clerkUserId обязателен' }); + } + + try { + const payments = await prisma.payment.findMany({ + where: { clerkUserId }, + orderBy: { date: 'desc' }, + }); + + res.json({ payments }); + } catch (error) { + console.error('Ошибка при получении истории платежей:', error); + res.status(500).json({ error: 'Ошибка при получении истории платежей' }); + } }; export const renewAutoRenewal = async (req, res) => { - const { clerkUserId } = req.body; - try { - const user = await prisma.user.findUnique({ where: { clerkUserId } }); - if (!user) { - return res.status(404).json({ error: 'Пользователь не найден' }); - } - if (user.isAutoRenewal) { - return res.status(400).json({ error: 'Автопродление уже включено' }); - } - await prisma.user.update({ - where: { clerkUserId }, - data: { isAutoRenewal: true }, - }); - res.json({ success: true, message: 'Автопродление подписки включено.' }); - } catch (error) { - console.error('❌ Ошибка при включении автопродления:', error); - res.status(500).json({ error: 'Ошибка при включении автопродления' }); - } + const { clerkUserId } = req.body; + try { + const user = await prisma.user.findUnique({ where: { clerkUserId } }); + if (!user) { + return res.status(404).json({ error: 'Пользователь не найден' }); + } + if (user.isAutoRenewal) { + return res.status(400).json({ error: 'Автопродление уже включено' }); + } + await prisma.user.update({ + where: { clerkUserId }, + data: { isAutoRenewal: true }, + }); + res.json({ success: true, message: 'Автопродление подписки включено.' }); + } catch (error) { + console.error('❌ Ошибка при включении автопродления:', error); + res.status(500).json({ error: 'Ошибка при включении автопродления' }); + } }; export const getStripePaymentHistory = async (req, res) => { - const { clerkUserId, limit = 10, offset = 0 } = req.query; - try { - const user = await prisma.user.findUnique({ where: { clerkUserId } }); - if (!user) { - return res.status(404).json({ error: 'Пользователь не найден' }); - } - // Получаем stripeCustomerId или email - const customerEmail = user.email; - // Получаем клиента Stripe по email (если нет stripeCustomerId) - let customerId = user.stripeCustomerId; - if (!customerId) { - // Поиск по email - const customers = await stripe.customers.list({ email: customerEmail, limit: 1 }); - if (customers.data.length > 0) { - customerId = customers.data[0].id; - } - } - if (!customerId) { - return res.json({ payments: [], total: 0 }); - } - // Получаем invoices (можно заменить на charges, если нужно) - const invoices = await stripe.invoices.list({ - customer: customerId, - limit: Number(limit), - starting_after: offset ? undefined : undefined // Stripe не поддерживает offset, нужна своя пагинация через starting_after - }); - // Для простоты: только limit, без offset (Stripe рекомендует keyset-пагинацию) - // Можно реализовать пагинацию через last invoice id (starting_after) - const payments = invoices.data.map(inv => ({ - id: inv.id, - amount: inv.amount_paid / 100, - currency: inv.currency, - date: new Date(inv.created * 1000), - status: inv.status, - description: inv.description, - period: inv.period_start ? new Date(inv.period_start * 1000) : null, - type: inv.lines.data[0]?.description || 'Premium', - })); - res.json({ payments, total: invoices.data.length }); - } catch (error) { - console.error('Ошибка при получении истории Stripe:', error); - res.status(500).json({ error: 'Ошибка при получении истории Stripe' }); - } + const { clerkUserId, limit = 10, offset = 0 } = req.query; + try { + const user = await prisma.user.findUnique({ where: { clerkUserId } }); + if (!user) { + return res.status(404).json({ error: 'Пользователь не найден' }); + } + // Получаем stripeCustomerId или email + const customerEmail = user.email; + // Получаем клиента Stripe по email (если нет stripeCustomerId) + let customerId = user.stripeCustomerId; + if (!customerId) { + // Поиск по email + const customers = await stripe.customers.list({ + email: customerEmail, + limit: 1, + }); + if (customers.data.length > 0) { + customerId = customers.data[0].id; + } + } + if (!customerId) { + return res.json({ payments: [], total: 0 }); + } + // Получаем invoices (можно заменить на charges, если нужно) + const invoices = await stripe.invoices.list({ + customer: customerId, + limit: Number(limit), + starting_after: offset ? undefined : undefined, // Stripe не поддерживает offset, нужна своя пагинация через starting_after + }); + // Для простоты: только limit, без offset (Stripe рекомендует keyset-пагинацию) + // Можно реализовать пагинацию через last invoice id (starting_after) + const payments = invoices.data.map((inv) => ({ + id: inv.id, + amount: inv.amount_paid / 100, + currency: inv.currency, + date: new Date(inv.created * 1000), + status: inv.status, + description: inv.description, + period: inv.period_start ? new Date(inv.period_start * 1000) : null, + type: inv.lines.data[0]?.description || 'Premium', + })); + res.json({ payments, total: invoices.data.length }); + } catch (error) { + console.error('Ошибка при получении истории Stripe:', error); + res.status(500).json({ error: 'Ошибка при получении истории Stripe' }); + } }; diff --git a/apps/api/controllers/seekerController.js b/apps/api/controllers/seekerController.js index 7206338..737910c 100644 --- a/apps/api/controllers/seekerController.js +++ b/apps/api/controllers/seekerController.js @@ -1,103 +1,128 @@ -import { getAllSeekers, createSeeker, getSeekerBySlug, deleteSeeker, getSeekerById } from '../services/seekerService.js'; -import { getUserByClerkIdService } from '../services/getUserByClerkService.js'; import { checkAndSendNewCandidatesNotification } from '../services/candidateNotificationService.js'; +import { getUserByClerkIdService } from '../services/getUserByClerkService.js'; +import { + createSeeker, + deleteSeeker, + getAllSeekers, + getSeekerById, + getSeekerBySlug, +} from '../services/seekerService.js'; export async function getSeekers(req, res) { - try { - // Handle languages array from query parameters - const query = { ...req.query }; - if (req.query.languages) { - // If languages is already an array, use it as is - if (Array.isArray(req.query.languages)) { - query.languages = req.query.languages; - } else { - // If it's a single value, convert to array - query.languages = [req.query.languages]; - } - } - - // Add language parameter for city translation - query.lang = req.query.lang || 'ru'; - - const data = await getAllSeekers(query); - res.json(data); - } catch (error) { - console.error('❌ Error getting seekers:', error); - res.status(500).json({ error: 'Ошибка получения соискателей' }); - } + try { + // Handle languages array from query parameters + const query = { ...req.query }; + if (req.query.languages) { + // If languages is already an array, use it as is + if (Array.isArray(req.query.languages)) { + query.languages = req.query.languages; + } else { + // If it's a single value, convert to array + query.languages = [req.query.languages]; + } + } + + // Add language parameter for city translation + query.lang = req.query.lang || 'ru'; + + const data = await getAllSeekers(query); + res.json(data); + } catch (error) { + console.error('❌ Error getting seekers:', error); + res.status(500).json({ error: 'Ошибка получения соискателей' }); + } } export async function addSeeker(req, res) { - try { - const { name, contact, city, description, gender, isDemanded, facebook, languages, nativeLanguage, category, employment, documents, announcement, note, documentType } = req.body; - const seekerData = { - name, - contact, - city, - description, - gender, - isDemanded, - facebook, - languages, - nativeLanguage, - category, - employment, - documents, - announcement, - note, - documentType, - }; - const seeker = await createSeeker(seekerData); - - // Trigger new candidates notification check after adding new candidate - try { - await checkAndSendNewCandidatesNotification(); - } catch (newsletterError) { - console.error('❌ Error triggering notification after adding candidate:', newsletterError); - // Don't fail the candidate creation if notification fails - } - - res.status(201).json(seeker); - } catch (e) { - console.error('Ошибка при добавлении соискателя:', e); - res.status(500).json({ error: 'Ошибка добавления соискателя' }); - } + try { + const { + name, + contact, + city, + description, + gender, + isDemanded, + facebook, + languages, + nativeLanguage, + category, + employment, + documents, + announcement, + note, + documentType, + } = req.body; + const seekerData = { + name, + contact, + city, + description, + gender, + isDemanded, + facebook, + languages, + nativeLanguage, + category, + employment, + documents, + announcement, + note, + documentType, + }; + const seeker = await createSeeker(seekerData); + + // Trigger new candidates notification check after adding new candidate + try { + await checkAndSendNewCandidatesNotification(); + } catch (newsletterError) { + console.error( + '❌ Error triggering notification after adding candidate:', + newsletterError, + ); + // Don't fail the candidate creation if notification fails + } + + res.status(201).json(seeker); + } catch (e) { + console.error('Ошибка при добавлении соискателя:', e); + res.status(500).json({ error: 'Ошибка добавления соискателя' }); + } } export async function getSeekerBySlugController(req, res) { - try { - const seeker = await getSeekerBySlug(req.params.slug); - if (!seeker) return res.status(404).json({ error: 'not found' }); - res.json(seeker); - } catch { - res.status(500).json({ error: 'Ошибка получения соискателя' }); - } + try { + const seeker = await getSeekerBySlug(req.params.slug); + if (!seeker) return res.status(404).json({ error: 'not found' }); + res.json(seeker); + } catch { + res.status(500).json({ error: 'Ошибка получения соискателя' }); + } } export async function deleteSeekerController(req, res) { - try { - await deleteSeeker(req.params.id); - res.json({ success: true }); - } catch { - res.status(500).json({ error: 'Ошибка удаления соискателя' }); - } + try { + await deleteSeeker(req.params.id); + res.json({ success: true }); + } catch { + res.status(500).json({ error: 'Ошибка удаления соискателя' }); + } } export async function getSeekerByIdController(req, res) { - try { - const id = Number(req.params.id); - if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); - const seeker = await getSeekerById(id); - if (!seeker) return res.status(404).json({ error: 'not found' }); - let isPremium = false; - const clerkUserId = req.query.clerkUserId; - if (clerkUserId) { - const user = await getUserByClerkIdService(clerkUserId); - isPremium = !!user?.isPremium; - } - res.json({ ...seeker, isPremium }); - } catch (e) { - console.error('Ошибка получения соискателя по id:', e); - res.status(500).json({ error: 'Ошибка получения соискателя' }); - } -} \ No newline at end of file + try { + const id = Number(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); + const seeker = await getSeekerById(id); + if (!seeker) return res.status(404).json({ error: 'not found' }); + let isPremium = false; + const clerkUserId = req.query.clerkUserId; + if (clerkUserId) { + const user = await getUserByClerkIdService(clerkUserId); + isPremium = !!user?.isPremium; + } + res.json({ ...seeker, isPremium }); + } catch (e) { + console.error('Ошибка получения соискателя по id:', e); + res.status(500).json({ error: 'Ошибка получения соискателя' }); + } +} diff --git a/apps/api/controllers/userController.js b/apps/api/controllers/userController.js index e68dd9b..02064c5 100755 --- a/apps/api/controllers/userController.js +++ b/apps/api/controllers/userController.js @@ -1,18 +1,21 @@ import { getUserByClerkIdService } from '../services/getUserByClerkService'; export const getUserByClerkId = async (req, res) => { - const { clerkUserId } = req.params; + const { clerkUserId } = req.params; - try { - const user = await getUserByClerkIdService(clerkUserId); + try { + const user = await getUserByClerkIdService(clerkUserId); - if (!user) { - return res.status(404).json({ error: 'Пользователь не найден' }); - } + if (!user) { + return res.status(404).json({ error: 'Пользователь не найден' }); + } - res.status(200).json(user); - } catch (error) { - console.error('Ошибка получения данных пользователя:', error.message); - res.status(500).json({ error: 'Ошибка получения данных пользователя', details: error.message }); - } + res.status(200).json(user); + } catch (error) { + console.error('Ошибка получения данных пользователя:', error.message); + res.status(500).json({ + error: 'Ошибка получения данных пользователя', + details: error.message, + }); + } }; diff --git a/apps/api/controllers/userSyncController.js b/apps/api/controllers/userSyncController.js index 4eb2990..ff36bff 100755 --- a/apps/api/controllers/userSyncController.js +++ b/apps/api/controllers/userSyncController.js @@ -1,14 +1,14 @@ import { syncUserService } from '../services/userSyncService.js'; export const syncUser = async (req, res) => { - const { clerkUserId } = req.body; + const { clerkUserId } = req.body; - if (!clerkUserId) { - return res.status(400).json({ error: 'Missing Clerk user ID' }); - } + if (!clerkUserId) { + return res.status(400).json({ error: 'Missing Clerk user ID' }); + } - const result = await syncUserService(clerkUserId); - if (result.error) return res.status(500).json({ error: result.error }); + const result = await syncUserService(clerkUserId); + if (result.error) return res.status(500).json({ error: result.error }); - res.status(200).json(result); + res.status(200).json(result); }; diff --git a/apps/api/controllers/usersController.js b/apps/api/controllers/usersController.js index ab6dd0d..f6c8cda 100755 --- a/apps/api/controllers/usersController.js +++ b/apps/api/controllers/usersController.js @@ -1,54 +1,63 @@ -import { syncUserService, getUserByClerkIdService, getUserJobsService } from '../services/userService.js'; +import { + getUserByClerkIdService, + getUserJobsService, + syncUserService, +} from '../services/userService.js'; // Вебхук для Clerk export const clerkWebhook = async (req, res) => { - // eslint-disable-next-line no-undef - const { WEBHOOK_SECRET } = process.env; - if (!WEBHOOK_SECRET) return res.status(500).json({ error: 'Missing Clerk Webhook Secret' }); + // eslint-disable-next-line no-undef + const { WEBHOOK_SECRET } = process.env; + if (!WEBHOOK_SECRET) + return res.status(500).json({ error: 'Missing Clerk Webhook Secret' }); - const svix_id = req.headers['svix-id']; - const svix_timestamp = req.headers['svix-timestamp']; - const svix_signature = req.headers['svix-signature']; + const svix_id = req.headers['svix-id']; + const svix_timestamp = req.headers['svix-timestamp']; + const svix_signature = req.headers['svix-signature']; - if (!svix_id || !svix_timestamp || !svix_signature) { - return res.status(400).json({ error: 'Missing Svix headers' }); - } + if (!svix_id || !svix_timestamp || !svix_signature) { + return res.status(400).json({ error: 'Missing Svix headers' }); + } - try { - await syncUserService(req.body); - res.status(200).json({ success: true }); - } catch (error) { - res.status(400).json({ error: 'Webhook verification failed', details: error.message }); - } + try { + await syncUserService(req.body); + res.status(200).json({ success: true }); + } catch (error) { + res + .status(400) + .json({ error: 'Webhook verification failed', details: error.message }); + } }; export const syncUser = async (req, res) => { - const result = await syncUserService(req.body.clerkUserId); - if (result.error) return res.status(500).json({ error: result.error }); - res.status(200).json(result); + const result = await syncUserService(req.body.clerkUserId); + if (result.error) return res.status(500).json({ error: result.error }); + res.status(200).json(result); }; export const getUserByClerkId = async (req, res) => { - const result = await getUserByClerkIdService(req.params.clerkUserId); - if (result.error) return res.status(404).json({ error: result.error }); - res.status(200).json(result.user); + const result = await getUserByClerkIdService(req.params.clerkUserId); + if (result.error) return res.status(404).json({ error: result.error }); + res.status(200).json(result.user); }; export const getUserJobs = async (req, res) => { - const lang = req.query.lang || 'ru'; - const result = await getUserJobsService(req.params.clerkUserId, req.query); - if (result.error) return res.status(500).json({ error: result.error }); - // Формируем ответ с переводом категории - const jobs = result.jobs.map(job => { - let categoryLabel = job.category?.name; - if (job.category?.translations?.length) { - const translation = job.category.translations.find(t => t.lang === lang); - if (translation) categoryLabel = translation.name; - } - return { - ...job, - category: job.category ? { ...job.category, label: categoryLabel } : null - }; - }); - res.status(200).json({ ...result, jobs }); -}; \ No newline at end of file + const lang = req.query.lang || 'ru'; + const result = await getUserJobsService(req.params.clerkUserId, req.query); + if (result.error) return res.status(500).json({ error: result.error }); + // Формируем ответ с переводом категории + const jobs = result.jobs.map((job) => { + let categoryLabel = job.category?.name; + if (job.category?.translations?.length) { + const translation = job.category.translations.find( + (t) => t.lang === lang, + ); + if (translation) categoryLabel = translation.name; + } + return { + ...job, + category: job.category ? { ...job.category, label: categoryLabel } : null, + }; + }); + res.status(200).json({ ...result, jobs }); +}; diff --git a/apps/api/controllers/webhookController.js b/apps/api/controllers/webhookController.js index 434ec06..c92852a 100755 --- a/apps/api/controllers/webhookController.js +++ b/apps/api/controllers/webhookController.js @@ -5,34 +5,34 @@ import { processClerkWebhookService } from '../services/webhookService.js'; const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; if (!WEBHOOK_SECRET) { - console.error('❌ Missing Clerk Webhook Secret!'); - // eslint-disable-next-line no-undef - process.exit(1); + console.error('❌ Missing Clerk Webhook Secret!'); + // eslint-disable-next-line no-undef + process.exit(1); } export const clerkWebhook = async (req, res) => { - const svix_id = req.headers['svix-id']; - const svix_timestamp = req.headers['svix-timestamp']; - const svix_signature = req.headers['svix-signature']; + const svix_id = req.headers['svix-id']; + const svix_timestamp = req.headers['svix-timestamp']; + const svix_signature = req.headers['svix-signature']; - if (!svix_id || !svix_timestamp || !svix_signature) { - return res.status(400).json({ error: 'Missing Svix headers' }); - } + if (!svix_id || !svix_timestamp || !svix_signature) { + return res.status(400).json({ error: 'Missing Svix headers' }); + } - try { - const wh = new Webhook(WEBHOOK_SECRET); - const evt = wh.verify(req.rawBody, { - 'svix-id': svix_id, - 'svix-timestamp': svix_timestamp, - 'svix-signature': svix_signature, - }); + try { + const wh = new Webhook(WEBHOOK_SECRET); + const evt = wh.verify(req.rawBody, { + 'svix-id': svix_id, + 'svix-timestamp': svix_timestamp, + 'svix-signature': svix_signature, + }); - const result = await processClerkWebhookService(evt); - if (result.error) return res.status(400).json({ error: result.error }); + const result = await processClerkWebhookService(evt); + if (result.error) return res.status(400).json({ error: result.error }); - res.status(200).json({ success: true }); - // eslint-disable-next-line no-unused-vars - } catch (error) { - res.status(400).json({ error: 'Webhook verification failed' }); - } + res.status(200).json({ success: true }); + // eslint-disable-next-line no-unused-vars + } catch (error) { + res.status(400).json({ error: 'Webhook verification failed' }); + } }; diff --git a/apps/api/editFormService.js b/apps/api/editFormService.js index 9476839..2f0aff3 100755 --- a/apps/api/editFormService.js +++ b/apps/api/editFormService.js @@ -3,11 +3,11 @@ import axios from 'axios'; const API_URL = import.meta.env.VITE_API_URL; export const fetchJob = async (id) => { - const response = await axios.get(`${API_URL}/api/jobs/${id}`); // ✅ Путь исправлен - return response.data; + const response = await axios.get(`${API_URL}/api/jobs/${id}`); // ✅ Путь исправлен + return response.data; }; export const updateJob = async (id, jobData) => { - const response = await axios.put(`${API_URL}/api/jobs/${id}`, jobData); // ✅ Путь исправлен - return response.data; + const response = await axios.put(`${API_URL}/api/jobs/${id}`, jobData); // ✅ Путь исправлен + return response.data; }; diff --git a/apps/api/formService.js b/apps/api/formService.js index dd78548..9b2bfe9 100755 --- a/apps/api/formService.js +++ b/apps/api/formService.js @@ -3,6 +3,6 @@ import axios from 'axios'; const API_URL = import.meta.env.VITE_API_URL; export const createJob = async (jobData) => { - const response = await axios.post(`${API_URL}/api/jobs`, jobData); - return response.data; + const response = await axios.post(`${API_URL}/api/jobs`, jobData); + return response.data; }; diff --git a/apps/api/index.js b/apps/api/index.js index 893fa59..11b292f 100755 --- a/apps/api/index.js +++ b/apps/api/index.js @@ -1,25 +1,24 @@ /* eslint-disable no-undef */ -import express from 'express'; -import dotenv from 'dotenv'; -import path from "path"; -import { fileURLToPath } from "url" + import cors from 'cors'; -import paymentRoutes from './routes/payments.js'; -import jobsRoutes from './routes/jobs.js'; -import citiesRoutes from './routes/cities.js'; +import dotenv from 'dotenv'; +import express from 'express'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { CLERK_SECRET_KEY, WEBHOOK_SECRET } from './config/clerkConfig.js'; import { boostJob } from './controllers/jobsController.js'; -import usersRoutes from './routes/users.js'; -import webhookRoutes from './routes/webhook.js'; - -import seekersRoutes from './routes/seekers.js'; import categoriesRoutes from './routes/categories.js'; +import citiesRoutes from './routes/cities.js'; +import jobsRoutes from './routes/jobs.js'; import messagesRoutes from './routes/messages.js'; -import uploadRoutes from './routes/upload.js'; -import s3UploadRoutes from './routes/s3Upload.js'; import newsletterRoutes from './routes/newsletter.js'; +import paymentRoutes from './routes/payments.js'; +import s3UploadRoutes from './routes/s3Upload.js'; +import seekersRoutes from './routes/seekers.js'; +import uploadRoutes from './routes/upload.js'; +import usersRoutes from './routes/users.js'; +import webhookRoutes from './routes/webhook.js'; import redisService from './services/redisService.js'; - -import { WEBHOOK_SECRET, CLERK_SECRET_KEY } from './config/clerkConfig.js'; import { disableExpiredPremiums } from './utils/cron-jobs.js'; dotenv.config({ path: '.env.local' }); @@ -29,22 +28,24 @@ const app = express(); const PORT = process.env.PORT || 3001; // CORS configuration -app.use(cors({ - origin: [ - 'http://localhost:3000', - 'http://localhost:3001', - 'https://worknow.co.il', - 'https://www.worknow.co.il' - ], - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'x-intlayer-locale'] -})); +app.use( + cors({ + origin: [ + 'http://localhost:3000', + 'http://localhost:3001', + 'https://worknow.co.il', + 'https://www.worknow.co.il', + ], + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'x-intlayer-locale'], + }), +); // Проверяем важные переменные окружения if (!process.env.DATABASE_URL) { - console.error('❌ Missing DATABASE_URL!'); - process.exit(1); + console.error('❌ Missing DATABASE_URL!'); + process.exit(1); } // Note: VITE_API_URL is a frontend environment variable, not needed on backend @@ -58,43 +59,46 @@ console.log('🔍 Index.js - WEBHOOK_SECRET value:', WEBHOOK_SECRET); // } if (!CLERK_SECRET_KEY) { - console.error('❌ Missing Clerk API Secret Key!'); - process.exit(1); + console.error('❌ Missing Clerk API Secret Key!'); + process.exit(1); } - -app.use(express.json({ - verify: (req, res, buf) => { req.rawBody = buf.toString(); } -})); +app.use( + express.json({ + verify: (req, res, buf) => { + req.rawBody = buf.toString(); + }, + }), +); // Security headers for production if (process.env.NODE_ENV === 'production') { - app.use((req, res, next) => { - res.setHeader( - 'Content-Security-Policy', - "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://clerk.worknow.co.il https://*.clerk.accounts.dev https://cdn.jsdelivr.net https://widget.survicate.com blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: https:; connect-src 'self' https://api.stripe.com https://clerk.worknow.co.il https://*.clerk.accounts.dev; frame-src https://js.stripe.com https://clerk.worknow.co.il https://*.clerk.accounts.dev; worker-src 'self' blob:;" - ); - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('X-XSS-Protection', '1; mode=block'); - next(); - }); + app.use((req, res, next) => { + res.setHeader( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://clerk.worknow.co.il https://*.clerk.accounts.dev https://cdn.jsdelivr.net https://widget.survicate.com blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: https:; connect-src 'self' https://api.stripe.com https://clerk.worknow.co.il https://*.clerk.accounts.dev; frame-src https://js.stripe.com https://clerk.worknow.co.il https://*.clerk.accounts.dev; worker-src 'self' blob:;", + ); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-XSS-Protection', '1; mode=block'); + next(); + }); } else { - // More permissive CSP for development - app.use((req, res, next) => { - res.setHeader( - 'Content-Security-Policy', - "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http: blob:; style-src 'self' 'unsafe-inline' https: http:; font-src 'self' https: http:; img-src 'self' data: https: http:; connect-src 'self' https: http:; frame-src https: http:; worker-src 'self' blob:;" - ); - next(); - }); + // More permissive CSP for development + app.use((req, res, next) => { + res.setHeader( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http: blob:; style-src 'self' 'unsafe-inline' https: http:; font-src 'self' https: http:; img-src 'self' data: https: http:; connect-src 'self' https: http:; frame-src https: http:; worker-src 'self' blob:;", + ); + next(); + }); } const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Раздача статических файлов изображений -app.use('/images', express.static(path.join(__dirname, "../../public/images"))); +app.use('/images', express.static(path.join(__dirname, '../../public/images'))); // --- РЕГИСТРАЦИЯ МАРШРУТОВ --- app.use('/api/payments', paymentRoutes); @@ -112,207 +116,232 @@ app.use('/api/newsletter', newsletterRoutes); // Health check endpoint for Docker app.get('/api/health', (req, res) => { - res.status(200).json({ - status: 'healthy', - timestamp: new Date().toISOString(), - environment: process.env.NODE_ENV || 'development' - }); + res.status(200).json({ + status: 'healthy', + timestamp: new Date().toISOString(), + environment: process.env.NODE_ENV || 'development', + }); }); // Redis health check endpoint app.get('/api/redis/health', async (req, res) => { - try { - const redisHealth = await redisService.healthCheck(); - res.status(200).json({ - status: 'healthy', - redis: redisHealth, - timestamp: new Date().toISOString() - }); - } catch (error) { - res.status(500).json({ - status: 'unhealthy', - error: error.message, - timestamp: new Date().toISOString() - }); - } + try { + const redisHealth = await redisService.healthCheck(); + res.status(200).json({ + status: 'healthy', + redis: redisHealth, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + status: 'unhealthy', + error: error.message, + timestamp: new Date().toISOString(), + }); + } }); // Cache management endpoints app.get('/api/redis/cache/stats', async (req, res) => { - try { - const keys = await redisService.redis.keys('*'); - const jobKeys = await redisService.redis.keys('jobs:*'); - const sessionKeys = await redisService.redis.keys('session:*'); - - res.json({ - totalKeys: keys.length, - jobCacheKeys: jobKeys.length, - sessionKeys: sessionKeys.length, - memory: await redisService.redis.info('memory'), - timestamp: new Date().toISOString() - }); - } catch (error) { - res.status(500).json({ error: error.message }); - } + try { + const keys = await redisService.redis.keys('*'); + const jobKeys = await redisService.redis.keys('jobs:*'); + const sessionKeys = await redisService.redis.keys('session:*'); + + res.json({ + totalKeys: keys.length, + jobCacheKeys: jobKeys.length, + sessionKeys: sessionKeys.length, + memory: await redisService.redis.info('memory'), + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } }); app.delete('/api/redis/cache/clear', async (req, res) => { - try { - await redisService.redis.flushall(); - res.json({ - message: 'All cache cleared successfully', - timestamp: new Date().toISOString() - }); - } catch (error) { - res.status(500).json({ error: error.message }); - } + try { + await redisService.redis.flushall(); + res.json({ + message: 'All cache cleared successfully', + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } }); app.delete('/api/redis/cache/jobs', async (req, res) => { - try { - await redisService.invalidateJobsCache(); - res.json({ - message: 'Job cache cleared successfully', - timestamp: new Date().toISOString() - }); - } catch (error) { - res.status(500).json({ error: error.message }); - } + try { + await redisService.invalidateJobsCache(); + res.json({ + message: 'Job cache cleared successfully', + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } }); // Тестовый endpoint для проверки сервера (NEW) app.get('/api/test-server', (req, res) => { - res.json({ - success: true, - message: 'Server is running', - timestamp: new Date().toISOString(), - environment: process.env.NODE_ENV || 'development', - clerkKeyAvailable: !!process.env.CLERK_SECRET_KEY, - clerkKeyLength: process.env.CLERK_SECRET_KEY ? process.env.CLERK_SECRET_KEY.length : 0 - }); + res.json({ + success: true, + message: 'Server is running', + timestamp: new Date().toISOString(), + environment: process.env.NODE_ENV || 'development', + clerkKeyAvailable: !!process.env.CLERK_SECRET_KEY, + clerkKeyLength: process.env.CLERK_SECRET_KEY + ? process.env.CLERK_SECRET_KEY.length + : 0, + }); }); // Тестовый endpoint для проверки базы данных app.get('/api/test-database', async (req, res) => { - try { - const { PrismaClient } = await import('@prisma/client'); - const prisma = new PrismaClient(); - - // Test basic database connection - const result = await prisma.$queryRaw`SELECT 1 as test`; - console.log('✅ Database connection test result:', result); - - // Test NewsletterVerification table - try { - const verificationCount = await prisma.newsletterVerification.count(); - console.log('✅ NewsletterVerification table accessible, count:', verificationCount); - } catch (error) { - console.error('❌ NewsletterVerification table error:', error); - } - - await prisma.$disconnect(); - - res.json({ - success: true, - message: 'Database connection test successful', - result: result - }); - } catch (error) { - console.error('❌ Database test error:', error); - res.status(500).json({ - success: false, - message: 'Database connection test failed', - error: error.message - }); - } + try { + const { PrismaClient } = await import('@prisma/client'); + const prisma = new PrismaClient(); + + // Test basic database connection + const result = await prisma.$queryRaw`SELECT 1 as test`; + console.log('✅ Database connection test result:', result); + + // Test NewsletterVerification table + try { + const verificationCount = await prisma.newsletterVerification.count(); + console.log( + '✅ NewsletterVerification table accessible, count:', + verificationCount, + ); + } catch (error) { + console.error('❌ NewsletterVerification table error:', error); + } + + await prisma.$disconnect(); + + res.json({ + success: true, + message: 'Database connection test successful', + result: result, + }); + } catch (error) { + console.error('❌ Database test error:', error); + res.status(500).json({ + success: false, + message: 'Database connection test failed', + error: error.message, + }); + } }); // Тестовый endpoint для проверки Clerk API app.get('/api/test-clerk-api', async (req, res) => { - try { - const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY; - console.log('🔍 Test endpoint - CLERK_SECRET_KEY available:', !!CLERK_SECRET_KEY); - console.log('🔍 Test endpoint - CLERK_SECRET_KEY length:', CLERK_SECRET_KEY ? CLERK_SECRET_KEY.length : 0); - - const response = await fetch('https://api.clerk.com/v1/users/user_2tnxLkEalopDLnUWMFiSJAPCBKJ', { - headers: { - 'Authorization': `Bearer ${CLERK_SECRET_KEY}`, - 'Content-Type': 'application/json' - } - }); - - console.log('🔍 Test endpoint - Clerk API response status:', response.status); - - if (!response.ok) { - const errorText = await response.text(); - console.error('❌ Test endpoint - Clerk API error:', response.status, errorText); - return res.status(500).json({ error: `Clerk API error: ${response.status} ${response.statusText}`, details: errorText }); - } - - const userData = await response.json(); - res.json({ - success: true, - user: { - id: userData.id, - email: userData.email_addresses?.[0]?.email_address, - firstName: userData.first_name, - lastName: userData.last_name - } - }); - } catch (error) { - console.error('❌ Test endpoint - Error:', error); - res.status(500).json({ error: 'Test failed', details: error.message }); - } + try { + const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY; + console.log( + '🔍 Test endpoint - CLERK_SECRET_KEY available:', + !!CLERK_SECRET_KEY, + ); + console.log( + '🔍 Test endpoint - CLERK_SECRET_KEY length:', + CLERK_SECRET_KEY ? CLERK_SECRET_KEY.length : 0, + ); + + const response = await fetch( + 'https://api.clerk.com/v1/users/user_2tnxLkEalopDLnUWMFiSJAPCBKJ', + { + headers: { + Authorization: `Bearer ${CLERK_SECRET_KEY}`, + 'Content-Type': 'application/json', + }, + }, + ); + + console.log( + '🔍 Test endpoint - Clerk API response status:', + response.status, + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error( + '❌ Test endpoint - Clerk API error:', + response.status, + errorText, + ); + return res.status(500).json({ + error: `Clerk API error: ${response.status} ${response.statusText}`, + details: errorText, + }); + } + + const userData = await response.json(); + res.json({ + success: true, + user: { + id: userData.id, + email: userData.email_addresses?.[0]?.email_address, + firstName: userData.first_name, + lastName: userData.last_name, + }, + }); + } catch (error) { + console.error('❌ Test endpoint - Error:', error); + res.status(500).json({ error: 'Test failed', details: error.message }); + } }); // Тестовый endpoint для проверки создания job с imageUrl app.post('/api/test-create-job', async (req, res) => { - try { - console.log('🔍 Test endpoint - Request body:', req.body); - const { createJobService } = await import('./services/jobCreateService.js'); - const result = await createJobService(req.body); - console.log('🔍 Test endpoint - Result:', result); - res.json(result); - } catch (error) { - console.error('🔍 Test endpoint - Error:', error); - res.status(500).json({ error: error.message }); - } + try { + console.log('🔍 Test endpoint - Request body:', req.body); + const { createJobService } = await import('./services/jobCreateService.js'); + const result = await createJobService(req.body); + console.log('🔍 Test endpoint - Result:', result); + res.json(result); + } catch (error) { + console.error('🔍 Test endpoint - Error:', error); + res.status(500).json({ error: error.message }); + } }); // ВРЕМЕННЫЙ route для ручного теста сброса премиума app.get('/api/test-disable-premium', async (req, res) => { - await disableExpiredPremiums(); - res.json({ success: true }); + await disableExpiredPremiums(); + res.json({ success: true }); }); // Serve static files from the React build (AFTER API routes) if (process.env.NODE_ENV === 'production') { - // Serve static files from the React build directory - app.use(express.static(path.join(__dirname, '../../dist'))); - - // Handle React routing, return all requests to React app (LAST) - app.get('*', (req, res) => { - res.sendFile(path.join(__dirname, '../../dist/index.html')); - }); + // Serve static files from the React build directory + app.use(express.static(path.join(__dirname, '../../dist'))); + + // Handle React routing, return all requests to React app (LAST) + app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '../../dist/index.html')); + }); } else { - // In development, only serve API routes and let Vite handle frontend - // Don't serve static files or handle React routing in development - console.log('🔧 Development mode: Frontend served by Vite dev server'); + // In development, only serve API routes and let Vite handle frontend + // Don't serve static files or handle React routing in development + console.log('🔧 Development mode: Frontend served by Vite dev server'); } // API error handler - handle 404 for API routes app.use('/api/*', (req, res) => { - console.error(`❌ API route not found: ${req.method} ${req.originalUrl}`); - res.status(404).json({ error: "API endpoint not found" }); + console.error(`❌ API route not found: ${req.method} ${req.originalUrl}`); + res.status(404).json({ error: 'API endpoint not found' }); }); // Обработчик ошибок (защита от падения) // eslint-disable-next-line no-unused-vars -app.use((err, req, res, next) => { // next обязателен для error-handling middleware - console.error("❌ Ошибка на сервере:", err); - res.status(500).json({ error: "Внутренняя ошибка сервера" }); +app.use((err, req, res, next) => { + // next обязателен для error-handling middleware + console.error('❌ Ошибка на сервере:', err); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); }); app.listen(PORT, () => { - console.log(`🚀 Server running on http://localhost:${PORT}`); + console.log(`🚀 Server running on http://localhost:${PORT}`); }); diff --git a/apps/api/jobService.js b/apps/api/jobService.js index a6a3f05..1de1c28 100755 --- a/apps/api/jobService.js +++ b/apps/api/jobService.js @@ -3,11 +3,11 @@ import axios from 'axios'; const API_URL = import.meta.env.VITE_API_URL; export const fetchJobs = async () => { - try { - const response = await axios.get(`${API_URL}/api/jobs`); - return response.data; - } catch (error) { - console.error('Ошибка загрузки объявлений:', error); - return []; - } + try { + const response = await axios.get(`${API_URL}/api/jobs`); + return response.data; + } catch (error) { + console.error('Ошибка загрузки объявлений:', error); + return []; + } }; diff --git a/apps/api/middlewares/auth.js b/apps/api/middlewares/auth.js index c1656d4..b3ca40e 100644 --- a/apps/api/middlewares/auth.js +++ b/apps/api/middlewares/auth.js @@ -1,96 +1,101 @@ import pkg from '@prisma/client'; import { Buffer } from 'buffer'; + const { PrismaClient } = pkg; const prisma = new PrismaClient(); // Middleware для проверки аутентификации export const requireAuth = async (req, res, next) => { - try { - // Auth middleware processing request - - // Получаем токен из заголовка Authorization - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - console.error('❌ Auth middleware - No valid authorization header'); - return res.status(401).json({ error: 'No authorization token provided' }); - } - - const token = authHeader.substring(7); // Remove 'Bearer ' prefix - // Token received from headers - - try { - // For development, we'll use a simpler approach to extract user ID from the token - // This is a temporary solution that works with Clerk's JWT format - const tokenParts = token.split('.'); - if (tokenParts.length !== 3) { - console.error('Invalid JWT token format'); - return res.status(401).json({ error: 'Invalid token format' }); - } - - // Decode the payload (second part) - this is safe for development - const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString()); - - // Token payload verified - - // Extract user ID from the token payload - const clerkUserId = payload.sub; - - if (!clerkUserId) { - console.error('No user ID found in token payload'); - return res.status(401).json({ error: 'Invalid token - no user ID' }); - } - - // Set user information in request object - req.user = { - clerkUserId: clerkUserId, - email: payload.email, - firstName: payload.first_name, - lastName: payload.last_name, - imageUrl: payload.image_url - }; - - // User authenticated successfully - - next(); - } catch (decodeError) { - console.error('Token decode error:', decodeError); - console.error('Token decode error details:', { - message: decodeError.message, - stack: decodeError.stack - }); - return res.status(401).json({ error: 'Token verification failed' }); - } - } catch (error) { - console.error('Auth middleware error:', error); - return res.status(500).json({ error: 'Authentication error' }); - } + try { + // Auth middleware processing request + + // Получаем токен из заголовка Authorization + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + console.error('❌ Auth middleware - No valid authorization header'); + return res.status(401).json({ error: 'No authorization token provided' }); + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + // Token received from headers + + try { + // For development, we'll use a simpler approach to extract user ID from the token + // This is a temporary solution that works with Clerk's JWT format + const tokenParts = token.split('.'); + if (tokenParts.length !== 3) { + console.error('Invalid JWT token format'); + return res.status(401).json({ error: 'Invalid token format' }); + } + + // Decode the payload (second part) - this is safe for development + const payload = JSON.parse( + Buffer.from(tokenParts[1], 'base64').toString(), + ); + + // Token payload verified + + // Extract user ID from the token payload + const clerkUserId = payload.sub; + + if (!clerkUserId) { + console.error('No user ID found in token payload'); + return res.status(401).json({ error: 'Invalid token - no user ID' }); + } + + // Set user information in request object + req.user = { + clerkUserId: clerkUserId, + email: payload.email, + firstName: payload.first_name, + lastName: payload.last_name, + imageUrl: payload.image_url, + }; + + // User authenticated successfully + + next(); + } catch (decodeError) { + console.error('Token decode error:', decodeError); + console.error('Token decode error details:', { + message: decodeError.message, + stack: decodeError.stack, + }); + return res.status(401).json({ error: 'Token verification failed' }); + } + } catch (error) { + console.error('Auth middleware error:', error); + return res.status(500).json({ error: 'Authentication error' }); + } }; // Middleware для проверки админских прав export const requireAdmin = async (req, res, next) => { - try { - // Получаем userId из заголовка или токена (зависит от вашей аутентификации) - const userId = req.headers['user-id'] || req.user?.id; - - if (!userId) { - return res.status(401).json({ error: 'Не авторизован' }); - } - - // Проверяем, является ли пользователь админом - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { isAdmin: true } - }); - - if (!user || !user.isAdmin) { - return res.status(403).json({ error: 'Доступ запрещен. Требуются права администратора.' }); - } - - next(); - } catch (error) { - console.error('Ошибка проверки админских прав:', error); - return res.status(500).json({ error: 'Ошибка проверки прав доступа' }); - } -}; \ No newline at end of file + try { + // Получаем userId из заголовка или токена (зависит от вашей аутентификации) + const userId = req.headers['user-id'] || req.user?.id; + + if (!userId) { + return res.status(401).json({ error: 'Не авторизован' }); + } + + // Проверяем, является ли пользователь админом + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { isAdmin: true }, + }); + + if (!user || !user.isAdmin) { + return res + .status(403) + .json({ error: 'Доступ запрещен. Требуются права администратора.' }); + } + + next(); + } catch (error) { + console.error('Ошибка проверки админских прав:', error); + return res.status(500).json({ error: 'Ошибка проверки прав доступа' }); + } +}; diff --git a/apps/api/middlewares/cache.js b/apps/api/middlewares/cache.js index d55331b..4671c0e 100644 --- a/apps/api/middlewares/cache.js +++ b/apps/api/middlewares/cache.js @@ -2,115 +2,119 @@ import redisService from '../services/redisService.js'; // Cache middleware for API responses export const cacheMiddleware = (ttl = 300) => { - return async (req, res, next) => { - // Skip caching for non-GET requests - if (req.method !== 'GET') { - return next(); - } - - // Create cache key based on URL and query parameters - const cacheKey = `api:${req.originalUrl}`; - - try { - // Try to get cached response - const cachedResponse = await redisService.get(cacheKey); - - if (cachedResponse) { - // Cache hit - serving from cache - return res.json(cachedResponse); - } - - // If no cache, intercept the response - const originalSend = res.json; - res.json = function(data) { - // Cache the response - redisService.set(cacheKey, data, ttl); - // Response cached successfully - - // Send the original response - return originalSend.call(this, data); - }; - - next(); - } catch (error) { - console.error('❌ Cache middleware error:', error); - next(); // Continue without caching if Redis fails - } - }; + return async (req, res, next) => { + // Skip caching for non-GET requests + if (req.method !== 'GET') { + return next(); + } + + // Create cache key based on URL and query parameters + const cacheKey = `api:${req.originalUrl}`; + + try { + // Try to get cached response + const cachedResponse = await redisService.get(cacheKey); + + if (cachedResponse) { + // Cache hit - serving from cache + return res.json(cachedResponse); + } + + // If no cache, intercept the response + const originalSend = res.json; + res.json = function (data) { + // Cache the response + redisService.set(cacheKey, data, ttl); + // Response cached successfully + + // Send the original response + return originalSend.call(this, data); + }; + + next(); + } catch (error) { + console.error('❌ Cache middleware error:', error); + next(); // Continue without caching if Redis fails + } + }; }; // Rate limiting middleware export const rateLimitMiddleware = (limit = 100, window = 3600) => { - return async (req, res, next) => { - const identifier = req.ip || req.headers['x-forwarded-for'] || 'unknown'; - - try { - const rateLimit = await redisService.checkRateLimit(identifier, limit, window); - - // Add rate limit headers - res.set({ - 'X-RateLimit-Limit': rateLimit.limit, - 'X-RateLimit-Remaining': rateLimit.remaining, - 'X-RateLimit-Reset': rateLimit.resetTime - }); - - if (!rateLimit.allowed) { - return res.status(429).json({ - error: 'Rate limit exceeded', - message: `Too many requests. Try again in ${rateLimit.resetTime} seconds.`, - retryAfter: rateLimit.resetTime - }); - } - - next(); - } catch (error) { - console.error('❌ Rate limit middleware error:', error); - next(); // Continue without rate limiting if Redis fails - } - }; + return async (req, res, next) => { + const identifier = req.ip || req.headers['x-forwarded-for'] || 'unknown'; + + try { + const rateLimit = await redisService.checkRateLimit( + identifier, + limit, + window, + ); + + // Add rate limit headers + res.set({ + 'X-RateLimit-Limit': rateLimit.limit, + 'X-RateLimit-Remaining': rateLimit.remaining, + 'X-RateLimit-Reset': rateLimit.resetTime, + }); + + if (!rateLimit.allowed) { + return res.status(429).json({ + error: 'Rate limit exceeded', + message: `Too many requests. Try again in ${rateLimit.resetTime} seconds.`, + retryAfter: rateLimit.resetTime, + }); + } + + next(); + } catch (error) { + console.error('❌ Rate limit middleware error:', error); + next(); // Continue without rate limiting if Redis fails + } + }; }; // Session middleware using Redis export const sessionMiddleware = () => { - return async (req, res, next) => { - const sessionId = req.headers['x-session-id'] || req.cookies?.sessionId; - - if (sessionId) { - try { - const session = await redisService.getSession(sessionId); - if (session) { - req.session = session; - // Session loaded from cache - } - } catch (error) { - console.error('❌ Session middleware error:', error); - } - } - - next(); - }; + return async (req, res, next) => { + const sessionId = req.headers['x-session-id'] || req.cookies?.sessionId; + + if (sessionId) { + try { + const session = await redisService.getSession(sessionId); + if (session) { + req.session = session; + // Session loaded from cache + } + } catch (error) { + console.error('❌ Session middleware error:', error); + } + } + + next(); + }; }; // Activity tracking middleware export const activityTrackingMiddleware = () => { - return async (req, res, next) => { - const userId = req.user?.id || req.headers['x-user-id']; - - if (userId) { - try { - const action = { - method: req.method, - path: req.path, - timestamp: new Date().toISOString(), - userAgent: req.headers['user-agent'] - }; - - await redisService.trackUserActivity(userId, action); - } catch (error) { - console.error('❌ Activity tracking error:', error); - } - } - - next(); - }; -}; \ No newline at end of file + return async (req, res, next) => { + const userId = req.user?.id || req.headers['x-user-id']; + + if (userId) { + try { + const action = { + method: req.method, + path: req.path, + timestamp: new Date().toISOString(), + userAgent: req.headers['user-agent'], + }; + + await redisService.trackUserActivity(userId, action); + } catch (error) { + console.error('❌ Activity tracking error:', error); + } + } + + next(); + }; +}; diff --git a/apps/api/middlewares/validation.js b/apps/api/middlewares/validation.js index 870409a..76a8400 100755 --- a/apps/api/middlewares/validation.js +++ b/apps/api/middlewares/validation.js @@ -1,16 +1,18 @@ -import {Filter }from "bad-words"; +import { Filter } from 'bad-words'; import badWordsList from '../utils/badWordsList.js'; const filter = new Filter(); export const containsBadWords = (text) => { - if (!text) return false; - const words = text.toLowerCase().split(/\s+/); - return words.some((word) => badWordsList.includes(word) || filter.isProfane(word)); + if (!text) return false; + const words = text.toLowerCase().split(/\s+/); + return words.some( + (word) => badWordsList.includes(word) || filter.isProfane(word), + ); }; export const containsLinks = (text) => { - if (!text) return false; - const urlPattern = /(https?:\/\/|www\.)[^\s]+/gi; - return urlPattern.test(text); + if (!text) return false; + const urlPattern = /(https?:\/\/|www\.)[^\s]+/gi; + return urlPattern.test(text); }; diff --git a/apps/api/routes/categories.js b/apps/api/routes/categories.js index 407698c..2bdaf45 100644 --- a/apps/api/routes/categories.js +++ b/apps/api/routes/categories.js @@ -5,4 +5,4 @@ const router = express.Router(); router.get('/', getCategories); -export default router; \ No newline at end of file +export default router; diff --git a/apps/api/routes/cities.js b/apps/api/routes/cities.js index 22a1652..ffcb006 100755 --- a/apps/api/routes/cities.js +++ b/apps/api/routes/cities.js @@ -1,10 +1,10 @@ import express from 'express'; -import { getCities } from '../controllers/cityController.js'; import { getCategories } from '../controllers/categoryController.js'; +import { getCities } from '../controllers/cityController.js'; const router = express.Router(); router.get('/', getCities); router.get('/categories', getCategories); -export default router; \ No newline at end of file +export default router; diff --git a/apps/api/routes/jobs.js b/apps/api/routes/jobs.js index 335697e..4260d7e 100755 --- a/apps/api/routes/jobs.js +++ b/apps/api/routes/jobs.js @@ -1,6 +1,11 @@ import express from 'express'; -import { createJob, updateJob, deleteJob, getJobs } from '../controllers/jobsController.js'; import { getJobById } from '../controllers/jobController.js'; +import { + createJob, + deleteJob, + getJobs, + updateJob, +} from '../controllers/jobsController.js'; import { requireAuth } from '../middlewares/auth.js'; const router = express.Router(); diff --git a/apps/api/routes/messages.js b/apps/api/routes/messages.js index 694200c..9401b01 100644 --- a/apps/api/routes/messages.js +++ b/apps/api/routes/messages.js @@ -1,5 +1,11 @@ import { Router } from 'express'; -import { createMessage, getUserMessages, markMessageRead, broadcastMessage, deleteMessage } from '../controllers/messages.js'; +import { + broadcastMessage, + createMessage, + deleteMessage, + getUserMessages, + markMessageRead, +} from '../controllers/messages.js'; import { requireAdmin } from '../middlewares/auth.js'; const router = Router(); @@ -21,4 +27,4 @@ router.delete('/:id', deleteMessage); // Отправить массовое сообщение (только для админов) router.post('/broadcast', requireAdmin, broadcastMessage); -export default router; \ No newline at end of file +export default router; diff --git a/apps/api/routes/newsletter.js b/apps/api/routes/newsletter.js index d907aeb..097b4ac 100644 --- a/apps/api/routes/newsletter.js +++ b/apps/api/routes/newsletter.js @@ -1,14 +1,17 @@ import express from 'express'; import { - subscribeToNewsletter, - unsubscribeFromNewsletter, - getNewsletterSubscribers, - checkSubscriptionStatus, - updateNewsletterPreferences, - sendNewsletterVerificationCode, - verifyNewsletterCode + checkSubscriptionStatus, + getNewsletterSubscribers, + sendNewsletterVerificationCode, + subscribeToNewsletter, + unsubscribeFromNewsletter, + updateNewsletterPreferences, + verifyNewsletterCode, } from '../controllers/newsletterController.js'; -import { sendCandidatesToSubscribers, sendFilteredCandidatesToSubscribers } from '../services/newsletterService.js'; +import { + sendCandidatesToSubscribers, + sendFilteredCandidatesToSubscribers, +} from '../services/newsletterService.js'; const router = express.Router(); @@ -35,40 +38,43 @@ router.put('/preferences/:email', updateNewsletterPreferences); // Manual trigger to send candidates to subscribers (for testing) router.post('/send-candidates', async (req, res) => { - try { - const { subscriberIds } = req.body; // Optional: specific subscriber IDs - - await sendCandidatesToSubscribers(subscriberIds); - - res.json({ - success: true, - message: 'Candidates sent to subscribers successfully' - }); - } catch (error) { - console.error('❌ Error sending candidates to subscribers:', error); - res.status(500).json({ - success: false, - message: 'Failed to send candidates to subscribers' - }); - } + try { + const { subscriberIds } = req.body; // Optional: specific subscriber IDs + + await sendCandidatesToSubscribers(subscriberIds); + + res.json({ + success: true, + message: 'Candidates sent to subscribers successfully', + }); + } catch (error) { + console.error('❌ Error sending candidates to subscribers:', error); + res.status(500).json({ + success: false, + message: 'Failed to send candidates to subscribers', + }); + } }); // Manual trigger to send filtered candidates to subscribers (for testing) router.post('/send-filtered-candidates', async (req, res) => { - try { - await sendFilteredCandidatesToSubscribers(); - - res.json({ - success: true, - message: 'Filtered candidates sent to subscribers successfully' - }); - } catch (error) { - console.error('❌ Error sending filtered candidates to subscribers:', error); - res.status(500).json({ - success: false, - message: 'Failed to send filtered candidates to subscribers' - }); - } + try { + await sendFilteredCandidatesToSubscribers(); + + res.json({ + success: true, + message: 'Filtered candidates sent to subscribers successfully', + }); + } catch (error) { + console.error( + '❌ Error sending filtered candidates to subscribers:', + error, + ); + res.status(500).json({ + success: false, + message: 'Failed to send filtered candidates to subscribers', + }); + } }); -export default router; \ No newline at end of file +export default router; diff --git a/apps/api/routes/payments.js b/apps/api/routes/payments.js index c10b6a1..265d108 100755 --- a/apps/api/routes/payments.js +++ b/apps/api/routes/payments.js @@ -1,5 +1,13 @@ import { Router } from 'express'; -import { createCheckoutSession, activatePremium, addPaymentHistory, getPaymentHistory, renewAutoRenewal, getStripePaymentHistory, cancelAutoRenewal } from '../controllers/payments.js'; +import { + activatePremium, + addPaymentHistory, + cancelAutoRenewal, + createCheckoutSession, + getPaymentHistory, + getStripePaymentHistory, + renewAutoRenewal, +} from '../controllers/payments.js'; const router = Router(); diff --git a/apps/api/routes/s3Upload.js b/apps/api/routes/s3Upload.js index 036b9de..3bc7d2e 100644 --- a/apps/api/routes/s3Upload.js +++ b/apps/api/routes/s3Upload.js @@ -1,8 +1,14 @@ -import express from 'express'; -import { upload, uploadToS3, uploadToS3WithModeration, deleteFromS3, validateS3Config } from '../utils/s3Upload.js'; -import { requireAuth } from '../middlewares/auth.js'; import { PrismaClient } from '@prisma/client'; +import express from 'express'; import process from 'process'; +import { requireAuth } from '../middlewares/auth.js'; +import { + deleteFromS3, + upload, + uploadToS3, + uploadToS3WithModeration, + validateS3Config, +} from '../utils/s3Upload.js'; const prisma = new PrismaClient(); const router = express.Router(); @@ -10,8 +16,8 @@ const router = express.Router(); // Validate S3 configuration on startup const s3ConfigStatus = validateS3Config(); if (!s3ConfigStatus.isValid) { - console.warn('⚠️ S3 configuration is incomplete. S3 uploads will not work.'); - console.warn('📖 See SETUP_GUIDE.md for configuration instructions'); + console.warn('⚠️ S3 configuration is incomplete. S3 uploads will not work.'); + console.warn('📖 See SETUP_GUIDE.md for configuration instructions'); } /** @@ -19,19 +25,19 @@ if (!s3ConfigStatus.isValid) { * Test S3 configuration without file upload */ router.get('/test-config', async (req, res) => { - try { - const configStatus = validateS3Config(); - res.json({ - success: true, - config: configStatus, - bucket: process.env.AWS_S3_BUCKET_NAME, - region: process.env.AWS_REGION, - hasAccessKey: !!process.env.AWS_ACCESS_KEY_ID, - hasSecretKey: !!process.env.AWS_SECRET_ACCESS_KEY - }); - } catch (error) { - res.status(500).json({ error: error.message }); - } + try { + const configStatus = validateS3Config(); + res.json({ + success: true, + config: configStatus, + bucket: process.env.AWS_S3_BUCKET_NAME, + region: process.env.AWS_REGION, + hasAccessKey: !!process.env.AWS_ACCESS_KEY_ID, + hasSecretKey: !!process.env.AWS_SECRET_ACCESS_KEY, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } }); /** @@ -39,524 +45,575 @@ router.get('/test-config', async (req, res) => { * Test S3 upload without authentication (for debugging) */ router.post('/test-upload', upload.single('image'), async (req, res) => { - try { - console.log('🔍 S3 Test Upload - Request received:', { - hasFile: !!req.file, - fileInfo: req.file ? { - originalname: req.file.originalname, - size: req.file.size, - mimetype: req.file.mimetype - } : null - }); - - if (!req.file) { - return res.status(400).json({ - error: 'No image file provided', - code: 'MISSING_FILE' - }); - } - - // Validate file size - if (req.file.size > 5 * 1024 * 1024) { - return res.status(400).json({ - error: 'File too large. Maximum size is 5MB.', - code: 'FILE_TOO_LARGE' - }); - } - - // Upload to S3 - const imageUrl = await uploadToS3( - req.file.buffer, - req.file.originalname, - req.file.mimetype, - 'test' - ); - - console.log('✅ S3 test upload successful'); - - res.json({ - success: true, - imageUrl: imageUrl, - filename: req.file.originalname, - size: req.file.size - }); - - } catch (error) { - console.error('❌ S3 test upload error:', error); - res.status(500).json({ - error: 'Failed to upload image to S3', - details: error.message, - code: 'UPLOAD_FAILED' - }); - } + try { + console.log('🔍 S3 Test Upload - Request received:', { + hasFile: !!req.file, + fileInfo: req.file + ? { + originalname: req.file.originalname, + size: req.file.size, + mimetype: req.file.mimetype, + } + : null, + }); + + if (!req.file) { + return res.status(400).json({ + error: 'No image file provided', + code: 'MISSING_FILE', + }); + } + + // Validate file size + if (req.file.size > 5 * 1024 * 1024) { + return res.status(400).json({ + error: 'File too large. Maximum size is 5MB.', + code: 'FILE_TOO_LARGE', + }); + } + + // Upload to S3 + const imageUrl = await uploadToS3( + req.file.buffer, + req.file.originalname, + req.file.mimetype, + 'test', + ); + + console.log('✅ S3 test upload successful'); + + res.json({ + success: true, + imageUrl: imageUrl, + filename: req.file.originalname, + size: req.file.size, + }); + } catch (error) { + console.error('❌ S3 test upload error:', error); + res.status(500).json({ + error: 'Failed to upload image to S3', + details: error.message, + code: 'UPLOAD_FAILED', + }); + } }); /** * POST /api/s3-upload/test-upload-no-auth * Test S3 upload without authentication (for debugging) */ -router.post('/test-upload-no-auth', upload.single('image'), async (req, res) => { - try { - console.log('🔍 S3 Test Upload No Auth - Request received:', { - hasFile: !!req.file, - fileInfo: req.file ? { - originalname: req.file.originalname, - size: req.file.size, - mimetype: req.file.mimetype - } : null - }); - - if (!req.file) { - return res.status(400).json({ - error: 'No image file provided', - code: 'MISSING_FILE' - }); - } - - // Validate file size - if (req.file.size > 5 * 1024 * 1024) { - return res.status(400).json({ - error: 'File too large. Maximum size is 5MB.', - code: 'FILE_TOO_LARGE' - }); - } - - // Upload to S3 - const imageUrl = await uploadToS3( - req.file.buffer, - req.file.originalname, - req.file.mimetype, - 'test' - ); - - console.log('✅ S3 test upload no auth successful'); - - res.json({ - success: true, - imageUrl: imageUrl, - filename: req.file.originalname, - size: req.file.size - }); - - } catch (error) { - console.error('❌ S3 test upload no auth error:', error); - res.status(500).json({ - error: 'Failed to upload image to S3', - details: error.message, - code: 'UPLOAD_FAILED' - }); - } -}); +router.post( + '/test-upload-no-auth', + upload.single('image'), + async (req, res) => { + try { + console.log('🔍 S3 Test Upload No Auth - Request received:', { + hasFile: !!req.file, + fileInfo: req.file + ? { + originalname: req.file.originalname, + size: req.file.size, + mimetype: req.file.mimetype, + } + : null, + }); + + if (!req.file) { + return res.status(400).json({ + error: 'No image file provided', + code: 'MISSING_FILE', + }); + } + + // Validate file size + if (req.file.size > 5 * 1024 * 1024) { + return res.status(400).json({ + error: 'File too large. Maximum size is 5MB.', + code: 'FILE_TOO_LARGE', + }); + } + + // Upload to S3 + const imageUrl = await uploadToS3( + req.file.buffer, + req.file.originalname, + req.file.mimetype, + 'test', + ); + + console.log('✅ S3 test upload no auth successful'); + + res.json({ + success: true, + imageUrl: imageUrl, + filename: req.file.originalname, + size: req.file.size, + }); + } catch (error) { + console.error('❌ S3 test upload no auth error:', error); + res.status(500).json({ + error: 'Failed to upload image to S3', + details: error.message, + code: 'UPLOAD_FAILED', + }); + } + }, +); /** * POST /api/s3-upload/test-moderation * Test image moderation without uploading to S3 */ router.post('/test-moderation', upload.single('image'), async (req, res) => { - try { - console.log('🔍 Test Moderation - Request received:', { - hasFile: !!req.file, - fileInfo: req.file ? { - originalname: req.file.originalname, - size: req.file.size, - mimetype: req.file.mimetype - } : null - }); - - if (!req.file) { - return res.status(400).json({ - error: 'No image file provided', - code: 'MISSING_FILE' - }); - } - - // Validate file size - if (req.file.size > 5 * 1024 * 1024) { - return res.status(400).json({ - error: 'File too large. Maximum size is 5MB.', - code: 'FILE_TOO_LARGE' - }); - } - - // Test moderation only - const { moderateImage } = await import('../services/imageModerationService.js'); - const moderationResult = await moderateImage(req.file.buffer); - - console.log('✅ Test moderation completed'); - - res.json({ - success: true, - moderationResult, - filename: req.file.originalname, - size: req.file.size - }); - - } catch (error) { - console.error('❌ Test moderation error:', error); - res.status(500).json({ - error: 'Failed to moderate image', - details: error.message, - code: 'MODERATION_FAILED' - }); - } + try { + console.log('🔍 Test Moderation - Request received:', { + hasFile: !!req.file, + fileInfo: req.file + ? { + originalname: req.file.originalname, + size: req.file.size, + mimetype: req.file.mimetype, + } + : null, + }); + + if (!req.file) { + return res.status(400).json({ + error: 'No image file provided', + code: 'MISSING_FILE', + }); + } + + // Validate file size + if (req.file.size > 5 * 1024 * 1024) { + return res.status(400).json({ + error: 'File too large. Maximum size is 5MB.', + code: 'FILE_TOO_LARGE', + }); + } + + // Test moderation only + const { moderateImage } = await import( + '../services/imageModerationService.js' + ); + const moderationResult = await moderateImage(req.file.buffer); + + console.log('✅ Test moderation completed'); + + res.json({ + success: true, + moderationResult, + filename: req.file.originalname, + size: req.file.size, + }); + } catch (error) { + console.error('❌ Test moderation error:', error); + res.status(500).json({ + error: 'Failed to moderate image', + details: error.message, + code: 'MODERATION_FAILED', + }); + } }); /** * POST /api/s3-upload/job-image * Upload a single image for a job with moderation */ -router.post('/job-image', requireAuth, upload.single('image'), async (req, res) => { - try { - console.log('🔍 S3 Upload route - Request received:', { - hasFile: !!req.file, - fileInfo: req.file ? { - originalname: req.file.originalname, - size: req.file.size, - mimetype: req.file.mimetype - } : null, - userId: req.user?.clerkUserId - }); - - if (!req.file) { - return res.status(400).json({ - error: 'No image file provided', - code: 'MISSING_FILE' - }); - } - - // Validate file size - if (req.file.size > 5 * 1024 * 1024) { - return res.status(400).json({ - error: 'File too large. Maximum size is 5MB.', - code: 'FILE_TOO_LARGE' - }); - } - - // Upload to S3 with moderation - const uploadResult = await uploadToS3WithModeration( - req.file.buffer, - req.file.originalname, - req.file.mimetype, - 'jobs' - ); - - if (!uploadResult.success) { - if (uploadResult.code === 'CONTENT_REJECTED') { - return res.status(400).json({ - error: uploadResult.error, - code: uploadResult.code, - moderationDetails: uploadResult.moderationResult - }); - } - - return res.status(500).json({ - error: uploadResult.error, - code: uploadResult.code - }); - } - - console.log('✅ S3 upload with moderation successful for user:', req.user?.clerkUserId); - - res.json({ - success: true, - imageUrl: uploadResult.imageUrl, - filename: req.file.originalname, - size: req.file.size, - moderationResult: uploadResult.moderationResult - }); - - } catch (error) { - console.error('❌ S3 upload error:', error); - res.status(500).json({ - error: 'Failed to upload image to S3', - details: error.message, - code: 'UPLOAD_FAILED' - }); - } -}); +router.post( + '/job-image', + requireAuth, + upload.single('image'), + async (req, res) => { + try { + console.log('🔍 S3 Upload route - Request received:', { + hasFile: !!req.file, + fileInfo: req.file + ? { + originalname: req.file.originalname, + size: req.file.size, + mimetype: req.file.mimetype, + } + : null, + userId: req.user?.clerkUserId, + }); + + if (!req.file) { + return res.status(400).json({ + error: 'No image file provided', + code: 'MISSING_FILE', + }); + } + + // Validate file size + if (req.file.size > 5 * 1024 * 1024) { + return res.status(400).json({ + error: 'File too large. Maximum size is 5MB.', + code: 'FILE_TOO_LARGE', + }); + } + + // Upload to S3 with moderation + const uploadResult = await uploadToS3WithModeration( + req.file.buffer, + req.file.originalname, + req.file.mimetype, + 'jobs', + ); + + if (!uploadResult.success) { + if (uploadResult.code === 'CONTENT_REJECTED') { + return res.status(400).json({ + error: uploadResult.error, + code: uploadResult.code, + moderationDetails: uploadResult.moderationResult, + }); + } + + return res.status(500).json({ + error: uploadResult.error, + code: uploadResult.code, + }); + } + + console.log( + '✅ S3 upload with moderation successful for user:', + req.user?.clerkUserId, + ); + + res.json({ + success: true, + imageUrl: uploadResult.imageUrl, + filename: req.file.originalname, + size: req.file.size, + moderationResult: uploadResult.moderationResult, + }); + } catch (error) { + console.error('❌ S3 upload error:', error); + res.status(500).json({ + error: 'Failed to upload image to S3', + details: error.message, + code: 'UPLOAD_FAILED', + }); + } + }, +); /** * POST /api/s3-upload/job-with-image * Create a job with an uploaded image */ -router.post('/job-with-image', requireAuth, upload.single('image'), async (req, res) => { - try { - console.log('🔍 Create job with image - Request received:', { - hasFile: !!req.file, - body: req.body, - userId: req.user?.clerkUserId - }); - - const { title, salary, phone, description, cityId, categoryId, shuttle, meals } = req.body; - - // Validate required fields - if (!title || !salary || !phone || !description || !cityId || !categoryId) { - return res.status(400).json({ - error: 'Missing required fields', - required: ['title', 'salary', 'phone', 'description', 'cityId', 'categoryId'] - }); - } - - let imageUrl = null; - - // Upload image if provided - if (req.file) { - try { - const uploadResult = await uploadToS3WithModeration( - req.file.buffer, - req.file.originalname, - req.file.mimetype, - 'jobs' - ); - - if (!uploadResult.success) { - if (uploadResult.code === 'CONTENT_REJECTED') { - return res.status(400).json({ - error: uploadResult.error, - code: uploadResult.code, - moderationDetails: uploadResult.moderationResult - }); - } - - return res.status(500).json({ - error: uploadResult.error, - code: uploadResult.code - }); - } - - imageUrl = uploadResult.imageUrl; - } catch (uploadError) { - console.error('❌ Image upload failed:', uploadError); - return res.status(500).json({ - error: 'Failed to upload image', - details: uploadError.message - }); - } - } - - // Create job using the service to ensure all validation and business logic is applied - const { createJobService } = await import('../services/jobCreateService.js'); - - const jobData = { - title, - salary, - phone, - description, - cityId: parseInt(cityId), - categoryId: parseInt(categoryId), - userId: req.user.clerkUserId, - imageUrl, - shuttle: shuttle === 'true', - meals: meals === 'true' - }; - - const result = await createJobService(jobData); - - if (result.errors) { - return res.status(400).json({ success: false, errors: result.errors }); - } - - if (result.error) { - return res.status(400).json({ error: result.error }); - } - - const job = result.job; - - console.log('✅ Job created successfully with image:', job.id); - - res.status(201).json({ - success: true, - job, - imageUrl - }); - - } catch (error) { - console.error('❌ Create job with image error:', error); - - // If job creation failed and image was uploaded, try to delete it - if (req.file && error.code !== 'P2002') { // Not a duplicate key error - try { - // Note: We can't delete the image here since we don't have the URL yet - console.warn('⚠️ Job creation failed, but image was uploaded'); - } catch (deleteError) { - console.error('❌ Failed to cleanup uploaded image:', deleteError); - } - } - - res.status(500).json({ - error: 'Failed to create job', - details: error.message - }); - } -}); +router.post( + '/job-with-image', + requireAuth, + upload.single('image'), + async (req, res) => { + try { + console.log('🔍 Create job with image - Request received:', { + hasFile: !!req.file, + body: req.body, + userId: req.user?.clerkUserId, + }); + + const { + title, + salary, + phone, + description, + cityId, + categoryId, + shuttle, + meals, + } = req.body; + + // Validate required fields + if ( + !title || + !salary || + !phone || + !description || + !cityId || + !categoryId + ) { + return res.status(400).json({ + error: 'Missing required fields', + required: [ + 'title', + 'salary', + 'phone', + 'description', + 'cityId', + 'categoryId', + ], + }); + } + + let imageUrl = null; + + // Upload image if provided + if (req.file) { + try { + const uploadResult = await uploadToS3WithModeration( + req.file.buffer, + req.file.originalname, + req.file.mimetype, + 'jobs', + ); + + if (!uploadResult.success) { + if (uploadResult.code === 'CONTENT_REJECTED') { + return res.status(400).json({ + error: uploadResult.error, + code: uploadResult.code, + moderationDetails: uploadResult.moderationResult, + }); + } + + return res.status(500).json({ + error: uploadResult.error, + code: uploadResult.code, + }); + } + + imageUrl = uploadResult.imageUrl; + } catch (uploadError) { + console.error('❌ Image upload failed:', uploadError); + return res.status(500).json({ + error: 'Failed to upload image', + details: uploadError.message, + }); + } + } + + // Create job using the service to ensure all validation and business logic is applied + const { createJobService } = await import( + '../services/jobCreateService.js' + ); + + const jobData = { + title, + salary, + phone, + description, + cityId: parseInt(cityId), + categoryId: parseInt(categoryId), + userId: req.user.clerkUserId, + imageUrl, + shuttle: shuttle === 'true', + meals: meals === 'true', + }; + + const result = await createJobService(jobData); + + if (result.errors) { + return res.status(400).json({ success: false, errors: result.errors }); + } + + if (result.error) { + return res.status(400).json({ error: result.error }); + } + + const job = result.job; + + console.log('✅ Job created successfully with image:', job.id); + + res.status(201).json({ + success: true, + job, + imageUrl, + }); + } catch (error) { + console.error('❌ Create job with image error:', error); + + // If job creation failed and image was uploaded, try to delete it + if (req.file && error.code !== 'P2002') { + // Not a duplicate key error + try { + // Note: We can't delete the image here since we don't have the URL yet + console.warn('⚠️ Job creation failed, but image was uploaded'); + } catch (deleteError) { + console.error('❌ Failed to cleanup uploaded image:', deleteError); + } + } + + res.status(500).json({ + error: 'Failed to create job', + details: error.message, + }); + } + }, +); /** * DELETE /api/s3-upload/delete-image * Delete an image from S3 */ router.delete('/delete-image', requireAuth, async (req, res) => { - try { - const { imageUrl } = req.body; - - if (!imageUrl) { - return res.status(400).json({ - error: 'Image URL is required', - code: 'MISSING_URL' - }); - } - - const deleted = await deleteFromS3(imageUrl); - - if (deleted) { - res.json({ - success: true, - message: 'Image deleted successfully' - }); - } else { - res.status(404).json({ - error: 'Image not found or could not be deleted', - code: 'DELETE_FAILED' - }); - } - - } catch (error) { - console.error('❌ Delete image error:', error); - res.status(500).json({ - error: 'Failed to delete image', - details: error.message - }); - } + try { + const { imageUrl } = req.body; + + if (!imageUrl) { + return res.status(400).json({ + error: 'Image URL is required', + code: 'MISSING_URL', + }); + } + + const deleted = await deleteFromS3(imageUrl); + + if (deleted) { + res.json({ + success: true, + message: 'Image deleted successfully', + }); + } else { + res.status(404).json({ + error: 'Image not found or could not be deleted', + code: 'DELETE_FAILED', + }); + } + } catch (error) { + console.error('❌ Delete image error:', error); + res.status(500).json({ + error: 'Failed to delete image', + details: error.message, + }); + } }); /** * PUT /api/s3-upload/update-job-image/:jobId * Update job image */ -router.put('/update-job-image/:jobId', requireAuth, upload.single('image'), async (req, res) => { - try { - const { jobId } = req.params; - const userId = req.user.clerkUserId; - - console.log('🔍 Update job image - Request received:', { - jobId, - userId, - hasFile: !!req.file - }); - - // Check if job exists and belongs to user - const existingJob = await prisma.job.findFirst({ - where: { - id: parseInt(jobId), - userId: userId - } - }); - - if (!existingJob) { - return res.status(404).json({ - error: 'Job not found or access denied', - code: 'JOB_NOT_FOUND' - }); - } - - let imageUrl = existingJob.imageUrl; - - // Upload new image if provided - if (req.file) { - try { - // Delete old image if it exists - if (existingJob.imageUrl) { - await deleteFromS3(existingJob.imageUrl); - } - - // Upload new image with moderation - const uploadResult = await uploadToS3WithModeration( - req.file.buffer, - req.file.originalname, - req.file.mimetype, - 'jobs' - ); - - if (!uploadResult.success) { - if (uploadResult.code === 'CONTENT_REJECTED') { - return res.status(400).json({ - error: uploadResult.error, - code: uploadResult.code, - moderationDetails: uploadResult.moderationResult - }); - } - - return res.status(500).json({ - error: uploadResult.error, - code: uploadResult.code - }); - } - - imageUrl = uploadResult.imageUrl; - } catch (uploadError) { - console.error('❌ Image upload failed:', uploadError); - return res.status(500).json({ - error: 'Failed to upload new image', - details: uploadError.message - }); - } - } - - // Update job in database - const updatedJob = await prisma.job.update({ - where: { - id: parseInt(jobId) - }, - data: { - imageUrl - }, - include: { - city: true, - category: true, - user: { - select: { - id: true, - firstName: true, - lastName: true, - email: true - } - } - } - }); - - console.log('✅ Job image updated successfully:', jobId); - - res.json({ - success: true, - job: updatedJob, - imageUrl - }); - - } catch (error) { - console.error('❌ Update job image error:', error); - res.status(500).json({ - error: 'Failed to update job image', - details: error.message - }); - } -}); +router.put( + '/update-job-image/:jobId', + requireAuth, + upload.single('image'), + async (req, res) => { + try { + const { jobId } = req.params; + const userId = req.user.clerkUserId; + + console.log('🔍 Update job image - Request received:', { + jobId, + userId, + hasFile: !!req.file, + }); + + // Check if job exists and belongs to user + const existingJob = await prisma.job.findFirst({ + where: { + id: parseInt(jobId), + userId: userId, + }, + }); + + if (!existingJob) { + return res.status(404).json({ + error: 'Job not found or access denied', + code: 'JOB_NOT_FOUND', + }); + } + + let imageUrl = existingJob.imageUrl; + + // Upload new image if provided + if (req.file) { + try { + // Delete old image if it exists + if (existingJob.imageUrl) { + await deleteFromS3(existingJob.imageUrl); + } + + // Upload new image with moderation + const uploadResult = await uploadToS3WithModeration( + req.file.buffer, + req.file.originalname, + req.file.mimetype, + 'jobs', + ); + + if (!uploadResult.success) { + if (uploadResult.code === 'CONTENT_REJECTED') { + return res.status(400).json({ + error: uploadResult.error, + code: uploadResult.code, + moderationDetails: uploadResult.moderationResult, + }); + } + + return res.status(500).json({ + error: uploadResult.error, + code: uploadResult.code, + }); + } + + imageUrl = uploadResult.imageUrl; + } catch (uploadError) { + console.error('❌ Image upload failed:', uploadError); + return res.status(500).json({ + error: 'Failed to upload new image', + details: uploadError.message, + }); + } + } + + // Update job in database + const updatedJob = await prisma.job.update({ + where: { + id: parseInt(jobId), + }, + data: { + imageUrl, + }, + include: { + city: true, + category: true, + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + console.log('✅ Job image updated successfully:', jobId); + + res.json({ + success: true, + job: updatedJob, + imageUrl, + }); + } catch (error) { + console.error('❌ Update job image error:', error); + res.status(500).json({ + error: 'Failed to update job image', + details: error.message, + }); + } + }, +); // Error handling middleware for multer router.use((error, req, res, next) => { - if (error.code === 'LIMIT_FILE_SIZE') { - return res.status(400).json({ - error: 'File too large. Maximum size is 5MB.', - code: 'FILE_TOO_LARGE' - }); - } - - if (error.message === 'Only image files are allowed!') { - return res.status(400).json({ - error: 'Only image files are allowed!', - code: 'INVALID_FILE_TYPE' - }); - } - - next(error); + if (error.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ + error: 'File too large. Maximum size is 5MB.', + code: 'FILE_TOO_LARGE', + }); + } + + if (error.message === 'Only image files are allowed!') { + return res.status(400).json({ + error: 'Only image files are allowed!', + code: 'INVALID_FILE_TYPE', + }); + } + + next(error); }); -export default router; \ No newline at end of file +export default router; diff --git a/apps/api/routes/seekers.js b/apps/api/routes/seekers.js index 55904be..8acf151 100644 --- a/apps/api/routes/seekers.js +++ b/apps/api/routes/seekers.js @@ -1,5 +1,11 @@ import express from 'express'; -import { getSeekers, addSeeker, getSeekerBySlugController, deleteSeekerController, getSeekerByIdController } from '../controllers/seekerController.js'; +import { + addSeeker, + deleteSeekerController, + getSeekerByIdController, + getSeekerBySlugController, + getSeekers, +} from '../controllers/seekerController.js'; const router = express.Router(); @@ -9,4 +15,4 @@ router.get('/slug/:slug', getSeekerBySlugController); router.get('/:id', getSeekerByIdController); router.delete('/:id', deleteSeekerController); -export default router; \ No newline at end of file +export default router; diff --git a/apps/api/routes/upload.js b/apps/api/routes/upload.js index 3a6d007..9e97042 100644 --- a/apps/api/routes/upload.js +++ b/apps/api/routes/upload.js @@ -1,8 +1,8 @@ +import dotenv from 'dotenv'; import express from 'express'; import multer from 'multer'; -import upload from '../utils/upload.js'; import { requireAuth } from '../middlewares/auth.js'; -import dotenv from 'dotenv'; +import upload from '../utils/upload.js'; dotenv.config(); @@ -10,55 +10,59 @@ const router = express.Router(); // Upload single image router.post('/job-image', requireAuth, upload.single('image'), (req, res) => { - try { - console.log('🔍 Upload route - Request received:', { - hasFile: !!req.file, - fileInfo: req.file ? { - filename: req.file.filename, - originalname: req.file.originalname, - size: req.file.size, - mimetype: req.file.mimetype - } : null - }); + try { + console.log('🔍 Upload route - Request received:', { + hasFile: !!req.file, + fileInfo: req.file + ? { + filename: req.file.filename, + originalname: req.file.originalname, + size: req.file.size, + mimetype: req.file.mimetype, + } + : null, + }); - if (!req.file) { - return res.status(400).json({ error: 'No image file provided' }); - } + if (!req.file) { + return res.status(400).json({ error: 'No image file provided' }); + } - // Generate the full URL for the uploaded image - // For development, use localhost:3001, for production use the actual domain - const baseUrl = req.get('host')?.includes('localhost') - ? 'http://localhost:3001' - : 'https://worknow.co.il'; - const imageUrl = `${baseUrl}/images/jobs/${req.file.filename}`; - - console.log('🔍 Upload route - Generated imageUrl:', imageUrl); - - res.json({ - success: true, - imageUrl: imageUrl, - filename: req.file.filename - }); - } catch (error) { - console.error('Upload error:', error); - res.status(500).json({ error: 'Failed to upload image' }); - } + // Generate the full URL for the uploaded image + // For development, use localhost:3001, for production use the actual domain + const baseUrl = req.get('host')?.includes('localhost') + ? 'http://localhost:3001' + : 'https://worknow.co.il'; + const imageUrl = `${baseUrl}/images/jobs/${req.file.filename}`; + + console.log('🔍 Upload route - Generated imageUrl:', imageUrl); + + res.json({ + success: true, + imageUrl: imageUrl, + filename: req.file.filename, + }); + } catch (error) { + console.error('Upload error:', error); + res.status(500).json({ error: 'Failed to upload image' }); + } }); // Error handling middleware for multer router.use((error, req, res, next) => { - if (error instanceof multer.MulterError) { - if (error.code === 'LIMIT_FILE_SIZE') { - return res.status(400).json({ error: 'File too large. Maximum size is 5MB.' }); - } - return res.status(400).json({ error: 'File upload error' }); - } - - if (error.message === 'Only image files are allowed!') { - return res.status(400).json({ error: 'Only image files are allowed!' }); - } - - next(error); + if (error instanceof multer.MulterError) { + if (error.code === 'LIMIT_FILE_SIZE') { + return res + .status(400) + .json({ error: 'File too large. Maximum size is 5MB.' }); + } + return res.status(400).json({ error: 'File upload error' }); + } + + if (error.message === 'Only image files are allowed!') { + return res.status(400).json({ error: 'Only image files are allowed!' }); + } + + next(error); }); -export default router; \ No newline at end of file +export default router; diff --git a/apps/api/routes/users.js b/apps/api/routes/users.js index 3adefa5..ab6c693 100755 --- a/apps/api/routes/users.js +++ b/apps/api/routes/users.js @@ -1,5 +1,10 @@ import express from 'express'; -import { syncUser, getUserByClerkId, getUserJobs, clerkWebhook } from '../controllers/usersController.js'; +import { + clerkWebhook, + getUserByClerkId, + getUserJobs, + syncUser, +} from '../controllers/usersController.js'; const router = express.Router(); diff --git a/apps/api/services/aiJobTitleService.js b/apps/api/services/aiJobTitleService.js index 0dc6b8f..0e7cd38 100644 --- a/apps/api/services/aiJobTitleService.js +++ b/apps/api/services/aiJobTitleService.js @@ -1,5 +1,5 @@ -import OpenAI from 'openai'; import pkg from '@prisma/client'; +import OpenAI from 'openai'; const { PrismaClient } = pkg; @@ -7,44 +7,46 @@ const prisma = new PrismaClient(); // Initialize OpenAI client const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, + apiKey: process.env.OPENAI_API_KEY, }); // Rate limiting configuration const RATE_LIMIT_CONFIG = { - requestsPerMinute: 3, // Very conservative limit - delayBetweenRequests: 20000, // 20 seconds between requests - maxRetries: 3, // Reduced from 6 to 3 - initialDelay: 5000, // Start with 5 seconds - exponentialBase: 2, - jitter: true + requestsPerMinute: 3, // Very conservative limit + delayBetweenRequests: 20000, // 20 seconds between requests + maxRetries: 3, // Reduced from 6 to 3 + initialDelay: 5000, // Start with 5 seconds + exponentialBase: 2, + jitter: true, }; // Simple rate limiter class RateLimiter { - constructor(maxRequestsPerMinute) { - this.maxRequestsPerMinute = maxRequestsPerMinute; - this.requests = []; - } - - async waitForSlot() { - const now = Date.now(); - const oneMinuteAgo = now - 60000; - - // Remove old requests - this.requests = this.requests.filter(time => time > oneMinuteAgo); - - // If we've made too many requests recently, wait - if (this.requests.length >= this.maxRequestsPerMinute) { - const oldestRequest = this.requests[0]; - const waitTime = 60000 - (now - oldestRequest); - console.log(`⏳ Rate limiter: Waiting ${Math.round(waitTime)}ms for next slot...`); - await new Promise(resolve => setTimeout(resolve, waitTime)); - } - - // Add current request - this.requests.push(now); - } + constructor(maxRequestsPerMinute) { + this.maxRequestsPerMinute = maxRequestsPerMinute; + this.requests = []; + } + + async waitForSlot() { + const now = Date.now(); + const oneMinuteAgo = now - 60000; + + // Remove old requests + this.requests = this.requests.filter((time) => time > oneMinuteAgo); + + // If we've made too many requests recently, wait + if (this.requests.length >= this.maxRequestsPerMinute) { + const oldestRequest = this.requests[0]; + const waitTime = 60000 - (now - oldestRequest); + console.log( + `⏳ Rate limiter: Waiting ${Math.round(waitTime)}ms for next slot...`, + ); + await new Promise((resolve) => setTimeout(resolve, waitTime)); + } + + // Add current request + this.requests.push(now); + } } // Global rate limiter instance @@ -56,13 +58,21 @@ const rateLimiter = new RateLimiter(RATE_LIMIT_CONFIG.requestsPerMinute); * @returns {boolean} True if it's a quota/billing error */ function isQuotaError(error) { - const quotaKeywords = [ - 'quota', 'billing', 'payment', 'credit', 'exceeded', 'insufficient', - 'account', 'plan', 'subscription', 'payment method' - ]; - - const errorMessage = error.message?.toLowerCase() || ''; - return quotaKeywords.some(keyword => errorMessage.includes(keyword)); + const quotaKeywords = [ + 'quota', + 'billing', + 'payment', + 'credit', + 'exceeded', + 'insufficient', + 'account', + 'plan', + 'subscription', + 'payment method', + ]; + + const errorMessage = error.message?.toLowerCase() || ''; + return quotaKeywords.some((keyword) => errorMessage.includes(keyword)); } /** @@ -71,12 +81,15 @@ function isQuotaError(error) { * @returns {boolean} True if it's a rate limit error */ function isRateLimitError(error) { - const rateLimitKeywords = [ - '429', 'rate limit', 'too many requests', 'throttle' - ]; - - const errorMessage = error.message?.toLowerCase() || ''; - return rateLimitKeywords.some(keyword => errorMessage.includes(keyword)); + const rateLimitKeywords = [ + '429', + 'rate limit', + 'too many requests', + 'throttle', + ]; + + const errorMessage = error.message?.toLowerCase() || ''; + return rateLimitKeywords.some((keyword) => errorMessage.includes(keyword)); } /** @@ -86,58 +99,67 @@ function isRateLimitError(error) { * @returns {Function} Wrapped function with retry logic */ function retryWithExponentialBackoff( - func, - { - initialDelay = RATE_LIMIT_CONFIG.initialDelay, - exponentialBase = RATE_LIMIT_CONFIG.exponentialBase, - jitter = RATE_LIMIT_CONFIG.jitter, - maxRetries = RATE_LIMIT_CONFIG.maxRetries, - errors = [Error] - } = {} + func, + { + initialDelay = RATE_LIMIT_CONFIG.initialDelay, + exponentialBase = RATE_LIMIT_CONFIG.exponentialBase, + jitter = RATE_LIMIT_CONFIG.jitter, + maxRetries = RATE_LIMIT_CONFIG.maxRetries, + errors = [Error], + } = {}, ) { - return async function (...args) { - let numRetries = 0; - let delay = initialDelay; - - while (true) { - try { - // Wait for rate limiter slot - await rateLimiter.waitForSlot(); - - return await func.apply(this, args); - } catch (error) { - // Check if this is a quota error - don't retry these - if (isQuotaError(error)) { - console.log("❌ Quota/billing error detected - not retrying"); - throw new Error(`OpenAI quota exceeded: ${error.message}`); - } - - // Check if this is a rate limit error - const isRateLimit = isRateLimitError(error); - - // Only retry on rate limit errors or specified errors - if (!isRateLimit && !errors.some(ErrorClass => error instanceof ErrorClass)) { - throw error; - } - - numRetries += 1; - - // Check if max retries has been reached - if (numRetries > maxRetries) { - console.error(`❌ Maximum number of retries (${maxRetries}) exceeded for rate limit.`); - throw new Error(`Rate limit exceeded after ${maxRetries} retries. Please try again later.`); - } - - // Calculate delay with exponential backoff and optional jitter - delay *= exponentialBase * (1 + jitter * Math.random()); - - console.log(`⏳ Rate limit hit. Retrying in ${Math.round(delay)}ms (attempt ${numRetries}/${maxRetries})...`); - - // Sleep for the delay - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - }; + return async function (...args) { + let numRetries = 0; + let delay = initialDelay; + + while (true) { + try { + // Wait for rate limiter slot + await rateLimiter.waitForSlot(); + + return await func.apply(this, args); + } catch (error) { + // Check if this is a quota error - don't retry these + if (isQuotaError(error)) { + console.log('❌ Quota/billing error detected - not retrying'); + throw new Error(`OpenAI quota exceeded: ${error.message}`); + } + + // Check if this is a rate limit error + const isRateLimit = isRateLimitError(error); + + // Only retry on rate limit errors or specified errors + if ( + !isRateLimit && + !errors.some((ErrorClass) => error instanceof ErrorClass) + ) { + throw error; + } + + numRetries += 1; + + // Check if max retries has been reached + if (numRetries > maxRetries) { + console.error( + `❌ Maximum number of retries (${maxRetries}) exceeded for rate limit.`, + ); + throw new Error( + `Rate limit exceeded after ${maxRetries} retries. Please try again later.`, + ); + } + + // Calculate delay with exponential backoff and optional jitter + delay *= exponentialBase * (1 + jitter * Math.random()); + + console.log( + `⏳ Rate limit hit. Retrying in ${Math.round(delay)}ms (attempt ${numRetries}/${maxRetries})...`, + ); + + // Sleep for the delay + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + }; } /** @@ -146,66 +168,64 @@ function retryWithExponentialBackoff( * Includes proper rate limit handling with exponential backoff */ class AIJobTitleService { - - /** - * Generate job title using AI analysis with rate limit handling - * @param {string} description - Job description - * @param {Object} context - Additional context (city, salary, etc.) - * @returns {Promise} Generated title with confidence and analysis - */ - static async generateAITitle(description, context = {}) { - try { - if (!process.env.OPENAI_API_KEY) { - console.warn('⚠️ OpenAI API key not found. Using fallback method.'); - return this.fallbackTitleGeneration(description); - } - - // Use the retry wrapper for the actual API call - const generateTitleWithRetry = retryWithExponentialBackoff( - this._makeOpenAIRequest.bind(this), - { - initialDelay: RATE_LIMIT_CONFIG.initialDelay, - exponentialBase: RATE_LIMIT_CONFIG.exponentialBase, - jitter: RATE_LIMIT_CONFIG.jitter, - maxRetries: RATE_LIMIT_CONFIG.maxRetries, - errors: [Error] - } - ); - - const result = await generateTitleWithRetry(description, context); - return result; - - } catch (error) { - console.error('❌ AI title generation failed:', error.message); - - // Check if it's a quota error - if (isQuotaError(error)) { - console.log('💡 Using fallback due to quota/billing issues'); - } else if (isRateLimitError(error)) { - console.log('💡 Using fallback due to rate limits'); - } else { - console.log('💡 Using fallback due to other errors'); - } - - return this.fallbackTitleGeneration(description); - } - } - - /** - * Make the actual OpenAI API request (separated for retry logic) - * @param {string} description - Job description - * @param {Object} context - Additional context - * @returns {Promise} Generated title data - */ - static async _makeOpenAIRequest(description, context = {}) { - const prompt = this.buildPrompt(description, context); - - const completion = await openai.chat.completions.create({ - model: "gpt-3.5-turbo", - messages: [ - { - role: "system", - content: `You are an expert job title generator for the Israeli job market. + /** + * Generate job title using AI analysis with rate limit handling + * @param {string} description - Job description + * @param {Object} context - Additional context (city, salary, etc.) + * @returns {Promise} Generated title with confidence and analysis + */ + static async generateAITitle(description, context = {}) { + try { + if (!process.env.OPENAI_API_KEY) { + console.warn('⚠️ OpenAI API key not found. Using fallback method.'); + return this.fallbackTitleGeneration(description); + } + + // Use the retry wrapper for the actual API call + const generateTitleWithRetry = retryWithExponentialBackoff( + this._makeOpenAIRequest.bind(this), + { + initialDelay: RATE_LIMIT_CONFIG.initialDelay, + exponentialBase: RATE_LIMIT_CONFIG.exponentialBase, + jitter: RATE_LIMIT_CONFIG.jitter, + maxRetries: RATE_LIMIT_CONFIG.maxRetries, + errors: [Error], + }, + ); + + const result = await generateTitleWithRetry(description, context); + return result; + } catch (error) { + console.error('❌ AI title generation failed:', error.message); + + // Check if it's a quota error + if (isQuotaError(error)) { + console.log('💡 Using fallback due to quota/billing issues'); + } else if (isRateLimitError(error)) { + console.log('💡 Using fallback due to rate limits'); + } else { + console.log('💡 Using fallback due to other errors'); + } + + return this.fallbackTitleGeneration(description); + } + } + + /** + * Make the actual OpenAI API request (separated for retry logic) + * @param {string} description - Job description + * @param {Object} context - Additional context + * @returns {Promise} Generated title data + */ + static async _makeOpenAIRequest(description, context = {}) { + const prompt = this.buildPrompt(description, context); + + const completion = await openai.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { + role: 'system', + content: `You are an expert job title generator for the Israeli job market. Your task is to analyze job descriptions and generate concise, professional job titles in Russian. Requirements: @@ -235,402 +255,468 @@ class AIJobTitleService { - Парикмахер (Hairdresser) - Массажист (Masseur) - Return only the job title, nothing else.` - }, - { - role: "user", - content: prompt - } - ], - max_tokens: 50, - temperature: 0.3, - }); - - const generatedTitle = completion.choices[0]?.message?.content?.trim(); - - if (!generatedTitle) { - throw new Error('No title generated by AI'); - } - - return { - title: generatedTitle, - confidence: this.calculateAIConfidence(generatedTitle, description), - method: 'ai', - analysis: { - hasSpecificKeywords: this.hasSpecificKeywords(description), - hasLocation: this.hasLocation(description), - hasSalary: this.hasSalary(description), - hasLanguageRequirement: this.hasLanguageRequirement(description), - hasExperienceRequirement: this.hasExperienceRequirement(description) - } - }; - } - - /** - * Build AI prompt for job title generation - * @param {string} description - Job description - * @param {Object} context - Additional context - * @returns {string} Formatted prompt - */ - static buildPrompt(description, context = {}) { - let prompt = `Analyze this job description and generate a professional job title in Russian:\n\n`; - prompt += `Job Description: ${description}\n\n`; - - if (context.city) { - prompt += `Location: ${context.city}\n`; - } - if (context.salary) { - prompt += `Salary: ${context.salary} шек/час\n`; - } - if (context.requirements) { - prompt += `Requirements: ${context.requirements}\n`; - } - - prompt += `\nGenerate a concise, professional job title in Russian:`; - - return prompt; - } - - /** - * Fallback title generation when AI is not available - * @param {string} description - Job description - * @returns {Object} Generated title with fallback method - */ - static fallbackTitleGeneration(description) { - const lowerDescription = description.toLowerCase(); - - // Simple keyword-based title generation - let title = "Общая вакансия"; - - if (lowerDescription.includes('повар') || lowerDescription.includes('кухня')) { - title = "Повар"; - } else if (lowerDescription.includes('уборщик') || lowerDescription.includes('уборка')) { - title = "Уборщик"; - } else if (lowerDescription.includes('официант') || lowerDescription.includes('ресторан')) { - title = "Официант"; - } else if (lowerDescription.includes('грузчик') || lowerDescription.includes('склад')) { - title = "Грузчик"; - } else if (lowerDescription.includes('водитель') || lowerDescription.includes('доставка')) { - title = "Водитель"; - } else if (lowerDescription.includes('продавец') || lowerDescription.includes('магазин')) { - title = "Продавец-консультант"; - } else if (lowerDescription.includes('кассир') || lowerDescription.includes('касса')) { - title = "Кассир"; - } else if (lowerDescription.includes('строитель') || lowerDescription.includes('строительство')) { - title = "Строитель"; - } else if (lowerDescription.includes('электрик')) { - title = "Электрик"; - } else if (lowerDescription.includes('сантехник')) { - title = "Сантехник"; - } else if (lowerDescription.includes('маляр')) { - title = "Маляр"; - } else if (lowerDescription.includes('курьер')) { - title = "Курьер"; - } else if (lowerDescription.includes('программист')) { - title = "Программист"; - } else if (lowerDescription.includes('сиделка')) { - title = "Сиделка"; - } else if (lowerDescription.includes('няня')) { - title = "Няня"; - } else if (lowerDescription.includes('охранник')) { - title = "Охранник"; - } else if (lowerDescription.includes('парикмахер')) { - title = "Парикмахер"; - } else if (lowerDescription.includes('массажист')) { - title = "Массажист"; - } - - return { - title: title, - confidence: 0.6, // Lower confidence for rule-based method - method: 'rule-based', - analysis: { - hasSpecificKeywords: this.hasSpecificKeywords(description), - hasLocation: this.hasLocation(description), - hasSalary: this.hasSalary(description), - hasLanguageRequirement: this.hasLanguageRequirement(description), - hasExperienceRequirement: this.hasExperienceRequirement(description) - } - }; - } - - /** - * Calculate confidence score for AI-generated title - * @param {string} title - Generated title - * @param {string} description - Original description - * @returns {number} Confidence score (0-1) - */ - static calculateAIConfidence(title, description) { - if (!title || !description) return 0; - - const lowerTitle = title.toLowerCase(); - const lowerDescription = description.toLowerCase(); - - // Check if title keywords appear in description - const titleWords = lowerTitle.split(' ').filter(word => word.length > 2); - const matchingWords = titleWords.filter(word => lowerDescription.includes(word)); - - // Calculate basic confidence - let confidence = matchingWords.length / Math.max(titleWords.length, 1); - - // Boost confidence for AI-generated titles - confidence += 0.2; - - // Reduce confidence for generic titles - if (lowerTitle.includes('общая') || lowerTitle.includes('работник')) { - confidence -= 0.3; - } - - return Math.min(Math.max(confidence, 0), 1); - } - - /** - * Batch generate titles for multiple jobs with rate limit handling - * @param {Array} jobs - Array of job objects - * @returns {Promise} Array of jobs with AI-generated titles - */ - static async batchGenerateAITitles(jobs) { - console.log(`🤖 Starting AI-powered title generation for ${jobs.length} jobs...`); - console.log(`📊 Rate limit config: ${RATE_LIMIT_CONFIG.requestsPerMinute} requests per minute`); - - const results = []; - let successCount = 0; - let errorCount = 0; - let rateLimitCount = 0; - let quotaCount = 0; - - for (const job of jobs) { - try { - const context = { - city: job.city?.name, - salary: job.salary, - requirements: this.extractRequirements(job.description) - }; - - const titleData = await this.generateAITitle(job.description, context); - - results.push({ - ...job, - title: titleData.title, - titleConfidence: titleData.confidence, - titleMethod: titleData.method, - titleAnalysis: titleData.analysis - }); - - successCount++; - - // Log progress every 5 jobs (reduced from 10) - if (successCount % 5 === 0) { - console.log(`✅ Processed ${successCount}/${jobs.length} jobs`); - } - - // Add longer delay between requests to avoid hitting rate limits - await new Promise(resolve => setTimeout(resolve, RATE_LIMIT_CONFIG.delayBetweenRequests)); - - } catch (error) { - console.error(`❌ Failed to generate title for job ${job.id}:`, error.message); - errorCount++; - - if (isQuotaError(error)) { - quotaCount++; - } else if (isRateLimitError(error)) { - rateLimitCount++; - } - - // Use fallback for failed jobs - const fallbackData = this.fallbackTitleGeneration(job.description); - results.push({ - ...job, - title: fallbackData.title, - titleConfidence: fallbackData.confidence, - titleMethod: 'fallback', - titleAnalysis: fallbackData.analysis - }); - } - } - - console.log(`\n📊 AI Title Generation Summary:`); - console.log(` Total jobs: ${jobs.length}`); - console.log(` Successfully processed: ${successCount}`); - console.log(` Quota errors: ${quotaCount}`); - console.log(` Rate limit errors: ${rateLimitCount}`); - console.log(` Other errors (using fallback): ${errorCount - quotaCount - rateLimitCount}`); - - return results; - } - - /** - * Extract requirements from job description - * @param {string} description - Job description - * @returns {string} Extracted requirements - */ - static extractRequirements(description) { - const requirementPatterns = [ - /требуется\s+(.+?)(?=\n|$)/i, - /требования?\s*:\s*(.+?)(?=\n|$)/i, - /обязательно\s+(.+?)(?=\n|$)/i - ]; - - for (const pattern of requirementPatterns) { - const match = description.match(pattern); - if (match) { - return match[1].trim(); - } - } - - return ''; - } - - /** - * Check if description has specific job keywords - * @param {string} description - Job description - * @returns {boolean} - */ - static hasSpecificKeywords(description) { - const specificKeywords = [ - 'повар', 'официант', 'грузчик', 'водитель', 'продавец', - 'кассир', 'уборщик', 'строитель', 'электрик', 'сантехник', - 'маляр', 'курьер', 'программист', 'сиделка', 'няня' - ]; - - return specificKeywords.some(keyword => - description.toLowerCase().includes(keyword) - ); - } - - /** - * Check if description mentions a location - * @param {string} description - Job description - * @returns {boolean} - */ - static hasLocation(description) { - const locationPatterns = [ - /в\s+([а-яё]+(?:\s+[а-яё]+)*)/i, - /на\s+([а-яё]+(?:\s+[а-яё]+)*)/i - ]; - - return locationPatterns.some(pattern => pattern.test(description)); - } - - /** - * Check if description mentions salary - * @param {string} description - Job description - * @returns {boolean} - */ - static hasSalary(description) { - const salaryPattern = /(\d+)\s*(?:шек|₪|ILS)/i; - return salaryPattern.test(description); - } - - /** - * Check if description has language requirements - * @param {string} description - Job description - * @returns {boolean} - */ - static hasLanguageRequirement(description) { - const languageKeywords = ['иврит', 'ивритом', 'английский', 'английским', 'русский', 'русским']; - return languageKeywords.some(keyword => - description.toLowerCase().includes(keyword) - ); - } - - /** - * Check if description mentions experience requirements - * @param {string} description - Job description - * @returns {boolean} - */ - static hasExperienceRequirement(description) { - const experienceKeywords = ['опыт', 'опытный', 'опыт работы']; - return experienceKeywords.some(keyword => - description.toLowerCase().includes(keyword) - ); - } - - /** - * Update existing jobs in database with AI-generated titles - * @returns {Promise} Update statistics - */ - static async updateDatabaseWithAITitles() { - console.log("🤖 Updating database with AI-generated titles...\n"); - console.log(`📊 Rate limit config: ${RATE_LIMIT_CONFIG.requestsPerMinute} requests per minute`); - - try { - const jobs = await prisma.job.findMany({ - include: { - city: true, - category: true - } - }); - - console.log(`📊 Found ${jobs.length} jobs to update\n`); - - let updatedCount = 0; - let skippedCount = 0; - let errorCount = 0; - let rateLimitCount = 0; - let quotaCount = 0; - - for (const job of jobs) { - try { - const context = { - city: job.city?.name, - salary: job.salary, - requirements: this.extractRequirements(job.description) - }; - - const titleData = await this.generateAITitle(job.description, context); - - // Only update if the new title is better - if (titleData.confidence > 0.7 && titleData.title !== job.title) { - await prisma.job.update({ - where: { id: job.id }, - data: { title: titleData.title } - }); - - console.log(`✅ Updated: "${job.title}" → "${titleData.title}" (${titleData.method})`); - updatedCount++; - } else { - console.log(`⏭️ Skipping "${job.title}" - confidence: ${titleData.confidence.toFixed(2)}`); - skippedCount++; - } - - // Add longer delay to avoid rate limiting - await new Promise(resolve => setTimeout(resolve, RATE_LIMIT_CONFIG.delayBetweenRequests)); - - } catch (error) { - console.error(`❌ Failed to update job ${job.id}:`, error.message); - errorCount++; - - if (isQuotaError(error)) { - quotaCount++; - } else if (isRateLimitError(error)) { - rateLimitCount++; - } - } - } - - console.log(`\n📈 AI Update Summary:`); - console.log(` Total jobs processed: ${jobs.length}`); - console.log(` Jobs updated: ${updatedCount}`); - console.log(` Jobs skipped: ${skippedCount}`); - console.log(` Quota errors: ${quotaCount}`); - console.log(` Rate limit errors: ${rateLimitCount}`); - console.log(` Other errors: ${errorCount - quotaCount - rateLimitCount}`); - - return { - total: jobs.length, - updated: updatedCount, - skipped: skippedCount, - quotaErrors: quotaCount, - rateLimitErrors: rateLimitCount, - otherErrors: errorCount - quotaCount - rateLimitCount - }; - - } catch (error) { - console.error('❌ Error updating database with AI titles:', error); - throw error; - } - } + Return only the job title, nothing else.`, + }, + { + role: 'user', + content: prompt, + }, + ], + max_tokens: 50, + temperature: 0.3, + }); + + const generatedTitle = completion.choices[0]?.message?.content?.trim(); + + if (!generatedTitle) { + throw new Error('No title generated by AI'); + } + + return { + title: generatedTitle, + confidence: this.calculateAIConfidence(generatedTitle, description), + method: 'ai', + analysis: { + hasSpecificKeywords: this.hasSpecificKeywords(description), + hasLocation: this.hasLocation(description), + hasSalary: this.hasSalary(description), + hasLanguageRequirement: this.hasLanguageRequirement(description), + hasExperienceRequirement: this.hasExperienceRequirement(description), + }, + }; + } + + /** + * Build AI prompt for job title generation + * @param {string} description - Job description + * @param {Object} context - Additional context + * @returns {string} Formatted prompt + */ + static buildPrompt(description, context = {}) { + let prompt = `Analyze this job description and generate a professional job title in Russian:\n\n`; + prompt += `Job Description: ${description}\n\n`; + + if (context.city) { + prompt += `Location: ${context.city}\n`; + } + if (context.salary) { + prompt += `Salary: ${context.salary} шек/час\n`; + } + if (context.requirements) { + prompt += `Requirements: ${context.requirements}\n`; + } + + prompt += `\nGenerate a concise, professional job title in Russian:`; + + return prompt; + } + + /** + * Fallback title generation when AI is not available + * @param {string} description - Job description + * @returns {Object} Generated title with fallback method + */ + static fallbackTitleGeneration(description) { + const lowerDescription = description.toLowerCase(); + + // Simple keyword-based title generation + let title = 'Общая вакансия'; + + if ( + lowerDescription.includes('повар') || + lowerDescription.includes('кухня') + ) { + title = 'Повар'; + } else if ( + lowerDescription.includes('уборщик') || + lowerDescription.includes('уборка') + ) { + title = 'Уборщик'; + } else if ( + lowerDescription.includes('официант') || + lowerDescription.includes('ресторан') + ) { + title = 'Официант'; + } else if ( + lowerDescription.includes('грузчик') || + lowerDescription.includes('склад') + ) { + title = 'Грузчик'; + } else if ( + lowerDescription.includes('водитель') || + lowerDescription.includes('доставка') + ) { + title = 'Водитель'; + } else if ( + lowerDescription.includes('продавец') || + lowerDescription.includes('магазин') + ) { + title = 'Продавец-консультант'; + } else if ( + lowerDescription.includes('кассир') || + lowerDescription.includes('касса') + ) { + title = 'Кассир'; + } else if ( + lowerDescription.includes('строитель') || + lowerDescription.includes('строительство') + ) { + title = 'Строитель'; + } else if (lowerDescription.includes('электрик')) { + title = 'Электрик'; + } else if (lowerDescription.includes('сантехник')) { + title = 'Сантехник'; + } else if (lowerDescription.includes('маляр')) { + title = 'Маляр'; + } else if (lowerDescription.includes('курьер')) { + title = 'Курьер'; + } else if (lowerDescription.includes('программист')) { + title = 'Программист'; + } else if (lowerDescription.includes('сиделка')) { + title = 'Сиделка'; + } else if (lowerDescription.includes('няня')) { + title = 'Няня'; + } else if (lowerDescription.includes('охранник')) { + title = 'Охранник'; + } else if (lowerDescription.includes('парикмахер')) { + title = 'Парикмахер'; + } else if (lowerDescription.includes('массажист')) { + title = 'Массажист'; + } + + return { + title: title, + confidence: 0.6, // Lower confidence for rule-based method + method: 'rule-based', + analysis: { + hasSpecificKeywords: this.hasSpecificKeywords(description), + hasLocation: this.hasLocation(description), + hasSalary: this.hasSalary(description), + hasLanguageRequirement: this.hasLanguageRequirement(description), + hasExperienceRequirement: this.hasExperienceRequirement(description), + }, + }; + } + + /** + * Calculate confidence score for AI-generated title + * @param {string} title - Generated title + * @param {string} description - Original description + * @returns {number} Confidence score (0-1) + */ + static calculateAIConfidence(title, description) { + if (!title || !description) return 0; + + const lowerTitle = title.toLowerCase(); + const lowerDescription = description.toLowerCase(); + + // Check if title keywords appear in description + const titleWords = lowerTitle.split(' ').filter((word) => word.length > 2); + const matchingWords = titleWords.filter((word) => + lowerDescription.includes(word), + ); + + // Calculate basic confidence + let confidence = matchingWords.length / Math.max(titleWords.length, 1); + + // Boost confidence for AI-generated titles + confidence += 0.2; + + // Reduce confidence for generic titles + if (lowerTitle.includes('общая') || lowerTitle.includes('работник')) { + confidence -= 0.3; + } + + return Math.min(Math.max(confidence, 0), 1); + } + + /** + * Batch generate titles for multiple jobs with rate limit handling + * @param {Array} jobs - Array of job objects + * @returns {Promise} Array of jobs with AI-generated titles + */ + static async batchGenerateAITitles(jobs) { + console.log( + `🤖 Starting AI-powered title generation for ${jobs.length} jobs...`, + ); + console.log( + `📊 Rate limit config: ${RATE_LIMIT_CONFIG.requestsPerMinute} requests per minute`, + ); + + const results = []; + let successCount = 0; + let errorCount = 0; + let rateLimitCount = 0; + let quotaCount = 0; + + for (const job of jobs) { + try { + const context = { + city: job.city?.name, + salary: job.salary, + requirements: this.extractRequirements(job.description), + }; + + const titleData = await this.generateAITitle(job.description, context); + + results.push({ + ...job, + title: titleData.title, + titleConfidence: titleData.confidence, + titleMethod: titleData.method, + titleAnalysis: titleData.analysis, + }); + + successCount++; + + // Log progress every 5 jobs (reduced from 10) + if (successCount % 5 === 0) { + console.log(`✅ Processed ${successCount}/${jobs.length} jobs`); + } + + // Add longer delay between requests to avoid hitting rate limits + await new Promise((resolve) => + setTimeout(resolve, RATE_LIMIT_CONFIG.delayBetweenRequests), + ); + } catch (error) { + console.error( + `❌ Failed to generate title for job ${job.id}:`, + error.message, + ); + errorCount++; + + if (isQuotaError(error)) { + quotaCount++; + } else if (isRateLimitError(error)) { + rateLimitCount++; + } + + // Use fallback for failed jobs + const fallbackData = this.fallbackTitleGeneration(job.description); + results.push({ + ...job, + title: fallbackData.title, + titleConfidence: fallbackData.confidence, + titleMethod: 'fallback', + titleAnalysis: fallbackData.analysis, + }); + } + } + + console.log(`\n📊 AI Title Generation Summary:`); + console.log(` Total jobs: ${jobs.length}`); + console.log(` Successfully processed: ${successCount}`); + console.log(` Quota errors: ${quotaCount}`); + console.log(` Rate limit errors: ${rateLimitCount}`); + console.log( + ` Other errors (using fallback): ${errorCount - quotaCount - rateLimitCount}`, + ); + + return results; + } + + /** + * Extract requirements from job description + * @param {string} description - Job description + * @returns {string} Extracted requirements + */ + static extractRequirements(description) { + const requirementPatterns = [ + /требуется\s+(.+?)(?=\n|$)/i, + /требования?\s*:\s*(.+?)(?=\n|$)/i, + /обязательно\s+(.+?)(?=\n|$)/i, + ]; + + for (const pattern of requirementPatterns) { + const match = description.match(pattern); + if (match) { + return match[1].trim(); + } + } + + return ''; + } + + /** + * Check if description has specific job keywords + * @param {string} description - Job description + * @returns {boolean} + */ + static hasSpecificKeywords(description) { + const specificKeywords = [ + 'повар', + 'официант', + 'грузчик', + 'водитель', + 'продавец', + 'кассир', + 'уборщик', + 'строитель', + 'электрик', + 'сантехник', + 'маляр', + 'курьер', + 'программист', + 'сиделка', + 'няня', + ]; + + return specificKeywords.some((keyword) => + description.toLowerCase().includes(keyword), + ); + } + + /** + * Check if description mentions a location + * @param {string} description - Job description + * @returns {boolean} + */ + static hasLocation(description) { + const locationPatterns = [ + /в\s+([а-яё]+(?:\s+[а-яё]+)*)/i, + /на\s+([а-яё]+(?:\s+[а-яё]+)*)/i, + ]; + + return locationPatterns.some((pattern) => pattern.test(description)); + } + + /** + * Check if description mentions salary + * @param {string} description - Job description + * @returns {boolean} + */ + static hasSalary(description) { + const salaryPattern = /(\d+)\s*(?:шек|₪|ILS)/i; + return salaryPattern.test(description); + } + + /** + * Check if description has language requirements + * @param {string} description - Job description + * @returns {boolean} + */ + static hasLanguageRequirement(description) { + const languageKeywords = [ + 'иврит', + 'ивритом', + 'английский', + 'английским', + 'русский', + 'русским', + ]; + return languageKeywords.some((keyword) => + description.toLowerCase().includes(keyword), + ); + } + + /** + * Check if description mentions experience requirements + * @param {string} description - Job description + * @returns {boolean} + */ + static hasExperienceRequirement(description) { + const experienceKeywords = ['опыт', 'опытный', 'опыт работы']; + return experienceKeywords.some((keyword) => + description.toLowerCase().includes(keyword), + ); + } + + /** + * Update existing jobs in database with AI-generated titles + * @returns {Promise} Update statistics + */ + static async updateDatabaseWithAITitles() { + console.log('🤖 Updating database with AI-generated titles...\n'); + console.log( + `📊 Rate limit config: ${RATE_LIMIT_CONFIG.requestsPerMinute} requests per minute`, + ); + + try { + const jobs = await prisma.job.findMany({ + include: { + city: true, + category: true, + }, + }); + + console.log(`📊 Found ${jobs.length} jobs to update\n`); + + let updatedCount = 0; + let skippedCount = 0; + let errorCount = 0; + let rateLimitCount = 0; + let quotaCount = 0; + + for (const job of jobs) { + try { + const context = { + city: job.city?.name, + salary: job.salary, + requirements: this.extractRequirements(job.description), + }; + + const titleData = await this.generateAITitle( + job.description, + context, + ); + + // Only update if the new title is better + if (titleData.confidence > 0.7 && titleData.title !== job.title) { + await prisma.job.update({ + where: { id: job.id }, + data: { title: titleData.title }, + }); + + console.log( + `✅ Updated: "${job.title}" → "${titleData.title}" (${titleData.method})`, + ); + updatedCount++; + } else { + console.log( + `⏭️ Skipping "${job.title}" - confidence: ${titleData.confidence.toFixed(2)}`, + ); + skippedCount++; + } + + // Add longer delay to avoid rate limiting + await new Promise((resolve) => + setTimeout(resolve, RATE_LIMIT_CONFIG.delayBetweenRequests), + ); + } catch (error) { + console.error(`❌ Failed to update job ${job.id}:`, error.message); + errorCount++; + + if (isQuotaError(error)) { + quotaCount++; + } else if (isRateLimitError(error)) { + rateLimitCount++; + } + } + } + + console.log(`\n📈 AI Update Summary:`); + console.log(` Total jobs processed: ${jobs.length}`); + console.log(` Jobs updated: ${updatedCount}`); + console.log(` Jobs skipped: ${skippedCount}`); + console.log(` Quota errors: ${quotaCount}`); + console.log(` Rate limit errors: ${rateLimitCount}`); + console.log( + ` Other errors: ${errorCount - quotaCount - rateLimitCount}`, + ); + + return { + total: jobs.length, + updated: updatedCount, + skipped: skippedCount, + quotaErrors: quotaCount, + rateLimitErrors: rateLimitCount, + otherErrors: errorCount - quotaCount - rateLimitCount, + }; + } catch (error) { + console.error('❌ Error updating database with AI titles:', error); + throw error; + } + } } -export default AIJobTitleService; \ No newline at end of file +export default AIJobTitleService; diff --git a/apps/api/services/candidateNotificationService.js b/apps/api/services/candidateNotificationService.js index 4c17593..ec8af5e 100644 --- a/apps/api/services/candidateNotificationService.js +++ b/apps/api/services/candidateNotificationService.js @@ -1,7 +1,7 @@ import { PrismaClient } from '@prisma/client'; +import process from 'process'; import { Resend } from 'resend'; import { sendEmail } from '../utils/mailer.js'; -import process from 'process'; const prisma = new PrismaClient(); const resend = new Resend(process.env.RESEND_API_KEY); @@ -10,41 +10,46 @@ const resend = new Resend(process.env.RESEND_API_KEY); * Send 3 latest candidates to a newly subscribed user (only once) */ export async function sendInitialCandidatesToNewSubscriber(subscriber) { - try { - console.log(`📧 Отправляем 3 последних кандидата новому подписчику: ${subscriber.email}`); - - const candidates = await prisma.seeker.findMany({ - where: { isActive: true }, - orderBy: { createdAt: 'desc' }, - take: 3 - }); - - if (candidates.length === 0) { - console.log('📧 Нет доступных кандидатов для отправки'); - return; - } - - const emailContent = generateInitialCandidatesEmail(candidates, subscriber); - const emailSubject = 'Добро пожаловать! Ваши первые кандидаты с WorkNow'; - - try { - await resend.emails.send({ - from: 'WorkNow ', - to: subscriber.email, - subject: emailSubject, - html: emailContent - }); - console.log(`📧 Email с первыми кандидатами отправлен: ${subscriber.email}`); - } catch (resendError) { - console.error('❌ Resend failed, trying Gmail fallback:', resendError); - await sendEmail(subscriber.email, emailSubject, emailContent); - console.log(`📧 Email с первыми кандидатами отправлен через Gmail: ${subscriber.email}`); - } - - } catch (error) { - console.error('❌ Ошибка при отправке первых кандидатов:', error); - throw error; - } + try { + console.log( + `📧 Отправляем 3 последних кандидата новому подписчику: ${subscriber.email}`, + ); + + const candidates = await prisma.seeker.findMany({ + where: { isActive: true }, + orderBy: { createdAt: 'desc' }, + take: 3, + }); + + if (candidates.length === 0) { + console.log('📧 Нет доступных кандидатов для отправки'); + return; + } + + const emailContent = generateInitialCandidatesEmail(candidates, subscriber); + const emailSubject = 'Добро пожаловать! Ваши первые кандидаты с WorkNow'; + + try { + await resend.emails.send({ + from: 'WorkNow ', + to: subscriber.email, + subject: emailSubject, + html: emailContent, + }); + console.log( + `📧 Email с первыми кандидатами отправлен: ${subscriber.email}`, + ); + } catch (resendError) { + console.error('❌ Resend failed, trying Gmail fallback:', resendError); + await sendEmail(subscriber.email, emailSubject, emailContent); + console.log( + `📧 Email с первыми кандидатами отправлен через Gmail: ${subscriber.email}`, + ); + } + } catch (error) { + console.error('❌ Ошибка при отправке первых кандидатов:', error); + throw error; + } } /** @@ -53,182 +58,247 @@ export async function sendInitialCandidatesToNewSubscriber(subscriber) { * This is the SINGLE source of truth for candidate notifications */ export async function checkAndSendNewCandidatesNotification() { - try { - console.log('📧 Проверяем необходимость отправки уведомлений о новых кандидатах...'); - - // Get the current total count of active candidates - const currentCandidateCount = await prisma.seeker.count({ - where: { isActive: true } - }); - - console.log(`📧 Всего активных кандидатов: ${currentCandidateCount}`); - - // Simple approach: only send notifications if we have exactly 3, 6, 9, etc. candidates - // AND the most recent candidate was created recently (within last 5 minutes) - const mostRecentCandidate = await prisma.seeker.findFirst({ - where: { isActive: true }, - orderBy: { createdAt: 'desc' } - }); - - if (!mostRecentCandidate) { - console.log('📧 Нет кандидатов для уведомлений'); - return; - } - - // Check if the most recent candidate was created recently (within last 5 minutes) - const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); - const isRecent = mostRecentCandidate.createdAt > fiveMinutesAgo; - - if (!isRecent) { - console.log('📧 Последний кандидат был добавлен более 5 минут назад, уведомления уже отправлены'); - return; - } - - // Only send notification if total count is divisible by 3 AND candidate is recent - if (currentCandidateCount > 0 && currentCandidateCount % 3 === 0 && isRecent) { - console.log(`📧 Триггер отправки: ${currentCandidateCount} кандидатов (делится на 3) и кандидат недавний`); - - // Get the 3 most recent candidates - const recentCandidates = await prisma.seeker.findMany({ - where: { isActive: true }, - orderBy: { createdAt: 'desc' }, - take: 3 - }); - - if (recentCandidates.length > 0) { - console.log(`📧 Отправляем уведомления о ${recentCandidates.length} новых кандидатах`); - await sendNewCandidatesNotification(recentCandidates); - console.log(`📧 Уведомления отправлены для ${currentCandidateCount} кандидатов`); - } - } else { - console.log(`📧 Триггер не сработал: ${currentCandidateCount} кандидатов, недавний: ${isRecent}`); - } - - } catch (error) { - console.error('❌ Ошибка при проверке триггера рассылки:', error); - } + try { + console.log( + '📧 Проверяем необходимость отправки уведомлений о новых кандидатах...', + ); + + // Get the current total count of active candidates + const currentCandidateCount = await prisma.seeker.count({ + where: { isActive: true }, + }); + + console.log(`📧 Всего активных кандидатов: ${currentCandidateCount}`); + + // Simple approach: only send notifications if we have exactly 3, 6, 9, etc. candidates + // AND the most recent candidate was created recently (within last 5 minutes) + const mostRecentCandidate = await prisma.seeker.findFirst({ + where: { isActive: true }, + orderBy: { createdAt: 'desc' }, + }); + + if (!mostRecentCandidate) { + console.log('📧 Нет кандидатов для уведомлений'); + return; + } + + // Check if the most recent candidate was created recently (within last 5 minutes) + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + const isRecent = mostRecentCandidate.createdAt > fiveMinutesAgo; + + if (!isRecent) { + console.log( + '📧 Последний кандидат был добавлен более 5 минут назад, уведомления уже отправлены', + ); + return; + } + + // Only send notification if total count is divisible by 3 AND candidate is recent + if ( + currentCandidateCount > 0 && + currentCandidateCount % 3 === 0 && + isRecent + ) { + console.log( + `📧 Триггер отправки: ${currentCandidateCount} кандидатов (делится на 3) и кандидат недавний`, + ); + + // Get the 3 most recent candidates + const recentCandidates = await prisma.seeker.findMany({ + where: { isActive: true }, + orderBy: { createdAt: 'desc' }, + take: 3, + }); + + if (recentCandidates.length > 0) { + console.log( + `📧 Отправляем уведомления о ${recentCandidates.length} новых кандидатах`, + ); + await sendNewCandidatesNotification(recentCandidates); + console.log( + `📧 Уведомления отправлены для ${currentCandidateCount} кандидатов`, + ); + } + } else { + console.log( + `📧 Триггер не сработал: ${currentCandidateCount} кандидатов, недавний: ${isRecent}`, + ); + } + } catch (error) { + console.error('❌ Ошибка при проверке триггера рассылки:', error); + } } /** * Send notification about new candidates to all active subscribers */ async function sendNewCandidatesNotification(newCandidates) { - try { - console.log('📧 Отправляем уведомления о новых кандидатах всем подписчикам...'); - - const subscribers = await prisma.newsletterSubscriber.findMany({ - where: { isActive: true } - }); - - if (subscribers.length === 0) { - console.log('📧 Нет активных подписчиков для рассылки'); - return; - } - - console.log(`📧 Отправляем уведомления ${subscribers.length} подписчикам о ${newCandidates.length} новых кандидатах`); - - for (const subscriber of subscribers) { - try { - const filteredCandidates = filterCandidatesByPreferences(newCandidates, subscriber); - - if (filteredCandidates.length > 0) { - const emailContent = generateNewCandidatesNotificationEmail(filteredCandidates, subscriber); - const emailSubject = 'Новые соискатели добавлены на WorkNow'; - - try { - await resend.emails.send({ - from: 'WorkNow ', - to: subscriber.email, - subject: emailSubject, - html: emailContent - }); - console.log(`📧 Уведомление отправлено через Resend: ${subscriber.email}`); - } catch (resendError) { - await sendEmail(subscriber.email, emailSubject, emailContent); - console.log(`📧 Уведомление отправлено через Gmail: ${subscriber.email}`); - } - } - } catch (error) { - console.error(`❌ Ошибка при отправке уведомления подписчику ${subscriber.email}:`, error); - } - } - - console.log('📧 Рассылка уведомлений о новых кандидатах завершена'); - - } catch (error) { - console.error('❌ Ошибка при отправке уведомлений о новых кандидатах:', error); - throw error; - } + try { + console.log( + '📧 Отправляем уведомления о новых кандидатах всем подписчикам...', + ); + + const subscribers = await prisma.newsletterSubscriber.findMany({ + where: { isActive: true }, + }); + + if (subscribers.length === 0) { + console.log('📧 Нет активных подписчиков для рассылки'); + return; + } + + console.log( + `📧 Отправляем уведомления ${subscribers.length} подписчикам о ${newCandidates.length} новых кандидатах`, + ); + + for (const subscriber of subscribers) { + try { + const filteredCandidates = filterCandidatesByPreferences( + newCandidates, + subscriber, + ); + + if (filteredCandidates.length > 0) { + const emailContent = generateNewCandidatesNotificationEmail( + filteredCandidates, + subscriber, + ); + const emailSubject = 'Новые соискатели добавлены на WorkNow'; + + try { + await resend.emails.send({ + from: 'WorkNow ', + to: subscriber.email, + subject: emailSubject, + html: emailContent, + }); + console.log( + `📧 Уведомление отправлено через Resend: ${subscriber.email}`, + ); + } catch (resendError) { + await sendEmail(subscriber.email, emailSubject, emailContent); + console.log( + `📧 Уведомление отправлено через Gmail: ${subscriber.email}`, + ); + } + } + } catch (error) { + console.error( + `❌ Ошибка при отправке уведомления подписчику ${subscriber.email}:`, + error, + ); + } + } + + console.log('📧 Рассылка уведомлений о новых кандидатах завершена'); + } catch (error) { + console.error( + '❌ Ошибка при отправке уведомлений о новых кандидатах:', + error, + ); + throw error; + } } /** * Filter candidates based on subscriber preferences */ function filterCandidatesByPreferences(candidates, subscriber) { - let filteredCandidates = [...candidates]; - - if (subscriber.preferredCities && subscriber.preferredCities.length > 0) { - filteredCandidates = filteredCandidates.filter(candidate => - subscriber.preferredCities.some(city => - candidate.city && candidate.city.toLowerCase().includes(city.toLowerCase()) - ) - ); - } - - if (subscriber.preferredCategories && subscriber.preferredCategories.length > 0) { - filteredCandidates = filteredCandidates.filter(candidate => - subscriber.preferredCategories.some(category => - candidate.category && candidate.category.toLowerCase().includes(category.toLowerCase()) - ) - ); - } - - if (subscriber.preferredEmployment && subscriber.preferredEmployment.length > 0) { - filteredCandidates = filteredCandidates.filter(candidate => - subscriber.preferredEmployment.some(employment => - candidate.employment && candidate.employment.toLowerCase().includes(employment.toLowerCase()) - ) - ); - } - - if (subscriber.preferredLanguages && subscriber.preferredLanguages.length > 0) { - filteredCandidates = filteredCandidates.filter(candidate => - candidate.languages && candidate.languages.some(lang => - subscriber.preferredLanguages.some(prefLang => - lang.toLowerCase().includes(prefLang.toLowerCase()) - ) - ) - ); - } - - if (subscriber.preferredGender) { - filteredCandidates = filteredCandidates.filter(candidate => - candidate.gender && candidate.gender.toLowerCase() === subscriber.preferredGender.toLowerCase() - ); - } - - if (subscriber.preferredDocumentTypes && subscriber.preferredDocumentTypes.length > 0) { - filteredCandidates = filteredCandidates.filter(candidate => - candidate.documents && subscriber.preferredDocumentTypes.some(docType => - candidate.documents.toLowerCase().includes(docType.toLowerCase()) - ) - ); - } - - if (subscriber.onlyDemanded) { - filteredCandidates = filteredCandidates.filter(candidate => candidate.isDemanded === true); - } - - console.log(`📧 Подписчик ${subscriber.email}: ${filteredCandidates.length} кандидатов после фильтрации из ${candidates.length} общих`); - - return filteredCandidates; + let filteredCandidates = [...candidates]; + + if (subscriber.preferredCities && subscriber.preferredCities.length > 0) { + filteredCandidates = filteredCandidates.filter((candidate) => + subscriber.preferredCities.some( + (city) => + candidate.city && + candidate.city.toLowerCase().includes(city.toLowerCase()), + ), + ); + } + + if ( + subscriber.preferredCategories && + subscriber.preferredCategories.length > 0 + ) { + filteredCandidates = filteredCandidates.filter((candidate) => + subscriber.preferredCategories.some( + (category) => + candidate.category && + candidate.category.toLowerCase().includes(category.toLowerCase()), + ), + ); + } + + if ( + subscriber.preferredEmployment && + subscriber.preferredEmployment.length > 0 + ) { + filteredCandidates = filteredCandidates.filter((candidate) => + subscriber.preferredEmployment.some( + (employment) => + candidate.employment && + candidate.employment.toLowerCase().includes(employment.toLowerCase()), + ), + ); + } + + if ( + subscriber.preferredLanguages && + subscriber.preferredLanguages.length > 0 + ) { + filteredCandidates = filteredCandidates.filter( + (candidate) => + candidate.languages && + candidate.languages.some((lang) => + subscriber.preferredLanguages.some((prefLang) => + lang.toLowerCase().includes(prefLang.toLowerCase()), + ), + ), + ); + } + + if (subscriber.preferredGender) { + filteredCandidates = filteredCandidates.filter( + (candidate) => + candidate.gender && + candidate.gender.toLowerCase() === + subscriber.preferredGender.toLowerCase(), + ); + } + + if ( + subscriber.preferredDocumentTypes && + subscriber.preferredDocumentTypes.length > 0 + ) { + filteredCandidates = filteredCandidates.filter( + (candidate) => + candidate.documents && + subscriber.preferredDocumentTypes.some((docType) => + candidate.documents.toLowerCase().includes(docType.toLowerCase()), + ), + ); + } + + if (subscriber.onlyDemanded) { + filteredCandidates = filteredCandidates.filter( + (candidate) => candidate.isDemanded === true, + ); + } + + console.log( + `📧 Подписчик ${subscriber.email}: ${filteredCandidates.length} кандидатов после фильтрации из ${candidates.length} общих`, + ); + + return filteredCandidates; } /** * Generate email content for initial subscription (first 3 candidates) */ function generateInitialCandidatesEmail(candidates, subscriber) { - const candidatesHtml = candidates.map(candidate => ` + const candidatesHtml = candidates + .map( + (candidate) => `

Соискатель: ${candidate.name} ${candidate.gender ? `${candidate.gender}` : ''} @@ -249,13 +319,16 @@ function generateInitialCandidatesEmail(candidates, subscriber) { Объявление: ${candidate.description || 'Описание не указано'}

- `).join(''); + `, + ) + .join(''); - const subscriberName = subscriber.firstName && subscriber.lastName - ? `${subscriber.firstName} ${subscriber.lastName}` - : subscriber.firstName || subscriber.lastName || 'пользователь'; + const subscriberName = + subscriber.firstName && subscriber.lastName + ? `${subscriber.firstName} ${subscriber.lastName}` + : subscriber.firstName || subscriber.lastName || 'пользователь'; - return ` + return ` @@ -306,7 +379,9 @@ function generateInitialCandidatesEmail(candidates, subscriber) { * Generate email content for new candidates notification */ function generateNewCandidatesNotificationEmail(candidates, subscriber) { - const candidatesHtml = candidates.map(candidate => ` + const candidatesHtml = candidates + .map( + (candidate) => `

Новый соискатель: ${candidate.name} ${candidate.gender ? `${candidate.gender}` : ''} @@ -327,13 +402,16 @@ function generateNewCandidatesNotificationEmail(candidates, subscriber) { Объявление: ${candidate.description || 'Описание не указано'}

- `).join(''); + `, + ) + .join(''); - const subscriberName = subscriber.firstName && subscriber.lastName - ? `${subscriber.firstName} ${subscriber.lastName}` - : subscriber.firstName || subscriber.lastName || 'пользователь'; + const subscriberName = + subscriber.firstName && subscriber.lastName + ? `${subscriber.firstName} ${subscriber.lastName}` + : subscriber.firstName || subscriber.lastName || 'пользователь'; - return ` + return ` diff --git a/apps/api/services/cityService.js b/apps/api/services/cityService.js index 77d68e6..4427143 100755 --- a/apps/api/services/cityService.js +++ b/apps/api/services/cityService.js @@ -3,22 +3,22 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); export const getCitiesService = async (lang = 'ru') => { - try { - const cities = await prisma.city.findMany({ - include: { - translations: { - where: { lang } - } - } - }); - // Возвращаем только нужный перевод для каждого города - const result = cities.map(city => ({ - id: city.id, - name: city.translations[0]?.name || city.name - })); - return { cities: result }; - } catch (error) { - console.error("❌ Ошибка при получении городов:", error); - return { error: "Ошибка сервера при получении городов" }; - } + try { + const cities = await prisma.city.findMany({ + include: { + translations: { + where: { lang }, + }, + }, + }); + // Возвращаем только нужный перевод для каждого города + const result = cities.map((city) => ({ + id: city.id, + name: city.translations[0]?.name || city.name, + })); + return { cities: result }; + } catch (error) { + console.error('❌ Ошибка при получении городов:', error); + return { error: 'Ошибка сервера при получении городов' }; + } }; diff --git a/apps/api/services/createJobService.js b/apps/api/services/createJobService.js index d37af5e..335dd98 100644 --- a/apps/api/services/createJobService.js +++ b/apps/api/services/createJobService.js @@ -1,30 +1,32 @@ import { PrismaClient } from '@prisma/client'; + const prisma = new PrismaClient(); export const createJobService = async (jobData) => { - const { title, description, salary, cityId, categoryId, userId, phone } = jobData; + const { title, description, salary, cityId, categoryId, userId, phone } = + jobData; - try { - const job = await prisma.job.create({ - data: { - title, - description, - salary: parseInt(salary), - cityId: parseInt(cityId), - categoryId: parseInt(categoryId), - userId, - phone, - status: 'ACTIVE', - }, - include: { - city: true, - category: true, - }, - }); + try { + const job = await prisma.job.create({ + data: { + title, + description, + salary: parseInt(salary), + cityId: parseInt(cityId), + categoryId: parseInt(categoryId), + userId, + phone, + status: 'ACTIVE', + }, + include: { + city: true, + category: true, + }, + }); - return job; - } catch (error) { - console.error('Error creating job:', error); - throw new Error('Failed to create job'); - } -}; \ No newline at end of file + return job; + } catch (error) { + console.error('Error creating job:', error); + throw new Error('Failed to create job'); + } +}; diff --git a/apps/api/services/editFormService.js b/apps/api/services/editFormService.js index 3f80ebb..0a6cc09 100755 --- a/apps/api/services/editFormService.js +++ b/apps/api/services/editFormService.js @@ -4,58 +4,82 @@ import { sendUpdatedJobListToTelegram } from '../utils/telegram.js'; const prisma = new PrismaClient(); -export const updateJobService = async (id, { title, salary, cityId, phone, description, categoryId, shuttle, meals, imageUrl, userId }) => { - let errors = []; - if (containsBadWords(title)) errors.push("Заголовок содержит нецензурные слова."); - if (containsBadWords(description)) errors.push("Описание содержит нецензурные слова."); - if (containsLinks(title)) errors.push("Заголовок содержит запрещенные ссылки."); - if (containsLinks(description)) errors.push("Описание содержит запрещенные ссылки."); - if (errors.length > 0) return { errors }; +export const updateJobService = async ( + id, + { + title, + salary, + cityId, + phone, + description, + categoryId, + shuttle, + meals, + imageUrl, + userId, + }, +) => { + let errors = []; + if (containsBadWords(title)) + errors.push('Заголовок содержит нецензурные слова.'); + if (containsBadWords(description)) + errors.push('Описание содержит нецензурные слова.'); + if (containsLinks(title)) + errors.push('Заголовок содержит запрещенные ссылки.'); + if (containsLinks(description)) + errors.push('Описание содержит запрещенные ссылки.'); + if (errors.length > 0) return { errors }; - try { - const existingJob = await prisma.job.findUnique({ where: { id: parseInt(id) }, include: { user: true } }); - if (!existingJob) return { error: 'Объявление не найдено' }; + try { + const existingJob = await prisma.job.findUnique({ + where: { id: parseInt(id) }, + include: { user: true }, + }); + if (!existingJob) return { error: 'Объявление не найдено' }; - // Check if the authenticated user owns this job - if (existingJob.user.clerkUserId !== userId) { - return { error: 'У вас нет прав для редактирования этого объявления' }; - } + // Check if the authenticated user owns this job + if (existingJob.user.clerkUserId !== userId) { + return { error: 'У вас нет прав для редактирования этого объявления' }; + } - console.log('🔍 updateJobService - Updating job with imageUrl:', imageUrl); - console.log('🔍 updateJobService - Full update data:', { - title, - salary, - phone, - description, - imageUrl, - cityId, - categoryId, - shuttle, - meals - }); + console.log('🔍 updateJobService - Updating job with imageUrl:', imageUrl); + console.log('🔍 updateJobService - Full update data:', { + title, + salary, + phone, + description, + imageUrl, + cityId, + categoryId, + shuttle, + meals, + }); - const updatedJob = await prisma.job.update({ - where: { id: parseInt(id) }, - data: { - title, - salary, - phone, - description, - imageUrl, // Add imageUrl to the update data - city: { connect: { id: parseInt(cityId) } }, - category: { connect: { id: parseInt(categoryId) } }, - shuttle, - meals - }, - include: { city: true, user: true, category: true }, - }); + const updatedJob = await prisma.job.update({ + where: { id: parseInt(id) }, + data: { + title, + salary, + phone, + description, + imageUrl, // Add imageUrl to the update data + city: { connect: { id: parseInt(cityId) } }, + category: { connect: { id: parseInt(categoryId) } }, + shuttle, + meals, + }, + include: { city: true, user: true, category: true }, + }); - if (updatedJob.user.isPremium) { - const userJobs = await prisma.job.findMany({ where: { userId: updatedJob.user.id }, include: { city: true } }); - await sendUpdatedJobListToTelegram(updatedJob.user, userJobs); - } - return { updatedJob }; - } catch (error) { - return { error: 'Ошибка обновления объявления', details: error.message }; - } -}; \ No newline at end of file + if (updatedJob.user.isPremium) { + const userJobs = await prisma.job.findMany({ + where: { userId: updatedJob.user.id }, + include: { city: true }, + }); + await sendUpdatedJobListToTelegram(updatedJob.user, userJobs); + } + return { updatedJob }; + } catch (error) { + return { error: 'Ошибка обновления объявления', details: error.message }; + } +}; diff --git a/apps/api/services/getJobById.js b/apps/api/services/getJobById.js index 9251357..9aa5a45 100644 --- a/apps/api/services/getJobById.js +++ b/apps/api/services/getJobById.js @@ -3,43 +3,42 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); export const getJobByIdService = async (id) => { - try { + try { + if (!id || isNaN(id)) { + throw new Error('ID вакансии не передан или имеет неверный формат'); + } - if (!id || isNaN(id)) { - throw new Error("ID вакансии не передан или имеет неверный формат"); - } + const job = await prisma.job.findUnique({ + where: { id: Number(id) }, // Преобразуем id в число + include: { + city: true, + category: true, + user: { + select: { + id: true, + isPremium: true, + firstName: true, + lastName: true, + clerkUserId: true, + }, + }, + }, + }); - const job = await prisma.job.findUnique({ - where: { id: Number(id) }, // Преобразуем id в число - include: { - city: true, - category: true, - user: { - select: { - id: true, - isPremium: true, - firstName: true, - lastName: true, - clerkUserId: true - } - }, - }, - }); + if (!job) { + return { error: 'Объявление не найдено' }; + } - if (!job) { - return { error: "Объявление не найдено" }; - } + console.log('🔍 getJobByIdService - Job with imageUrl:', { + id: job.id, + title: job.title, + imageUrl: job.imageUrl, + hasImageUrl: !!job.imageUrl, + }); - console.log('🔍 getJobByIdService - Job with imageUrl:', { - id: job.id, - title: job.title, - imageUrl: job.imageUrl, - hasImageUrl: !!job.imageUrl - }); - - return { job }; - } catch (error) { - console.error("Ошибка в getJobByIdService:", error); - return { error: "Ошибка получения объявления", details: error.message }; - } + return { job }; + } catch (error) { + console.error('Ошибка в getJobByIdService:', error); + return { error: 'Ошибка получения объявления', details: error.message }; + } }; diff --git a/apps/api/services/getJobService.js b/apps/api/services/getJobService.js index 7cb6cff..bf66f70 100755 --- a/apps/api/services/getJobService.js +++ b/apps/api/services/getJobService.js @@ -3,36 +3,36 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); export const getJobByIdService = async (id) => { - try { - console.log('🔍 getJobByIdService - Fetching job with ID:', id); - - const job = await prisma.job.findUnique({ - where: { id: parseInt(id) }, - include: { - city: true, - category: true, - user: { - select: { - id: true, - isPremium: true, - firstName: true, - lastName: true, - clerkUserId: true - } - }, - }, - }); + try { + console.log('🔍 getJobByIdService - Fetching job with ID:', id); - console.log('🔍 getJobByIdService - Job found:', { - id: job?.id, - title: job?.title, - imageUrl: job?.imageUrl, - hasImageUrl: !!job?.imageUrl - }); + const job = await prisma.job.findUnique({ + where: { id: parseInt(id) }, + include: { + city: true, + category: true, + user: { + select: { + id: true, + isPremium: true, + firstName: true, + lastName: true, + clerkUserId: true, + }, + }, + }, + }); - return job; - } catch (error) { - console.error('Ошибка в getJobByIdService:', error.message); - throw new Error('Ошибка получения объявления'); - } + console.log('🔍 getJobByIdService - Job found:', { + id: job?.id, + title: job?.title, + imageUrl: job?.imageUrl, + hasImageUrl: !!job?.imageUrl, + }); + + return job; + } catch (error) { + console.error('Ошибка в getJobByIdService:', error.message); + throw new Error('Ошибка получения объявления'); + } }; diff --git a/apps/api/services/getUserByClerkService.js b/apps/api/services/getUserByClerkService.js index 319c1cb..e639536 100755 --- a/apps/api/services/getUserByClerkService.js +++ b/apps/api/services/getUserByClerkService.js @@ -3,12 +3,12 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); export const getUserByClerkIdService = async (clerkUserId) => { - try { - return await prisma.user.findUnique({ - where: { clerkUserId }, - }); - } catch (error) { - console.error('Ошибка в getUserByClerkIdService:', error.message); - throw new Error('Ошибка получения данных пользователя'); - } + try { + return await prisma.user.findUnique({ + where: { clerkUserId }, + }); + } catch (error) { + console.error('Ошибка в getUserByClerkIdService:', error.message); + throw new Error('Ошибка получения данных пользователя'); + } }; diff --git a/apps/api/services/imageModerationService.js b/apps/api/services/imageModerationService.js index 8600120..c171de8 100644 --- a/apps/api/services/imageModerationService.js +++ b/apps/api/services/imageModerationService.js @@ -5,9 +5,9 @@ dotenv.config(); // Configure AWS Rekognition (use supported region for Rekognition) const rekognition = new AWS.Rekognition({ - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, - region: process.env.AWS_REKOGNITION_REGION || 'us-east-1' // Rekognition is available in us-east-1, us-west-2, eu-west-1 + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + region: process.env.AWS_REKOGNITION_REGION || 'us-east-1', // Rekognition is available in us-east-1, us-west-2, eu-west-1 }); /** @@ -16,108 +16,110 @@ const rekognition = new AWS.Rekognition({ * @returns {Promise} Moderation result with status and details */ export const moderateImage = async (imageBuffer) => { - try { - // Starting content moderation - - // Prepare parameters for Rekognition - const params = { - Image: { - Bytes: imageBuffer - }, - MinConfidence: 95 // Very high confidence threshold for very strict moderation - }; - - // Analyzing image content - - // Detect inappropriate content - const [moderationResult, labelResult] = await Promise.all([ - rekognition.detectModerationLabels(params).promise(), - rekognition.detectLabels(params).promise() - ]); - - // Analysis completed - - // Check for inappropriate content - only the most serious violations - const inappropriateLabels = [ - 'Explicit Nudity', - 'Violence', - 'Hate Symbols' - ]; - - const detectedInappropriate = moderationResult.ModerationLabels?.filter(label => - inappropriateLabels.includes(label.Name) && label.Confidence >= 95 - ) || []; - - // Additional checks for specific content types - const detectedLabels = labelResult.Labels?.map(label => label.Name) || []; - - // Check for potentially inappropriate labels - only the most serious - const potentiallyInappropriate = [ - 'Weapon', - 'Gun', - 'Knife', - 'Adult', - 'Nude' - ]; - - const detectedPotentiallyInappropriate = detectedLabels.filter(label => - potentiallyInappropriate.some(inappropriate => - label.toLowerCase().includes(inappropriate.toLowerCase()) - ) - ); - - const isInappropriate = detectedInappropriate.length > 0 || detectedPotentiallyInappropriate.length > 0; - - const result = { - isApproved: !isInappropriate, - confidence: Math.max( - ...detectedInappropriate.map(label => label.Confidence), - ...detectedPotentiallyInappropriate.map(() => 98), // Very high confidence for potential issues - 0 - ), - detectedIssues: { - moderationLabels: detectedInappropriate, - potentiallyInappropriateLabels: detectedPotentiallyInappropriate - }, - analysis: { - moderationLabels: moderationResult.ModerationLabels || [], - detectedLabels: detectedLabels, - moderationConfidence: detectedInappropriate.length > 0 - ? Math.max(...detectedInappropriate.map(label => label.Confidence)) - : 0 - } - }; - - if (isInappropriate) { - // Content rejected due to inappropriate content - } else { - // Content approved - } - - return result; - - } catch (error) { - console.error('❌ Image Moderation - Error during moderation:', error); - - // If moderation fails, we can either: - // 1. Reject the image (safer approach) - // 2. Allow the image (less restrictive) - // For now, we'll reject if moderation fails - return { - isApproved: false, - confidence: 0, - error: error.message, - detectedIssues: { - moderationLabels: [], - potentiallyInappropriateLabels: [] - }, - analysis: { - moderationLabels: [], - detectedLabels: [], - moderationConfidence: 0 - } - }; - } + try { + // Starting content moderation + + // Prepare parameters for Rekognition + const params = { + Image: { + Bytes: imageBuffer, + }, + MinConfidence: 95, // Very high confidence threshold for very strict moderation + }; + + // Analyzing image content + + // Detect inappropriate content + const [moderationResult, labelResult] = await Promise.all([ + rekognition.detectModerationLabels(params).promise(), + rekognition.detectLabels(params).promise(), + ]); + + // Analysis completed + + // Check for inappropriate content - only the most serious violations + const inappropriateLabels = ['Explicit Nudity', 'Violence', 'Hate Symbols']; + + const detectedInappropriate = + moderationResult.ModerationLabels?.filter( + (label) => + inappropriateLabels.includes(label.Name) && label.Confidence >= 95, + ) || []; + + // Additional checks for specific content types + const detectedLabels = labelResult.Labels?.map((label) => label.Name) || []; + + // Check for potentially inappropriate labels - only the most serious + const potentiallyInappropriate = [ + 'Weapon', + 'Gun', + 'Knife', + 'Adult', + 'Nude', + ]; + + const detectedPotentiallyInappropriate = detectedLabels.filter((label) => + potentiallyInappropriate.some((inappropriate) => + label.toLowerCase().includes(inappropriate.toLowerCase()), + ), + ); + + const isInappropriate = + detectedInappropriate.length > 0 || + detectedPotentiallyInappropriate.length > 0; + + const result = { + isApproved: !isInappropriate, + confidence: Math.max( + ...detectedInappropriate.map((label) => label.Confidence), + ...detectedPotentiallyInappropriate.map(() => 98), // Very high confidence for potential issues + 0, + ), + detectedIssues: { + moderationLabels: detectedInappropriate, + potentiallyInappropriateLabels: detectedPotentiallyInappropriate, + }, + analysis: { + moderationLabels: moderationResult.ModerationLabels || [], + detectedLabels: detectedLabels, + moderationConfidence: + detectedInappropriate.length > 0 + ? Math.max( + ...detectedInappropriate.map((label) => label.Confidence), + ) + : 0, + }, + }; + + if (isInappropriate) { + // Content rejected due to inappropriate content + } else { + // Content approved + } + + return result; + } catch (error) { + console.error('❌ Image Moderation - Error during moderation:', error); + + // If moderation fails, we can either: + // 1. Reject the image (safer approach) + // 2. Allow the image (less restrictive) + // For now, we'll reject if moderation fails + return { + isApproved: false, + confidence: 0, + error: error.message, + detectedIssues: { + moderationLabels: [], + potentiallyInappropriateLabels: [], + }, + analysis: { + moderationLabels: [], + detectedLabels: [], + moderationConfidence: 0, + }, + }; + } }; /** @@ -125,44 +127,48 @@ export const moderateImage = async (imageBuffer) => { * @returns {Object} Configuration status */ export const validateRekognitionConfig = () => { - const requiredEnvVars = [ - 'AWS_ACCESS_KEY_ID', - 'AWS_SECRET_ACCESS_KEY' - ]; - - const missingVars = requiredEnvVars.filter(varName => !process.env[varName]); - - if (missingVars.length > 0) { - console.error('❌ Missing Rekognition environment variables:', missingVars); - return { - isValid: false, - missingVars, - message: `Missing Rekognition environment variables: ${missingVars.join(', ')}` - }; - } - - // Check if Rekognition region is supported - const rekognitionRegion = process.env.AWS_REKOGNITION_REGION || 'us-east-1'; - const supportedRegions = ['us-east-1', 'us-west-2', 'eu-west-1', 'ap-southeast-2']; - - if (!supportedRegions.includes(rekognitionRegion)) { - console.error('❌ Rekognition not available in region:', rekognitionRegion); - console.error('💡 Supported regions:', supportedRegions.join(', ')); - return { - isValid: false, - missingVars: [], - message: `Rekognition not available in region: ${rekognitionRegion}. Supported regions: ${supportedRegions.join(', ')}` - }; - } - - return { - isValid: true, - missingVars: [], - message: 'Rekognition configuration is valid' - }; + const requiredEnvVars = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY']; + + const missingVars = requiredEnvVars.filter( + (varName) => !process.env[varName], + ); + + if (missingVars.length > 0) { + console.error('❌ Missing Rekognition environment variables:', missingVars); + return { + isValid: false, + missingVars, + message: `Missing Rekognition environment variables: ${missingVars.join(', ')}`, + }; + } + + // Check if Rekognition region is supported + const rekognitionRegion = process.env.AWS_REKOGNITION_REGION || 'us-east-1'; + const supportedRegions = [ + 'us-east-1', + 'us-west-2', + 'eu-west-1', + 'ap-southeast-2', + ]; + + if (!supportedRegions.includes(rekognitionRegion)) { + console.error('❌ Rekognition not available in region:', rekognitionRegion); + console.error('💡 Supported regions:', supportedRegions.join(', ')); + return { + isValid: false, + missingVars: [], + message: `Rekognition not available in region: ${rekognitionRegion}. Supported regions: ${supportedRegions.join(', ')}`, + }; + } + + return { + isValid: true, + missingVars: [], + message: 'Rekognition configuration is valid', + }; }; export default { - moderateImage, - validateRekognitionConfig -}; \ No newline at end of file + moderateImage, + validateRekognitionConfig, +}; diff --git a/apps/api/services/jobBoostService.js b/apps/api/services/jobBoostService.js index 04c2675..3aef0d2 100755 --- a/apps/api/services/jobBoostService.js +++ b/apps/api/services/jobBoostService.js @@ -4,26 +4,36 @@ const prisma = new PrismaClient(); const ONE_DAY = 24 * 60 * 60 * 1000; export const boostJobService = async (id) => { - try { - const job = await prisma.job.findUnique({ where: { id: parseInt(id) }, include: { user: true } }); - if (!job) return { error: 'Объявление не найдено' }; - if (!job.user) return { error: 'Пользователь не найден' }; + try { + const job = await prisma.job.findUnique({ + where: { id: parseInt(id) }, + include: { user: true }, + }); + if (!job) return { error: 'Объявление не найдено' }; + if (!job.user) return { error: 'Пользователь не найден' }; - const now = new Date(); - if (job.boostedAt) { - const lastBoostTime = new Date(job.boostedAt); - const timeSinceBoost = now - lastBoostTime; - if (timeSinceBoost < ONE_DAY) { - const timeLeft = ONE_DAY - timeSinceBoost; - const hoursLeft = Math.floor(timeLeft / (1000 * 60 * 60)); - const minutesLeft = Math.floor((timeLeft % (1000 * 60 * 60)) / (1000 * 60)); - return { error: `Вы сможете поднять вакансию через ${hoursLeft} ч ${minutesLeft} м.` }; - } - } + const now = new Date(); + if (job.boostedAt) { + const lastBoostTime = new Date(job.boostedAt); + const timeSinceBoost = now - lastBoostTime; + if (timeSinceBoost < ONE_DAY) { + const timeLeft = ONE_DAY - timeSinceBoost; + const hoursLeft = Math.floor(timeLeft / (1000 * 60 * 60)); + const minutesLeft = Math.floor( + (timeLeft % (1000 * 60 * 60)) / (1000 * 60), + ); + return { + error: `Вы сможете поднять вакансию через ${hoursLeft} ч ${minutesLeft} м.`, + }; + } + } - const boostedJob = await prisma.job.update({ where: { id: parseInt(id) }, data: { boostedAt: now } }); - return { boostedJob }; - } catch (error) { - return { error: 'Ошибка поднятия вакансии', details: error.message }; - } + const boostedJob = await prisma.job.update({ + where: { id: parseInt(id) }, + data: { boostedAt: now }, + }); + return { boostedJob }; + } catch (error) { + return { error: 'Ошибка поднятия вакансии', details: error.message }; + } }; diff --git a/apps/api/services/jobCreateService.js b/apps/api/services/jobCreateService.js index eca7965..f973671 100755 --- a/apps/api/services/jobCreateService.js +++ b/apps/api/services/jobCreateService.js @@ -1,104 +1,128 @@ -import stringSimilarity from "string-similarity"; +import { PrismaClient } from '@prisma/client'; +import stringSimilarity from 'string-similarity'; import { containsBadWords, containsLinks } from '../middlewares/validation.js'; import { sendNewJobNotificationToTelegram } from '../utils/telegram.js'; -import { PrismaClient } from "@prisma/client"; -const prisma = new PrismaClient() +const prisma = new PrismaClient(); const MAX_JOBS_FREE_USER = 5; const MAX_JOBS_PREMIUM_USER = 10; -export const createJobService = async ({ title, salary, cityId, categoryId, phone, description, userId, shuttle, meals, imageUrl }) => { - let errors = []; - - // Валидация на запрещенные слова и ссылки - if (containsBadWords(title)) errors.push("Заголовок содержит нецензурные слова."); - if (containsBadWords(description)) errors.push("Описание содержит нецензурные слова."); - if (containsLinks(title)) errors.push("Заголовок содержит запрещенные ссылки."); - if (containsLinks(description)) errors.push("Описание содержит запрещенные ссылки."); - - if (errors.length > 0) return { errors }; - - // Поиск пользователя - const existingUser = await prisma.user.findUnique({ - where: { clerkUserId: userId }, - include: { jobs: { orderBy: { createdAt: 'desc' }, take: 1 } }, - }); - - if (!existingUser) return { error: 'Пользователь не найден' }; - - // Проверка на дублирование вакансий - const existingJobs = await prisma.job.findMany({ - where: { userId: existingUser.id }, - select: { title: true, description: true } - }); - - const isDuplicate = existingJobs.some(job => - stringSimilarity.compareTwoStrings(job.title, title) > 0.9 && - stringSimilarity.compareTwoStrings(job.description, description) > 0.9 - ); - - if (isDuplicate) return { error: "Ваше объявление похоже на уже существующее. Измените заголовок или описание." }; - - // Проверка на количество объявлений в зависимости от статуса подписки - const jobCount = await prisma.job.count({ where: { userId: existingUser.id } }); - - // Определяем лимит в зависимости от статуса подписки - const isPremium = existingUser.isPremium || existingUser.premiumDeluxe; - const maxJobs = isPremium ? MAX_JOBS_PREMIUM_USER : MAX_JOBS_FREE_USER; - - if (jobCount >= maxJobs) { - if (isPremium) { - return { error: `Вы уже разместили ${MAX_JOBS_PREMIUM_USER} объявлений.` }; - } else { - return { - error: `Вы уже разместили ${MAX_JOBS_FREE_USER} объявлений. Для размещения большего количества объявлений перейдите на Premium тариф.`, - upgradeRequired: true - }; - } - } - - console.log('🔍 createJobService - Creating job with imageUrl:', imageUrl); - console.log('🔍 createJobService - Full data object:', { - title, - salary, - phone, - description, - shuttle, - meals, - imageUrl, - cityId, - categoryId, - userId - }); - - // Создание новой вакансии - const job = await prisma.job.create({ - data: { - title, - salary, - phone, - description, - shuttle, - meals, - imageUrl, - city: { connect: { id: parseInt(cityId) } }, - category: { connect: { id: parseInt(categoryId) } }, - user: { connect: { id: existingUser.id } } - }, - include: { city: true, user: true, category: true }, - }); - - console.log('🔍 createJobService - Job created successfully:', { - id: job.id, - title: job.title, - imageUrl: job.imageUrl - }); - - // Если пользователь премиум — отправляем уведомление в Telegram - if (existingUser.isPremium) { - await sendNewJobNotificationToTelegram(existingUser, job); - } - - return { job }; +export const createJobService = async ({ + title, + salary, + cityId, + categoryId, + phone, + description, + userId, + shuttle, + meals, + imageUrl, +}) => { + let errors = []; + + // Валидация на запрещенные слова и ссылки + if (containsBadWords(title)) + errors.push('Заголовок содержит нецензурные слова.'); + if (containsBadWords(description)) + errors.push('Описание содержит нецензурные слова.'); + if (containsLinks(title)) + errors.push('Заголовок содержит запрещенные ссылки.'); + if (containsLinks(description)) + errors.push('Описание содержит запрещенные ссылки.'); + + if (errors.length > 0) return { errors }; + + // Поиск пользователя + const existingUser = await prisma.user.findUnique({ + where: { clerkUserId: userId }, + include: { jobs: { orderBy: { createdAt: 'desc' }, take: 1 } }, + }); + + if (!existingUser) return { error: 'Пользователь не найден' }; + + // Проверка на дублирование вакансий + const existingJobs = await prisma.job.findMany({ + where: { userId: existingUser.id }, + select: { title: true, description: true }, + }); + + const isDuplicate = existingJobs.some( + (job) => + stringSimilarity.compareTwoStrings(job.title, title) > 0.9 && + stringSimilarity.compareTwoStrings(job.description, description) > 0.9, + ); + + if (isDuplicate) + return { + error: + 'Ваше объявление похоже на уже существующее. Измените заголовок или описание.', + }; + + // Проверка на количество объявлений в зависимости от статуса подписки + const jobCount = await prisma.job.count({ + where: { userId: existingUser.id }, + }); + + // Определяем лимит в зависимости от статуса подписки + const isPremium = existingUser.isPremium || existingUser.premiumDeluxe; + const maxJobs = isPremium ? MAX_JOBS_PREMIUM_USER : MAX_JOBS_FREE_USER; + + if (jobCount >= maxJobs) { + if (isPremium) { + return { + error: `Вы уже разместили ${MAX_JOBS_PREMIUM_USER} объявлений.`, + }; + } else { + return { + error: `Вы уже разместили ${MAX_JOBS_FREE_USER} объявлений. Для размещения большего количества объявлений перейдите на Premium тариф.`, + upgradeRequired: true, + }; + } + } + + console.log('🔍 createJobService - Creating job with imageUrl:', imageUrl); + console.log('🔍 createJobService - Full data object:', { + title, + salary, + phone, + description, + shuttle, + meals, + imageUrl, + cityId, + categoryId, + userId, + }); + + // Создание новой вакансии + const job = await prisma.job.create({ + data: { + title, + salary, + phone, + description, + shuttle, + meals, + imageUrl, + city: { connect: { id: parseInt(cityId) } }, + category: { connect: { id: parseInt(categoryId) } }, + user: { connect: { id: existingUser.id } }, + }, + include: { city: true, user: true, category: true }, + }); + + console.log('🔍 createJobService - Job created successfully:', { + id: job.id, + title: job.title, + imageUrl: job.imageUrl, + }); + + // Если пользователь премиум — отправляем уведомление в Telegram + if (existingUser.isPremium) { + await sendNewJobNotificationToTelegram(existingUser, job); + } + + return { job }; }; diff --git a/apps/api/services/jobDeleteService.js b/apps/api/services/jobDeleteService.js index 82a84f8..edaae13 100755 --- a/apps/api/services/jobDeleteService.js +++ b/apps/api/services/jobDeleteService.js @@ -1,48 +1,56 @@ import { PrismaClient } from '@prisma/client'; -import { sendUpdatedJobListToTelegram } from '../utils/telegram.js'; import { deleteFromS3 } from '../utils/s3Upload.js'; +import { sendUpdatedJobListToTelegram } from '../utils/telegram.js'; const prisma = new PrismaClient(); export const deleteJobService = async (id, userId) => { - try { - const job = await prisma.job.findUnique({ - where: { id: parseInt(id) }, - include: { user: true }, - }); - if (!job) return { error: 'Объявление не найдено' }; + try { + const job = await prisma.job.findUnique({ + where: { id: parseInt(id) }, + include: { user: true }, + }); + if (!job) return { error: 'Объявление не найдено' }; - // Check if the authenticated user owns this job - if (job.user.clerkUserId !== userId) { - return { error: 'У вас нет прав для удаления этого объявления' }; - } + // Check if the authenticated user owns this job + if (job.user.clerkUserId !== userId) { + return { error: 'У вас нет прав для удаления этого объявления' }; + } - // Delete image from S3 if it exists - if (job.imageUrl) { - try { - // Deleting image from S3 - const imageDeleted = await deleteFromS3(job.imageUrl); - if (imageDeleted) { - // Image deleted from S3 successfully - } else { - console.warn('⚠️ deleteJobService - Failed to delete image from S3, but continuing with job deletion'); - } - } catch (imageError) { - console.error('❌ deleteJobService - Error deleting image from S3:', imageError); - // Continue with job deletion even if image deletion fails - } - } + // Delete image from S3 if it exists + if (job.imageUrl) { + try { + // Deleting image from S3 + const imageDeleted = await deleteFromS3(job.imageUrl); + if (imageDeleted) { + // Image deleted from S3 successfully + } else { + console.warn( + '⚠️ deleteJobService - Failed to delete image from S3, but continuing with job deletion', + ); + } + } catch (imageError) { + console.error( + '❌ deleteJobService - Error deleting image from S3:', + imageError, + ); + // Continue with job deletion even if image deletion fails + } + } - // Delete the job from database - await prisma.job.delete({ where: { id: parseInt(id) } }); + // Delete the job from database + await prisma.job.delete({ where: { id: parseInt(id) } }); - if (job.user.isPremium) { - const userJobs = await prisma.job.findMany({ where: { userId: job.user.id }, include: { city: true } }); - await sendUpdatedJobListToTelegram(job.user, userJobs); - } + if (job.user.isPremium) { + const userJobs = await prisma.job.findMany({ + where: { userId: job.user.id }, + include: { city: true }, + }); + await sendUpdatedJobListToTelegram(job.user, userJobs); + } - return {}; - } catch (error) { - return { error: 'Ошибка удаления объявления', details: error.message }; - } -}; \ No newline at end of file + return {}; + } catch (error) { + return { error: 'Ошибка удаления объявления', details: error.message }; + } +}; diff --git a/apps/api/services/jobService.js b/apps/api/services/jobService.js index e7d3114..b9fe241 100755 --- a/apps/api/services/jobService.js +++ b/apps/api/services/jobService.js @@ -4,143 +4,151 @@ import redisService from './redisService.js'; const prisma = new PrismaClient(); export const getJobsService = async (filters = {}) => { - const { category, city, salary, shuttle, meals, page = 1, limit = 20 } = filters; - - try { - // Try to get from cache first - // const cacheKey = `jobs:${category || 'all'}:${city || 'all'}:${salary || 'all'}:${shuttle || 'all'}:${meals || 'all'}:${page}:${limit}`; - // const cachedJobs = await redisService.get(cacheKey); - - // if (cachedJobs) { - // Jobs served from Redis cache - // return cachedJobs; - // } - - // Fetching jobs from database - - // Build query with filters - const where = {}; - if (category) where.categoryId = parseInt(category); - if (city) where.cityId = parseInt(city); - if (shuttle) where.shuttle = true; - if (meals) where.meals = true; - - // For salary filtering, we'll need to handle it differently since salary is stored as string - // We'll filter in JavaScript after fetching the jobs - - const skip = (page - 1) * limit; - - // Get total count first (without pagination) - const total = await prisma.job.count({ where }); - - // Get jobs with pagination - const jobs = await prisma.job.findMany({ - where, - include: { - city: true, - user: true, - category: { include: { translations: true } } - }, - orderBy: [ - { user: { isPremium: 'desc' } }, - { boostedAt: { sort: 'desc', nulls: 'last' } }, - { createdAt: 'desc' } - ], - skip, - take: limit - }); - - // Filter by salary if specified (since salary is stored as string) - let filteredJobs = jobs; - if (salary) { - const minSalary = parseInt(salary); - filteredJobs = jobs.filter(job => { - // Extract numeric value from salary string (e.g., "45" from "45 шек/час") - const salaryMatch = job.salary.match(/(\d+)/); - if (salaryMatch) { - const jobSalary = parseInt(salaryMatch[1]); - return jobSalary >= minSalary; - } - return false; - }); - } - - const result = { - jobs: filteredJobs, - pagination: { - page: parseInt(page), - limit: parseInt(limit), - total: total, - pages: Math.ceil(total / limit) - } - }; - - // Cache the result for 5 minutes - // await redisService.set(cacheKey, result, 300); - // Jobs cached in Redis for 5 minutes - - return result; - } catch (error) { - console.error('❌ Error fetching jobs:', error); - return { error: 'Ошибка получения объявлений', details: error.message }; - } + const { + category, + city, + salary, + shuttle, + meals, + page = 1, + limit = 20, + } = filters; + + try { + // Try to get from cache first + // const cacheKey = `jobs:${category || 'all'}:${city || 'all'}:${salary || 'all'}:${shuttle || 'all'}:${meals || 'all'}:${page}:${limit}`; + // const cachedJobs = await redisService.get(cacheKey); + + // if (cachedJobs) { + // Jobs served from Redis cache + // return cachedJobs; + // } + + // Fetching jobs from database + + // Build query with filters + const where = {}; + if (category) where.categoryId = parseInt(category); + if (city) where.cityId = parseInt(city); + if (shuttle) where.shuttle = true; + if (meals) where.meals = true; + + // For salary filtering, we'll need to handle it differently since salary is stored as string + // We'll filter in JavaScript after fetching the jobs + + const skip = (page - 1) * limit; + + // Get total count first (without pagination) + const total = await prisma.job.count({ where }); + + // Get jobs with pagination + const jobs = await prisma.job.findMany({ + where, + include: { + city: true, + user: true, + category: { include: { translations: true } }, + }, + orderBy: [ + { user: { isPremium: 'desc' } }, + { boostedAt: { sort: 'desc', nulls: 'last' } }, + { createdAt: 'desc' }, + ], + skip, + take: limit, + }); + + // Filter by salary if specified (since salary is stored as string) + let filteredJobs = jobs; + if (salary) { + const minSalary = parseInt(salary); + filteredJobs = jobs.filter((job) => { + // Extract numeric value from salary string (e.g., "45" from "45 шек/час") + const salaryMatch = job.salary.match(/(\d+)/); + if (salaryMatch) { + const jobSalary = parseInt(salaryMatch[1]); + return jobSalary >= minSalary; + } + return false; + }); + } + + const result = { + jobs: filteredJobs, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total: total, + pages: Math.ceil(total / limit), + }, + }; + + // Cache the result for 5 minutes + // await redisService.set(cacheKey, result, 300); + // Jobs cached in Redis for 5 minutes + + return result; + } catch (error) { + console.error('❌ Error fetching jobs:', error); + return { error: 'Ошибка получения объявлений', details: error.message }; + } }; export const getJobByIdService = async (id) => { - try { - // Try to get from cache first - const cacheKey = `job:${id}`; - const cachedJob = await redisService.get(cacheKey); - - if (cachedJob) { - // Job served from Redis cache - return cachedJob; - } - - // Fetching job from database - - const job = await prisma.job.findUnique({ - where: { id: parseInt(id) }, - include: { - city: true, - user: true, - category: { include: { translations: true } } - } - }); - - if (!job) { - return { error: 'Вакансия не найдена' }; - } - - // Cache the job for 10 minutes - await redisService.set(cacheKey, { job }, 600); - // Job cached in Redis for 10 minutes - - return { job }; - } catch (error) { - console.error('❌ Error fetching job:', error); - return { error: 'Ошибка получения объявления', details: error.message }; - } + try { + // Try to get from cache first + const cacheKey = `job:${id}`; + const cachedJob = await redisService.get(cacheKey); + + if (cachedJob) { + // Job served from Redis cache + return cachedJob; + } + + // Fetching job from database + + const job = await prisma.job.findUnique({ + where: { id: parseInt(id) }, + include: { + city: true, + user: true, + category: { include: { translations: true } }, + }, + }); + + if (!job) { + return { error: 'Вакансия не найдена' }; + } + + // Cache the job for 10 minutes + await redisService.set(cacheKey, { job }, 600); + // Job cached in Redis for 10 minutes + + return { job }; + } catch (error) { + console.error('❌ Error fetching job:', error); + return { error: 'Ошибка получения объявления', details: error.message }; + } }; export const createJobService = async (jobData) => { - try { - const job = await prisma.job.create({ - data: jobData, - include: { - city: true, - user: true, - category: { include: { translations: true } } - } - }); - - // Invalidate related caches when new job is created - await redisService.invalidateJobsCache(); - // Job caches invalidated after new job creation - - return job; - } catch (error) { - console.error('❌ Error creating job:', error); - throw error; - } -}; \ No newline at end of file + try { + const job = await prisma.job.create({ + data: jobData, + include: { + city: true, + user: true, + category: { include: { translations: true } }, + }, + }); + + // Invalidate related caches when new job is created + await redisService.invalidateJobsCache(); + // Job caches invalidated after new job creation + + return job; + } catch (error) { + console.error('❌ Error creating job:', error); + throw error; + } +}; diff --git a/apps/api/services/newsletterService.js b/apps/api/services/newsletterService.js index b3f1d44..4d298cf 100644 --- a/apps/api/services/newsletterService.js +++ b/apps/api/services/newsletterService.js @@ -1,13 +1,18 @@ import { PrismaClient } from '@prisma/client'; +import process from 'process'; import { Resend } from 'resend'; import { sendEmail } from '../utils/mailer.js'; -import process from 'process'; const prisma = new PrismaClient(); // Debug: Check if RESEND_API_KEY is available console.log('🔍 RESEND_API_KEY available:', !!process.env.RESEND_API_KEY); -console.log('🔍 RESEND_API_KEY value:', process.env.RESEND_API_KEY ? process.env.RESEND_API_KEY.substring(0, 10) + '...' : 'NOT SET'); +console.log( + '🔍 RESEND_API_KEY value:', + process.env.RESEND_API_KEY + ? process.env.RESEND_API_KEY.substring(0, 10) + '...' + : 'NOT SET', +); const resend = new Resend(process.env.RESEND_API_KEY); @@ -15,67 +20,76 @@ const resend = new Resend(process.env.RESEND_API_KEY); * Send 3 candidates to a newly subscribed user (only once) */ export async function sendCandidatesToNewSubscriber(subscriber) { - try { - console.log(`📧 Отправляем 3 кандидата новому подписчику: ${subscriber.email}`); - - // Get 3 most recent active candidates - const candidates = await prisma.seeker.findMany({ - where: { - isActive: true - }, - orderBy: { createdAt: 'desc' }, - take: 3 - }); - - if (candidates.length === 0) { - console.log('📧 Нет доступных кандидатов для отправки'); - return; - } - - // Generate email content - const emailContent = generateCandidatesEmailContent(candidates, subscriber); - const emailSubject = 'Новые соискатели с сайта WorkNow'; - - // Send email with fallback - console.log('📧 Attempting to send email via Resend...'); - console.log('📧 From:', 'WorkNow '); - console.log('📧 To:', subscriber.email); - console.log('📧 Subject:', emailSubject); - - try { - const result = await resend.emails.send({ - from: 'WorkNow ', - to: subscriber.email, - subject: emailSubject, - html: emailContent - }); - - console.log('📧 Resend API response:', result); - console.log(`📧 Email с кандидатами успешно отправлен через Resend: ${subscriber.email}`); - } catch (resendError) { - console.error('❌ Resend failed, trying Gmail fallback:', resendError); - - // Fallback to Gmail - try { - await sendEmail(subscriber.email, emailSubject, emailContent); - console.log(`📧 Email с кандидатами успешно отправлен через Gmail: ${subscriber.email}`); - } catch (gmailError) { - console.error('❌ Gmail fallback also failed:', gmailError); - throw new Error(`Failed to send email: Resend error - ${resendError.message}, Gmail error - ${gmailError.message}`); - } - } - - } catch (error) { - console.error('❌ Ошибка при отправке кандидатов:', error); - throw error; - } + try { + console.log( + `📧 Отправляем 3 кандидата новому подписчику: ${subscriber.email}`, + ); + + // Get 3 most recent active candidates + const candidates = await prisma.seeker.findMany({ + where: { + isActive: true, + }, + orderBy: { createdAt: 'desc' }, + take: 3, + }); + + if (candidates.length === 0) { + console.log('📧 Нет доступных кандидатов для отправки'); + return; + } + + // Generate email content + const emailContent = generateCandidatesEmailContent(candidates, subscriber); + const emailSubject = 'Новые соискатели с сайта WorkNow'; + + // Send email with fallback + console.log('📧 Attempting to send email via Resend...'); + console.log('📧 From:', 'WorkNow '); + console.log('📧 To:', subscriber.email); + console.log('📧 Subject:', emailSubject); + + try { + const result = await resend.emails.send({ + from: 'WorkNow ', + to: subscriber.email, + subject: emailSubject, + html: emailContent, + }); + + console.log('📧 Resend API response:', result); + console.log( + `📧 Email с кандидатами успешно отправлен через Resend: ${subscriber.email}`, + ); + } catch (resendError) { + console.error('❌ Resend failed, trying Gmail fallback:', resendError); + + // Fallback to Gmail + try { + await sendEmail(subscriber.email, emailSubject, emailContent); + console.log( + `📧 Email с кандидатами успешно отправлен через Gmail: ${subscriber.email}`, + ); + } catch (gmailError) { + console.error('❌ Gmail fallback also failed:', gmailError); + throw new Error( + `Failed to send email: Resend error - ${resendError.message}, Gmail error - ${gmailError.message}`, + ); + } + } + } catch (error) { + console.error('❌ Ошибка при отправке кандидатов:', error); + throw error; + } } /** * Generate email content with candidates */ function generateCandidatesEmailContent(candidates, subscriber) { - const candidatesHtml = candidates.map(candidate => ` + const candidatesHtml = candidates + .map( + (candidate) => `

${candidate.name} ${candidate.gender ? `${candidate.gender}` : ''} @@ -97,13 +111,16 @@ function generateCandidatesEmailContent(candidates, subscriber) { Объявление: ${candidate.description || 'Описание не указано'}

- `).join(''); + `, + ) + .join(''); - const subscriberName = subscriber.firstName && subscriber.lastName - ? `${subscriber.firstName} ${subscriber.lastName}` - : subscriber.firstName || subscriber.lastName || 'пользователь'; + const subscriberName = + subscriber.firstName && subscriber.lastName + ? `${subscriber.firstName} ${subscriber.lastName}` + : subscriber.firstName || subscriber.lastName || 'пользователь'; - return ` + return ` @@ -165,226 +182,295 @@ function generateCandidatesEmailContent(candidates, subscriber) { * Send candidates to existing subscribers (for testing or manual trigger) */ export async function sendCandidatesToSubscribers(subscriberIds = null) { - try { - console.log('📧 Отправляем кандидатов подписчикам...'); - - // Get subscribers - const whereClause = subscriberIds - ? { id: { in: subscriberIds }, isActive: true } - : { isActive: true }; - - const subscribers = await prisma.newsletterSubscriber.findMany({ - where: whereClause - }); - - if (subscribers.length === 0) { - console.log('📧 Нет активных подписчиков для рассылки'); - return; - } - - // Get 3 most recent active candidates - const candidates = await prisma.seeker.findMany({ - where: { - isActive: true - }, - orderBy: { createdAt: 'desc' }, - take: 3 - }); - - if (candidates.length === 0) { - console.log('📧 Нет доступных кандидатов для отправки'); - return; - } - - console.log(`📧 Отправляем ${candidates.length} кандидатов ${subscribers.length} подписчикам`); - - // Send emails to all subscribers with fallback - const emailPromises = subscribers.map(async (subscriber) => { - const emailContent = generateCandidatesEmailContent(candidates, subscriber); - const emailSubject = 'Новые соискатели с сайта WorkNow'; - - try { - const result = await resend.emails.send({ - from: 'WorkNow ', - to: subscriber.email, - subject: emailSubject, - html: emailContent - }); - console.log(`📧 Email sent via Resend to: ${subscriber.email}`); - return result; - } catch (resendError) { - console.error(`❌ Resend failed for ${subscriber.email}, trying Gmail fallback:`, resendError); - - // Fallback to Gmail - try { - await sendEmail(subscriber.email, emailSubject, emailContent); - console.log(`📧 Email sent via Gmail to: ${subscriber.email}`); - } catch (gmailError) { - console.error(`❌ Gmail fallback also failed for ${subscriber.email}:`, gmailError); - throw new Error(`Failed to send email to ${subscriber.email}: Resend error - ${resendError.message}, Gmail error - ${gmailError.message}`); - } - } - }); - - await Promise.all(emailPromises); - - console.log(`📧 Рассылка успешно отправлена ${subscribers.length} подписчикам`); - - } catch (error) { - console.error('❌ Ошибка при отправке кандидатов подписчикам:', error); - throw error; - } -} + try { + console.log('📧 Отправляем кандидатов подписчикам...'); + + // Get subscribers + const whereClause = subscriberIds + ? { id: { in: subscriberIds }, isActive: true } + : { isActive: true }; + + const subscribers = await prisma.newsletterSubscriber.findMany({ + where: whereClause, + }); + + if (subscribers.length === 0) { + console.log('📧 Нет активных подписчиков для рассылки'); + return; + } + + // Get 3 most recent active candidates + const candidates = await prisma.seeker.findMany({ + where: { + isActive: true, + }, + orderBy: { createdAt: 'desc' }, + take: 3, + }); + + if (candidates.length === 0) { + console.log('📧 Нет доступных кандидатов для отправки'); + return; + } + + console.log( + `📧 Отправляем ${candidates.length} кандидатов ${subscribers.length} подписчикам`, + ); + + // Send emails to all subscribers with fallback + const emailPromises = subscribers.map(async (subscriber) => { + const emailContent = generateCandidatesEmailContent( + candidates, + subscriber, + ); + const emailSubject = 'Новые соискатели с сайта WorkNow'; + + try { + const result = await resend.emails.send({ + from: 'WorkNow ', + to: subscriber.email, + subject: emailSubject, + html: emailContent, + }); + console.log(`📧 Email sent via Resend to: ${subscriber.email}`); + return result; + } catch (resendError) { + console.error( + `❌ Resend failed for ${subscriber.email}, trying Gmail fallback:`, + resendError, + ); + + // Fallback to Gmail + try { + await sendEmail(subscriber.email, emailSubject, emailContent); + console.log(`📧 Email sent via Gmail to: ${subscriber.email}`); + } catch (gmailError) { + console.error( + `❌ Gmail fallback also failed for ${subscriber.email}:`, + gmailError, + ); + throw new Error( + `Failed to send email to ${subscriber.email}: Resend error - ${resendError.message}, Gmail error - ${gmailError.message}`, + ); + } + } + }); + + await Promise.all(emailPromises); + + console.log( + `📧 Рассылка успешно отправлена ${subscribers.length} подписчикам`, + ); + } catch (error) { + console.error('❌ Ошибка при отправке кандидатов подписчикам:', error); + throw error; + } +} /** * Send filtered candidates to subscribers when 3 new candidates are added */ export async function sendFilteredCandidatesToSubscribers() { - try { - console.log('📧 Checking for new candidates and sending filtered emails...'); - - // Get all active subscribers - const subscribers = await prisma.newsletterSubscriber.findMany({ - where: { isActive: true } - }); - - if (subscribers.length === 0) { - console.log('📧 Нет активных подписчиков для рассылки'); - return; - } - - // Get all active candidates - const allCandidates = await prisma.seeker.findMany({ - where: { isActive: true }, - orderBy: { createdAt: 'desc' } - }); - - if (allCandidates.length === 0) { - console.log('📧 Нет доступных кандидатов для рассылки'); - return; - } - - console.log(`📧 Найдено ${allCandidates.length} кандидатов и ${subscribers.length} подписчиков`); - - // Send filtered candidates to each subscriber - for (const subscriber of subscribers) { - try { - const filteredCandidates = filterCandidatesByPreferences(allCandidates, subscriber); - - if (filteredCandidates.length > 0) { - // Take up to 3 candidates - const candidatesToSend = filteredCandidates.slice(0, 3); - - const emailContent = generateCandidatesEmailContent(candidatesToSend, subscriber); - const emailSubject = 'Новые соискатели с сайта WorkNow'; - - console.log(`📧 Отправляем ${candidatesToSend.length} отфильтрованных кандидатов подписчику: ${subscriber.email}`); - - // Send email with fallback - try { - await resend.emails.send({ - from: 'WorkNow ', - to: subscriber.email, - subject: emailSubject, - html: emailContent - }); - - console.log(`📧 Email с отфильтрованными кандидатами отправлен через Resend: ${subscriber.email}`); - } catch (resendError) { - console.error(`❌ Resend failed for ${subscriber.email}, trying Gmail fallback:`, resendError); - - // Fallback to Gmail - try { - await sendEmail(subscriber.email, emailSubject, emailContent); - console.log(`📧 Email с отфильтрованными кандидатами отправлен через Gmail: ${subscriber.email}`); - } catch (gmailError) { - console.error(`❌ Gmail fallback also failed for ${subscriber.email}:`, gmailError); - } - } - } else { - console.log(`📧 Нет подходящих кандидатов для подписчика: ${subscriber.email}`); - } - } catch (error) { - console.error(`❌ Ошибка при отправке кандидатов подписчику ${subscriber.email}:`, error); - } - } - - console.log('📧 Рассылка отфильтрованных кандидатов завершена'); - - } catch (error) { - console.error('❌ Ошибка при отправке отфильтрованных кандидатов:', error); - throw error; - } + try { + console.log( + '📧 Checking for new candidates and sending filtered emails...', + ); + + // Get all active subscribers + const subscribers = await prisma.newsletterSubscriber.findMany({ + where: { isActive: true }, + }); + + if (subscribers.length === 0) { + console.log('📧 Нет активных подписчиков для рассылки'); + return; + } + + // Get all active candidates + const allCandidates = await prisma.seeker.findMany({ + where: { isActive: true }, + orderBy: { createdAt: 'desc' }, + }); + + if (allCandidates.length === 0) { + console.log('📧 Нет доступных кандидатов для рассылки'); + return; + } + + console.log( + `📧 Найдено ${allCandidates.length} кандидатов и ${subscribers.length} подписчиков`, + ); + + // Send filtered candidates to each subscriber + for (const subscriber of subscribers) { + try { + const filteredCandidates = filterCandidatesByPreferences( + allCandidates, + subscriber, + ); + + if (filteredCandidates.length > 0) { + // Take up to 3 candidates + const candidatesToSend = filteredCandidates.slice(0, 3); + + const emailContent = generateCandidatesEmailContent( + candidatesToSend, + subscriber, + ); + const emailSubject = 'Новые соискатели с сайта WorkNow'; + + console.log( + `📧 Отправляем ${candidatesToSend.length} отфильтрованных кандидатов подписчику: ${subscriber.email}`, + ); + + // Send email with fallback + try { + await resend.emails.send({ + from: 'WorkNow ', + to: subscriber.email, + subject: emailSubject, + html: emailContent, + }); + + console.log( + `📧 Email с отфильтрованными кандидатами отправлен через Resend: ${subscriber.email}`, + ); + } catch (resendError) { + console.error( + `❌ Resend failed for ${subscriber.email}, trying Gmail fallback:`, + resendError, + ); + + // Fallback to Gmail + try { + await sendEmail(subscriber.email, emailSubject, emailContent); + console.log( + `📧 Email с отфильтрованными кандидатами отправлен через Gmail: ${subscriber.email}`, + ); + } catch (gmailError) { + console.error( + `❌ Gmail fallback also failed for ${subscriber.email}:`, + gmailError, + ); + } + } + } else { + console.log( + `📧 Нет подходящих кандидатов для подписчика: ${subscriber.email}`, + ); + } + } catch (error) { + console.error( + `❌ Ошибка при отправке кандидатов подписчику ${subscriber.email}:`, + error, + ); + } + } + + console.log('📧 Рассылка отфильтрованных кандидатов завершена'); + } catch (error) { + console.error('❌ Ошибка при отправке отфильтрованных кандидатов:', error); + throw error; + } } /** * Filter candidates based on subscriber preferences */ function filterCandidatesByPreferences(candidates, subscriber) { - let filteredCandidates = [...candidates]; - - // Filter by cities - if (subscriber.preferredCities && subscriber.preferredCities.length > 0) { - filteredCandidates = filteredCandidates.filter(candidate => - subscriber.preferredCities.some(city => - candidate.city && candidate.city.toLowerCase().includes(city.toLowerCase()) - ) - ); - } - - // Filter by categories - if (subscriber.preferredCategories && subscriber.preferredCategories.length > 0) { - filteredCandidates = filteredCandidates.filter(candidate => - subscriber.preferredCategories.some(category => - candidate.category && candidate.category.toLowerCase().includes(category.toLowerCase()) - ) - ); - } - - // Filter by employment type - if (subscriber.preferredEmployment && subscriber.preferredEmployment.length > 0) { - filteredCandidates = filteredCandidates.filter(candidate => - subscriber.preferredEmployment.some(employment => - candidate.employment && candidate.employment.toLowerCase().includes(employment.toLowerCase()) - ) - ); - } - - // Filter by languages - if (subscriber.preferredLanguages && subscriber.preferredLanguages.length > 0) { - filteredCandidates = filteredCandidates.filter(candidate => - candidate.languages && candidate.languages.some(lang => - subscriber.preferredLanguages.some(prefLang => - lang.toLowerCase().includes(prefLang.toLowerCase()) - ) - ) - ); - } - - // Filter by gender - if (subscriber.preferredGender) { - filteredCandidates = filteredCandidates.filter(candidate => - candidate.gender && candidate.gender.toLowerCase() === subscriber.preferredGender.toLowerCase() - ); - } - - // Filter by document types - if (subscriber.preferredDocumentTypes && subscriber.preferredDocumentTypes.length > 0) { - filteredCandidates = filteredCandidates.filter(candidate => - candidate.documents && subscriber.preferredDocumentTypes.some(docType => - candidate.documents.toLowerCase().includes(docType.toLowerCase()) - ) - ); - } - - // Filter by demanded status - if (subscriber.onlyDemanded) { - filteredCandidates = filteredCandidates.filter(candidate => candidate.isDemanded === true); - } - - console.log(`📧 Подписчик ${subscriber.email}: ${filteredCandidates.length} кандидатов после фильтрации из ${candidates.length} общих`); - - return filteredCandidates; + let filteredCandidates = [...candidates]; + + // Filter by cities + if (subscriber.preferredCities && subscriber.preferredCities.length > 0) { + filteredCandidates = filteredCandidates.filter((candidate) => + subscriber.preferredCities.some( + (city) => + candidate.city && + candidate.city.toLowerCase().includes(city.toLowerCase()), + ), + ); + } + + // Filter by categories + if ( + subscriber.preferredCategories && + subscriber.preferredCategories.length > 0 + ) { + filteredCandidates = filteredCandidates.filter((candidate) => + subscriber.preferredCategories.some( + (category) => + candidate.category && + candidate.category.toLowerCase().includes(category.toLowerCase()), + ), + ); + } + + // Filter by employment type + if ( + subscriber.preferredEmployment && + subscriber.preferredEmployment.length > 0 + ) { + filteredCandidates = filteredCandidates.filter((candidate) => + subscriber.preferredEmployment.some( + (employment) => + candidate.employment && + candidate.employment.toLowerCase().includes(employment.toLowerCase()), + ), + ); + } + + // Filter by languages + if ( + subscriber.preferredLanguages && + subscriber.preferredLanguages.length > 0 + ) { + filteredCandidates = filteredCandidates.filter( + (candidate) => + candidate.languages && + candidate.languages.some((lang) => + subscriber.preferredLanguages.some((prefLang) => + lang.toLowerCase().includes(prefLang.toLowerCase()), + ), + ), + ); + } + + // Filter by gender + if (subscriber.preferredGender) { + filteredCandidates = filteredCandidates.filter( + (candidate) => + candidate.gender && + candidate.gender.toLowerCase() === + subscriber.preferredGender.toLowerCase(), + ); + } + + // Filter by document types + if ( + subscriber.preferredDocumentTypes && + subscriber.preferredDocumentTypes.length > 0 + ) { + filteredCandidates = filteredCandidates.filter( + (candidate) => + candidate.documents && + subscriber.preferredDocumentTypes.some((docType) => + candidate.documents.toLowerCase().includes(docType.toLowerCase()), + ), + ); + } + + // Filter by demanded status + if (subscriber.onlyDemanded) { + filteredCandidates = filteredCandidates.filter( + (candidate) => candidate.isDemanded === true, + ); + } + + console.log( + `📧 Подписчик ${subscriber.email}: ${filteredCandidates.length} кандидатов после фильтрации из ${candidates.length} общих`, + ); + + return filteredCandidates; } /** @@ -393,18 +479,21 @@ function filterCandidatesByPreferences(candidates, subscriber) { * to prevent duplicate emails. This function now only handles newsletter subscriptions. */ export async function checkAndSendFilteredNewsletter() { - try { - console.log('📧 Newsletter service: Duplicate notification logic disabled to prevent duplicate emails'); - console.log('📧 Candidate notifications are now handled exclusively by candidateNotificationService.js'); - - // This function no longer sends candidate notifications to prevent duplicates - // Candidate notifications are handled by candidateNotificationService.js - - } catch (error) { - console.error('❌ Error in newsletter service:', error); - } -} + try { + console.log( + '📧 Newsletter service: Duplicate notification logic disabled to prevent duplicate emails', + ); + console.log( + '📧 Candidate notifications are now handled exclusively by candidateNotificationService.js', + ); + + // This function no longer sends candidate notifications to prevent duplicates + // Candidate notifications are handled by candidateNotificationService.js + } catch (error) { + console.error('❌ Error in newsletter service:', error); + } +} // DISABLED: sendNewCandidatesNotification function moved to candidateNotificationService.js to prevent duplicate emails -// DISABLED: generateNewCandidatesNotificationEmail function moved to candidateNotificationService.js to prevent duplicate emails \ No newline at end of file +// DISABLED: generateNewCandidatesNotificationEmail function moved to candidateNotificationService.js to prevent duplicate emails diff --git a/apps/api/services/notificationService.js b/apps/api/services/notificationService.js index afbeb1b..88d6888 100644 --- a/apps/api/services/notificationService.js +++ b/apps/api/services/notificationService.js @@ -8,58 +8,61 @@ const prisma = new PrismaClient(); * @param {Array} newSeekers - Array of newly added seekers */ export async function sendNewCandidatesNotification(newSeekers) { - try { - console.log('📧 Starting to send notifications for', newSeekers.length, 'new candidates'); - - // Get all users - const users = await prisma.user.findMany({ - select: { - id: true, - email: true, - firstName: true, - lastName: true, - clerkUserId: true, - } - }); - - console.log('👥 Found', users.length, 'users to notify'); - - if (users.length === 0) { - console.log('⚠️ No users found to notify'); - return; - } - - // Prepare email content - const emailContent = generateNewCandidatesEmail(newSeekers); - - // Send emails to all users - const emailPromises = users.map(user => - sendEmailToUser(user, emailContent) - ); - - // Wait for all emails to be sent - const results = await Promise.allSettled(emailPromises); - - // Log results - const successful = results.filter(r => r.status === 'fulfilled').length; - const failed = results.filter(r => r.status === 'rejected').length; - - console.log(`✅ Successfully sent ${successful} notifications`); - if (failed > 0) { - console.log(`❌ Failed to send ${failed} notifications`); - } - - return { - totalUsers: users.length, - successful, - failed, - newCandidates: newSeekers.length - }; - - } catch (error) { - console.error('❌ Error sending new candidates notifications:', error); - throw error; - } + try { + console.log( + '📧 Starting to send notifications for', + newSeekers.length, + 'new candidates', + ); + + // Get all users + const users = await prisma.user.findMany({ + select: { + id: true, + email: true, + firstName: true, + lastName: true, + clerkUserId: true, + }, + }); + + console.log('👥 Found', users.length, 'users to notify'); + + if (users.length === 0) { + console.log('⚠️ No users found to notify'); + return; + } + + // Prepare email content + const emailContent = generateNewCandidatesEmail(newSeekers); + + // Send emails to all users + const emailPromises = users.map((user) => + sendEmailToUser(user, emailContent), + ); + + // Wait for all emails to be sent + const results = await Promise.allSettled(emailPromises); + + // Log results + const successful = results.filter((r) => r.status === 'fulfilled').length; + const failed = results.filter((r) => r.status === 'rejected').length; + + console.log(`✅ Successfully sent ${successful} notifications`); + if (failed > 0) { + console.log(`❌ Failed to send ${failed} notifications`); + } + + return { + totalUsers: users.length, + successful, + failed, + newCandidates: newSeekers.length, + }; + } catch (error) { + console.error('❌ Error sending new candidates notifications:', error); + throw error; + } } /** @@ -68,21 +71,21 @@ export async function sendNewCandidatesNotification(newSeekers) { * @param {Object} emailContent - Email content object */ async function sendEmailToUser(user, emailContent) { - try { - const userName = user.firstName || user.lastName || 'Пользователь'; - - await sendEmail( - user.email, - emailContent.subject, - emailContent.html.replace('{{userName}}', userName) - ); - - console.log(`✅ Email sent to ${user.email}`); - return { success: true, email: user.email }; - } catch (error) { - console.error(`❌ Failed to send email to ${user.email}:`, error.message); - return { success: false, email: user.email, error: error.message }; - } + try { + const userName = user.firstName || user.lastName || 'Пользователь'; + + await sendEmail( + user.email, + emailContent.subject, + emailContent.html.replace('{{userName}}', userName), + ); + + console.log(`✅ Email sent to ${user.email}`); + return { success: true, email: user.email }; + } catch (error) { + console.error(`❌ Failed to send email to ${user.email}:`, error.message); + return { success: false, email: user.email, error: error.message }; + } } /** @@ -91,7 +94,9 @@ async function sendEmailToUser(user, emailContent) { * @returns {Object} Email content with subject and HTML */ function generateNewCandidatesEmail(newSeekers) { - const candidatesList = newSeekers.map(seeker => ` + const candidatesList = newSeekers + .map( + (seeker) => `

${seeker.name}

Город: ${seeker.city}

@@ -101,11 +106,13 @@ function generateNewCandidatesEmail(newSeekers) { ${seeker.languages && seeker.languages.length > 0 ? `

Языки: ${seeker.languages.join(', ')}

` : ''} ${seeker.isDemanded ? '

⭐ Востребованный кандидат

' : ''}
- `).join(''); + `, + ) + .join(''); + + const subject = `Новые соискатели на WorkNow - ${newSeekers.length} новых кандидатов`; - const subject = `Новые соискатели на WorkNow - ${newSeekers.length} новых кандидатов`; - - const html = ` + const html = ` @@ -148,7 +155,7 @@ function generateNewCandidatesEmail(newSeekers) { `; - return { subject, html }; + return { subject, html }; } /** @@ -156,7 +163,7 @@ function generateNewCandidatesEmail(newSeekers) { * @param {Object} seeker - Newly added seeker */ export async function sendSingleCandidateNotification(seeker) { - return sendNewCandidatesNotification([seeker]); + return sendNewCandidatesNotification([seeker]); } /** @@ -164,5 +171,5 @@ export async function sendSingleCandidateNotification(seeker) { * @param {Array} seekers - Array of newly added seekers */ export async function sendMultipleCandidatesNotification(seekers) { - return sendNewCandidatesNotification(seekers); -} \ No newline at end of file + return sendNewCandidatesNotification(seekers); +} diff --git a/apps/api/services/premiumEmailService.js b/apps/api/services/premiumEmailService.js index afda4b9..d7f54a8 100644 --- a/apps/api/services/premiumEmailService.js +++ b/apps/api/services/premiumEmailService.js @@ -1,13 +1,13 @@ +import process from 'process'; import { Resend } from 'resend'; import { sendEmail } from '../utils/mailer.js'; -import process from 'process'; // Initialize Resend only if API key is available const getResend = () => { - if (!process.env.RESEND_API_KEY) { - return null; - } - return new Resend(process.env.RESEND_API_KEY); + if (!process.env.RESEND_API_KEY) { + return null; + } + return new Resend(process.env.RESEND_API_KEY); }; /** @@ -16,10 +16,13 @@ const getResend = () => { * @param {string} userName - User's name (optional) * @returns {Promise} - Result of email sending */ -export const sendPremiumDeluxeWelcomeEmail = async (userEmail, userName = '') => { - const greeting = userName ? `Дорогой ${userName},` : 'Дорогой пользователь,'; - - const emailHtml = ` +export const sendPremiumDeluxeWelcomeEmail = async ( + userEmail, + userName = '', +) => { + const greeting = userName ? `Дорогой ${userName},` : 'Дорогой пользователь,'; + + const emailHtml = ` @@ -185,7 +188,7 @@ export const sendPremiumDeluxeWelcomeEmail = async (userEmail, userName = '') => `; - const emailText = ` + const emailText = ` Добро пожаловать в Premium Deluxe! ${greeting} @@ -222,46 +225,55 @@ ${greeting} Это письмо отправлено автоматически, пожалуйста, не отвечайте на него. `; - try { - // Try Resend first (if available) - const resend = getResend(); - if (resend) { - console.log('📧 Attempting to send Premium Deluxe welcome email via Resend...'); - - const result = await resend.emails.send({ - from: 'WorkNow ', - to: userEmail, - subject: '🎉 Добро пожаловать в Premium Deluxe! - WorkNow', - html: emailHtml, - text: emailText, - }); - - console.log('✅ Premium Deluxe welcome email sent via Resend:', userEmail); - return { success: true, messageId: result.id || 'resend-' + Date.now() }; - } else { - throw new Error('RESEND_API_KEY not available'); - } - } catch (resendError) { - console.error('❌ Resend failed, trying Gmail fallback:', resendError); - - try { - // Fallback to Gmail - console.log('📧 Attempting to send Premium Deluxe welcome email via Gmail...'); - - await sendEmail({ - to: userEmail, - subject: '🎉 Добро пожаловать в Premium Deluxe! - WorkNow', - html: emailHtml, - text: emailText, - }); - - console.log('✅ Premium Deluxe welcome email sent via Gmail:', userEmail); - return { success: true, messageId: 'gmail-' + Date.now() }; - } catch (gmailError) { - console.error('❌ Gmail also failed:', gmailError); - throw new Error(`Failed to send Premium Deluxe welcome email: Resend error - ${resendError.message}, Gmail error - ${gmailError.message}`); - } - } + try { + // Try Resend first (if available) + const resend = getResend(); + if (resend) { + console.log( + '📧 Attempting to send Premium Deluxe welcome email via Resend...', + ); + + const result = await resend.emails.send({ + from: 'WorkNow ', + to: userEmail, + subject: '🎉 Добро пожаловать в Premium Deluxe! - WorkNow', + html: emailHtml, + text: emailText, + }); + + console.log( + '✅ Premium Deluxe welcome email sent via Resend:', + userEmail, + ); + return { success: true, messageId: result.id || 'resend-' + Date.now() }; + } else { + throw new Error('RESEND_API_KEY not available'); + } + } catch (resendError) { + console.error('❌ Resend failed, trying Gmail fallback:', resendError); + + try { + // Fallback to Gmail + console.log( + '📧 Attempting to send Premium Deluxe welcome email via Gmail...', + ); + + await sendEmail({ + to: userEmail, + subject: '🎉 Добро пожаловать в Premium Deluxe! - WorkNow', + html: emailHtml, + text: emailText, + }); + + console.log('✅ Premium Deluxe welcome email sent via Gmail:', userEmail); + return { success: true, messageId: 'gmail-' + Date.now() }; + } catch (gmailError) { + console.error('❌ Gmail also failed:', gmailError); + throw new Error( + `Failed to send Premium Deluxe welcome email: Resend error - ${resendError.message}, Gmail error - ${gmailError.message}`, + ); + } + } }; /** @@ -271,9 +283,9 @@ ${greeting} * @returns {Promise} - Result of email sending */ export const sendProWelcomeEmail = async (userEmail, userName = '') => { - const greeting = userName ? `Дорогой ${userName},` : 'Дорогой пользователь,'; - - const emailHtml = ` + const greeting = userName ? `Дорогой ${userName},` : 'Дорогой пользователь,'; + + const emailHtml = ` @@ -430,7 +442,7 @@ export const sendProWelcomeEmail = async (userEmail, userName = '') => { `; - const emailText = ` + const emailText = ` Добро пожаловать в Pro! ${greeting} @@ -467,44 +479,46 @@ ${greeting} Это письмо отправлено автоматически, пожалуйста, не отвечайте на него. `; - try { - // Try Resend first (if available) - const resend = getResend(); - if (resend) { - console.log('📧 Attempting to send Pro welcome email via Resend...'); - - const result = await resend.emails.send({ - from: 'WorkNow ', - to: userEmail, - subject: '🚀 Добро пожаловать в Pro! - WorkNow', - html: emailHtml, - text: emailText, - }); - - console.log('✅ Pro welcome email sent via Resend:', userEmail); - return { success: true, messageId: result.id || 'resend-' + Date.now() }; - } else { - throw new Error('RESEND_API_KEY not available'); - } - } catch (resendError) { - console.error('❌ Resend failed, trying Gmail fallback:', resendError); - - try { - // Fallback to Gmail - console.log('📧 Attempting to send Pro welcome email via Gmail...'); - - await sendEmail({ - to: userEmail, - subject: '🚀 Добро пожаловать в Pro! - WorkNow', - html: emailHtml, - text: emailText, - }); - - console.log('✅ Pro welcome email sent via Gmail:', userEmail); - return { success: true, messageId: 'gmail-' + Date.now() }; - } catch (gmailError) { - console.error('❌ Gmail also failed:', gmailError); - throw new Error(`Failed to send Pro welcome email: Resend error - ${resendError.message}, Gmail error - ${gmailError.message}`); - } - } -}; \ No newline at end of file + try { + // Try Resend first (if available) + const resend = getResend(); + if (resend) { + console.log('📧 Attempting to send Pro welcome email via Resend...'); + + const result = await resend.emails.send({ + from: 'WorkNow ', + to: userEmail, + subject: '🚀 Добро пожаловать в Pro! - WorkNow', + html: emailHtml, + text: emailText, + }); + + console.log('✅ Pro welcome email sent via Resend:', userEmail); + return { success: true, messageId: result.id || 'resend-' + Date.now() }; + } else { + throw new Error('RESEND_API_KEY not available'); + } + } catch (resendError) { + console.error('❌ Resend failed, trying Gmail fallback:', resendError); + + try { + // Fallback to Gmail + console.log('📧 Attempting to send Pro welcome email via Gmail...'); + + await sendEmail({ + to: userEmail, + subject: '🚀 Добро пожаловать в Pro! - WorkNow', + html: emailHtml, + text: emailText, + }); + + console.log('✅ Pro welcome email sent via Gmail:', userEmail); + return { success: true, messageId: 'gmail-' + Date.now() }; + } catch (gmailError) { + console.error('❌ Gmail also failed:', gmailError); + throw new Error( + `Failed to send Pro welcome email: Resend error - ${resendError.message}, Gmail error - ${gmailError.message}`, + ); + } + } +}; diff --git a/apps/api/services/redisService.js b/apps/api/services/redisService.js index 64a1968..f7dbad4 100644 --- a/apps/api/services/redisService.js +++ b/apps/api/services/redisService.js @@ -1,198 +1,199 @@ import Redis from 'ioredis'; class RedisService { - constructor() { - this.redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', { - retryDelayOnFailover: 100, - maxRetriesPerRequest: 3, - lazyConnect: true, - }); - - this.redis.on('connect', () => { - // Redis connected successfully - }); - - this.redis.on('error', (err) => { - console.error('❌ Redis connection error:', err); - }); - - this.redis.on('ready', () => { - // Redis is ready for operations - }); - } - - // Cache management - async set(key, value, ttl = 3600) { - try { - const serializedValue = typeof value === 'object' ? JSON.stringify(value) : value; - await this.redis.setex(key, ttl, serializedValue); - // Data cached successfully - return true; - } catch (error) { - console.error('❌ Redis set error:', error); - return false; - } - } - - async get(key) { - try { - const value = await this.redis.get(key); - if (value) { - // Cache hit - data retrieved - try { - return JSON.parse(value); - } catch { - return value; - } - } - // Cache miss - data not found - return null; - } catch (error) { - console.error('❌ Redis get error:', error); - return null; - } - } - - async del(key) { - try { - await this.redis.del(key); - // Cache entry deleted - return true; - } catch (error) { - console.error('❌ Redis del error:', error); - return false; - } - } - - // Session management - async setSession(sessionId, userData, ttl = 86400) { - return this.set(`session:${sessionId}`, userData, ttl); - } - - async getSession(sessionId) { - return this.get(`session:${sessionId}`); - } - - async deleteSession(sessionId) { - return this.del(`session:${sessionId}`); - } - - // Rate limiting - async checkRateLimit(identifier, limit = 100, window = 3600) { - const key = `rate_limit:${identifier}`; - try { - const current = await this.redis.incr(key); - if (current === 1) { - await this.redis.expire(key, window); - } - - const remaining = Math.max(0, limit - current); - const resetTime = await this.redis.ttl(key); - - return { - allowed: current <= limit, - remaining, - resetTime, - limit - }; - } catch (error) { - console.error('❌ Rate limit check error:', error); - return { allowed: true, remaining: limit, resetTime: window, limit }; - } - } - - // Job listings cache - async cacheJobs(category, city, page, jobs) { - const key = `jobs:${category || 'all'}:${city || 'all'}:${page}`; - return this.set(key, jobs, 300); // 5 minutes cache - } - - async getCachedJobs(category, city, page) { - const key = `jobs:${category || 'all'}:${city || 'all'}:${page}`; - return this.get(key); - } - - async invalidateJobsCache() { - try { - const keys = await this.redis.keys('jobs:*'); - if (keys.length > 0) { - await this.redis.del(...keys); - // Job cache entries invalidated - } - return true; - } catch (error) { - console.error('❌ Cache invalidation error:', error); - return false; - } - } - - // User activity tracking - async trackUserActivity(userId, action) { - const key = `activity:${userId}`; - try { - const activity = { - action, - timestamp: new Date().toISOString(), - ip: 'tracked_ip' // You can add IP tracking here - }; - - await this.redis.lpush(key, JSON.stringify(activity)); - await this.redis.ltrim(key, 0, 99); // Keep last 100 activities - await this.redis.expire(key, 86400); // 24 hours - - return true; - } catch (error) { - console.error('❌ Activity tracking error:', error); - return false; - } - } - - // Real-time notifications - async publishNotification(channel, message) { - try { - await this.redis.publish(channel, JSON.stringify(message)); - // Message published to channel - return true; - } catch (error) { - console.error('❌ Notification publish error:', error); - return false; - } - } - - // Health check - async healthCheck() { - try { - const start = Date.now(); - await this.redis.ping(); - const latency = Date.now() - start; - - return { - status: 'healthy', - latency: `${latency}ms`, - memory: await this.redis.info('memory'), - connected: this.redis.status === 'ready' - }; - } catch (error) { - return { - status: 'unhealthy', - error: error.message, - connected: false - }; - } - } - - // Close connection - async close() { - try { - await this.redis.quit(); - // Redis connection closed - } catch (error) { - console.error('❌ Redis close error:', error); - } - } + constructor() { + this.redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', { + retryDelayOnFailover: 100, + maxRetriesPerRequest: 3, + lazyConnect: true, + }); + + this.redis.on('connect', () => { + // Redis connected successfully + }); + + this.redis.on('error', (err) => { + console.error('❌ Redis connection error:', err); + }); + + this.redis.on('ready', () => { + // Redis is ready for operations + }); + } + + // Cache management + async set(key, value, ttl = 3600) { + try { + const serializedValue = + typeof value === 'object' ? JSON.stringify(value) : value; + await this.redis.setex(key, ttl, serializedValue); + // Data cached successfully + return true; + } catch (error) { + console.error('❌ Redis set error:', error); + return false; + } + } + + async get(key) { + try { + const value = await this.redis.get(key); + if (value) { + // Cache hit - data retrieved + try { + return JSON.parse(value); + } catch { + return value; + } + } + // Cache miss - data not found + return null; + } catch (error) { + console.error('❌ Redis get error:', error); + return null; + } + } + + async del(key) { + try { + await this.redis.del(key); + // Cache entry deleted + return true; + } catch (error) { + console.error('❌ Redis del error:', error); + return false; + } + } + + // Session management + async setSession(sessionId, userData, ttl = 86400) { + return this.set(`session:${sessionId}`, userData, ttl); + } + + async getSession(sessionId) { + return this.get(`session:${sessionId}`); + } + + async deleteSession(sessionId) { + return this.del(`session:${sessionId}`); + } + + // Rate limiting + async checkRateLimit(identifier, limit = 100, window = 3600) { + const key = `rate_limit:${identifier}`; + try { + const current = await this.redis.incr(key); + if (current === 1) { + await this.redis.expire(key, window); + } + + const remaining = Math.max(0, limit - current); + const resetTime = await this.redis.ttl(key); + + return { + allowed: current <= limit, + remaining, + resetTime, + limit, + }; + } catch (error) { + console.error('❌ Rate limit check error:', error); + return { allowed: true, remaining: limit, resetTime: window, limit }; + } + } + + // Job listings cache + async cacheJobs(category, city, page, jobs) { + const key = `jobs:${category || 'all'}:${city || 'all'}:${page}`; + return this.set(key, jobs, 300); // 5 minutes cache + } + + async getCachedJobs(category, city, page) { + const key = `jobs:${category || 'all'}:${city || 'all'}:${page}`; + return this.get(key); + } + + async invalidateJobsCache() { + try { + const keys = await this.redis.keys('jobs:*'); + if (keys.length > 0) { + await this.redis.del(...keys); + // Job cache entries invalidated + } + return true; + } catch (error) { + console.error('❌ Cache invalidation error:', error); + return false; + } + } + + // User activity tracking + async trackUserActivity(userId, action) { + const key = `activity:${userId}`; + try { + const activity = { + action, + timestamp: new Date().toISOString(), + ip: 'tracked_ip', // You can add IP tracking here + }; + + await this.redis.lpush(key, JSON.stringify(activity)); + await this.redis.ltrim(key, 0, 99); // Keep last 100 activities + await this.redis.expire(key, 86400); // 24 hours + + return true; + } catch (error) { + console.error('❌ Activity tracking error:', error); + return false; + } + } + + // Real-time notifications + async publishNotification(channel, message) { + try { + await this.redis.publish(channel, JSON.stringify(message)); + // Message published to channel + return true; + } catch (error) { + console.error('❌ Notification publish error:', error); + return false; + } + } + + // Health check + async healthCheck() { + try { + const start = Date.now(); + await this.redis.ping(); + const latency = Date.now() - start; + + return { + status: 'healthy', + latency: `${latency}ms`, + memory: await this.redis.info('memory'), + connected: this.redis.status === 'ready', + }; + } catch (error) { + return { + status: 'unhealthy', + error: error.message, + connected: false, + }; + } + } + + // Close connection + async close() { + try { + await this.redis.quit(); + // Redis connection closed + } catch (error) { + console.error('❌ Redis close error:', error); + } + } } // Create singleton instance const redisService = new RedisService(); -export default redisService; \ No newline at end of file +export default redisService; diff --git a/apps/api/services/s3UploadService.js b/apps/api/services/s3UploadService.js index 4ab0fc4..9caf893 100644 --- a/apps/api/services/s3UploadService.js +++ b/apps/api/services/s3UploadService.js @@ -1,5 +1,9 @@ -import { uploadToS3WithModeration, deleteFromS3, validateS3Config } from '../utils/s3Upload.js'; import { PrismaClient } from '@prisma/client'; +import { + deleteFromS3, + uploadToS3WithModeration, + validateS3Config, +} from '../utils/s3Upload.js'; const prisma = new PrismaClient(); @@ -11,386 +15,402 @@ const MAX_JOBS_PREMIUM_USER = 10; * Service class for handling S3 upload operations */ class S3UploadService { - constructor() { - const configStatus = validateS3Config(); - this.isConfigured = configStatus.isValid; - this.configStatus = configStatus; - - if (!this.isConfigured) { - console.warn('⚠️ S3UploadService: S3 configuration is incomplete'); - console.warn('📖 See SETUP_GUIDE.md for configuration instructions'); - } - } - - /** - * Upload job image to S3 - * @param {Object} file - Multer file object - * @param {string} userId - User ID - * @returns {Promise} Upload result - */ - async uploadJobImage(file, userId) { - try { - if (!this.isConfigured) { - throw new Error('S3 is not properly configured'); - } - - if (!file) { - throw new Error('No file provided'); - } - - // Validate file type - if (!file.mimetype.startsWith('image/')) { - throw new Error('Only image files are allowed'); - } - - // Validate file size (5MB limit) - if (file.size > 5 * 1024 * 1024) { - throw new Error('File size exceeds 5MB limit'); - } - - // Upload to S3 with moderation - const uploadResult = await uploadToS3WithModeration( - file.buffer, - file.originalname, - file.mimetype, - 'jobs' - ); - - if (!uploadResult.success) { - if (uploadResult.code === 'CONTENT_REJECTED') { - throw new Error(`Image content violates community guidelines: ${uploadResult.error}`); - } - throw new Error(`Upload failed: ${uploadResult.error}`); - } - - const imageUrl = uploadResult.imageUrl; - - // Image uploaded successfully - - return { - success: true, - imageUrl, - filename: file.originalname, - size: file.size, - mimetype: file.mimetype - }; - - } catch (error) { - console.error('❌ S3UploadService: Upload failed:', error); - throw error; - } - } - - /** - * Create job with image upload - * @param {Object} jobData - Job data - * @param {Object} file - Multer file object (optional) - * @param {string} userId - User ID - * @returns {Promise} Created job - */ - async createJobWithImage(jobData, file, userId) { - let imageUrl = null; - let uploadedImageUrl = null; - - try { - // Validate required job data - const requiredFields = ['title', 'salary', 'phone', 'description', 'cityId', 'categoryId']; - const missingFields = requiredFields.filter(field => !jobData[field]); - - if (missingFields.length > 0) { - throw new Error(`Missing required fields: ${missingFields.join(', ')}`); - } - - // Upload image if provided - if (file) { - const uploadResult = await this.uploadJobImage(file, userId); - imageUrl = uploadResult.imageUrl; - uploadedImageUrl = imageUrl; - } - - // Check user's job limit before creating job - const user = await prisma.user.findUnique({ - where: { id: userId } - }); - - if (!user) { - throw new Error('User not found'); - } - - // Check job count limit - const jobCount = await prisma.job.count({ where: { userId: userId } }); - const isPremium = user.isPremium || user.premiumDeluxe; - const maxJobs = isPremium ? MAX_JOBS_PREMIUM_USER : MAX_JOBS_FREE_USER; - - if (jobCount >= maxJobs) { - if (isPremium) { - throw new Error(`Вы уже разместили ${MAX_JOBS_PREMIUM_USER} объявлений.`); - } else { - throw new Error(`Вы уже разместили ${MAX_JOBS_FREE_USER} объявлений. Для размещения большего количества объявлений перейдите на Premium тариф.`); - } - } - - // Create job in database - const job = await prisma.job.create({ - data: { - title: jobData.title, - salary: jobData.salary, - phone: jobData.phone, - description: jobData.description, - cityId: parseInt(jobData.cityId), - categoryId: parseInt(jobData.categoryId), - userId: userId, - imageUrl, - shuttle: jobData.shuttle === 'true', - meals: jobData.meals === 'true' - }, - include: { - city: true, - category: true, - user: { - select: { - id: true, - firstName: true, - lastName: true, - email: true - } - } - } - }); - - // Job created successfully with image - - return { - success: true, - job, - imageUrl - }; - - } catch (error) { - console.error('❌ S3UploadService: Create job failed:', error); - - // Cleanup: Delete uploaded image if job creation failed - if (uploadedImageUrl) { - try { - await this.deleteImage(uploadedImageUrl); - // Cleaned up uploaded image after job creation failure - } catch (cleanupError) { - console.error('❌ S3UploadService: Failed to cleanup image:', cleanupError); - } - } - - throw error; - } - } - - /** - * Update job image - * @param {number} jobId - Job ID - * @param {Object} file - Multer file object - * @param {string} userId - User ID - * @returns {Promise} Updated job - */ - async updateJobImage(jobId, file, userId) { - let newImageUrl = null; - - try { - // Check if job exists and belongs to user - const existingJob = await prisma.job.findFirst({ - where: { - id: parseInt(jobId), - userId: userId - } - }); - - if (!existingJob) { - throw new Error('Job not found or access denied'); - } - - // Upload new image - if (file) { - const uploadResult = await this.uploadJobImage(file, userId); - newImageUrl = uploadResult.imageUrl; - - // Delete old image if it exists - if (existingJob.imageUrl) { - await this.deleteImage(existingJob.imageUrl); - } - } - - // Update job in database - const updatedJob = await prisma.job.update({ - where: { - id: parseInt(jobId) - }, - data: { - imageUrl: newImageUrl || existingJob.imageUrl - }, - include: { - city: true, - category: true, - user: { - select: { - id: true, - firstName: true, - lastName: true, - email: true - } - } - } - }); - - // Job image updated successfully - - return { - success: true, - job: updatedJob, - imageUrl: newImageUrl - }; - - } catch (error) { - console.error('❌ S3UploadService: Update job image failed:', error); - - // Cleanup: Delete new image if update failed - if (newImageUrl) { - try { - await this.deleteImage(newImageUrl); - // Cleaned up new image after update failure - } catch (cleanupError) { - console.error('❌ S3UploadService: Failed to cleanup new image:', cleanupError); - } - } - - throw error; - } - } - - /** - * Delete image from S3 - * @param {string} imageUrl - Image URL to delete - * @returns {Promise} Success status - */ - async deleteImage(imageUrl) { - try { - if (!imageUrl) { - return false; - } - - const deleted = await deleteFromS3(imageUrl); - - if (deleted) { - // Image deleted successfully - } else { - console.warn('⚠️ S3UploadService: Image deletion failed or image not found'); - } - - return deleted; - - } catch (error) { - console.error('❌ S3UploadService: Delete image failed:', error); - return false; - } - } - - /** - * Delete job and its associated image - * @param {number} jobId - Job ID - * @param {string} userId - User ID - * @returns {Promise} Success status - */ - async deleteJobWithImage(jobId, userId) { - try { - // Get job with image URL - const job = await prisma.job.findFirst({ - where: { - id: parseInt(jobId), - userId: userId - }, - select: { - id: true, - imageUrl: true - } - }); - - if (!job) { - throw new Error('Job not found or access denied'); - } - - // Delete image from S3 if it exists - if (job.imageUrl) { - await this.deleteImage(job.imageUrl); - } - - // Delete job from database - await prisma.job.delete({ - where: { - id: parseInt(jobId) - } - }); - - // Job and image deleted successfully - - return true; - - } catch (error) { - console.error('❌ S3UploadService: Delete job failed:', error); - throw error; - } - } - - /** - * Validate file for upload - * @param {Object} file - Multer file object - * @returns {Object} Validation result - */ - validateFile(file) { - const errors = []; - - if (!file) { - errors.push('No file provided'); - return { isValid: false, errors }; - } - - // Check file type - if (!file.mimetype.startsWith('image/')) { - errors.push('Only image files are allowed'); - } - - // Check file size (5MB limit) - if (file.size > 5 * 1024 * 1024) { - errors.push('File size exceeds 5MB limit'); - } - - // Check file extension - const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; - const fileExtension = file.originalname.toLowerCase().substring(file.originalname.lastIndexOf('.')); - - if (!allowedExtensions.includes(fileExtension)) { - errors.push('Invalid file extension. Allowed: jpg, jpeg, png, gif, webp'); - } - - return { - isValid: errors.length === 0, - errors - }; - } - - /** - * Get S3 configuration status - * @returns {Object} Configuration status - */ - getConfigurationStatus() { - return { - isConfigured: this.isConfigured, - requiredEnvVars: [ - 'AWS_ACCESS_KEY_ID', - 'AWS_SECRET_ACCESS_KEY', - 'AWS_S3_BUCKET_NAME' - ], - optionalEnvVars: [ - 'AWS_REGION' - ] - }; - } + constructor() { + const configStatus = validateS3Config(); + this.isConfigured = configStatus.isValid; + this.configStatus = configStatus; + + if (!this.isConfigured) { + console.warn('⚠️ S3UploadService: S3 configuration is incomplete'); + console.warn('📖 See SETUP_GUIDE.md for configuration instructions'); + } + } + + /** + * Upload job image to S3 + * @param {Object} file - Multer file object + * @param {string} userId - User ID + * @returns {Promise} Upload result + */ + async uploadJobImage(file, userId) { + try { + if (!this.isConfigured) { + throw new Error('S3 is not properly configured'); + } + + if (!file) { + throw new Error('No file provided'); + } + + // Validate file type + if (!file.mimetype.startsWith('image/')) { + throw new Error('Only image files are allowed'); + } + + // Validate file size (5MB limit) + if (file.size > 5 * 1024 * 1024) { + throw new Error('File size exceeds 5MB limit'); + } + + // Upload to S3 with moderation + const uploadResult = await uploadToS3WithModeration( + file.buffer, + file.originalname, + file.mimetype, + 'jobs', + ); + + if (!uploadResult.success) { + if (uploadResult.code === 'CONTENT_REJECTED') { + throw new Error( + `Image content violates community guidelines: ${uploadResult.error}`, + ); + } + throw new Error(`Upload failed: ${uploadResult.error}`); + } + + const imageUrl = uploadResult.imageUrl; + + // Image uploaded successfully + + return { + success: true, + imageUrl, + filename: file.originalname, + size: file.size, + mimetype: file.mimetype, + }; + } catch (error) { + console.error('❌ S3UploadService: Upload failed:', error); + throw error; + } + } + + /** + * Create job with image upload + * @param {Object} jobData - Job data + * @param {Object} file - Multer file object (optional) + * @param {string} userId - User ID + * @returns {Promise} Created job + */ + async createJobWithImage(jobData, file, userId) { + let imageUrl = null; + let uploadedImageUrl = null; + + try { + // Validate required job data + const requiredFields = [ + 'title', + 'salary', + 'phone', + 'description', + 'cityId', + 'categoryId', + ]; + const missingFields = requiredFields.filter((field) => !jobData[field]); + + if (missingFields.length > 0) { + throw new Error(`Missing required fields: ${missingFields.join(', ')}`); + } + + // Upload image if provided + if (file) { + const uploadResult = await this.uploadJobImage(file, userId); + imageUrl = uploadResult.imageUrl; + uploadedImageUrl = imageUrl; + } + + // Check user's job limit before creating job + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new Error('User not found'); + } + + // Check job count limit + const jobCount = await prisma.job.count({ where: { userId: userId } }); + const isPremium = user.isPremium || user.premiumDeluxe; + const maxJobs = isPremium ? MAX_JOBS_PREMIUM_USER : MAX_JOBS_FREE_USER; + + if (jobCount >= maxJobs) { + if (isPremium) { + throw new Error( + `Вы уже разместили ${MAX_JOBS_PREMIUM_USER} объявлений.`, + ); + } else { + throw new Error( + `Вы уже разместили ${MAX_JOBS_FREE_USER} объявлений. Для размещения большего количества объявлений перейдите на Premium тариф.`, + ); + } + } + + // Create job in database + const job = await prisma.job.create({ + data: { + title: jobData.title, + salary: jobData.salary, + phone: jobData.phone, + description: jobData.description, + cityId: parseInt(jobData.cityId), + categoryId: parseInt(jobData.categoryId), + userId: userId, + imageUrl, + shuttle: jobData.shuttle === 'true', + meals: jobData.meals === 'true', + }, + include: { + city: true, + category: true, + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + // Job created successfully with image + + return { + success: true, + job, + imageUrl, + }; + } catch (error) { + console.error('❌ S3UploadService: Create job failed:', error); + + // Cleanup: Delete uploaded image if job creation failed + if (uploadedImageUrl) { + try { + await this.deleteImage(uploadedImageUrl); + // Cleaned up uploaded image after job creation failure + } catch (cleanupError) { + console.error( + '❌ S3UploadService: Failed to cleanup image:', + cleanupError, + ); + } + } + + throw error; + } + } + + /** + * Update job image + * @param {number} jobId - Job ID + * @param {Object} file - Multer file object + * @param {string} userId - User ID + * @returns {Promise} Updated job + */ + async updateJobImage(jobId, file, userId) { + let newImageUrl = null; + + try { + // Check if job exists and belongs to user + const existingJob = await prisma.job.findFirst({ + where: { + id: parseInt(jobId), + userId: userId, + }, + }); + + if (!existingJob) { + throw new Error('Job not found or access denied'); + } + + // Upload new image + if (file) { + const uploadResult = await this.uploadJobImage(file, userId); + newImageUrl = uploadResult.imageUrl; + + // Delete old image if it exists + if (existingJob.imageUrl) { + await this.deleteImage(existingJob.imageUrl); + } + } + + // Update job in database + const updatedJob = await prisma.job.update({ + where: { + id: parseInt(jobId), + }, + data: { + imageUrl: newImageUrl || existingJob.imageUrl, + }, + include: { + city: true, + category: true, + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + // Job image updated successfully + + return { + success: true, + job: updatedJob, + imageUrl: newImageUrl, + }; + } catch (error) { + console.error('❌ S3UploadService: Update job image failed:', error); + + // Cleanup: Delete new image if update failed + if (newImageUrl) { + try { + await this.deleteImage(newImageUrl); + // Cleaned up new image after update failure + } catch (cleanupError) { + console.error( + '❌ S3UploadService: Failed to cleanup new image:', + cleanupError, + ); + } + } + + throw error; + } + } + + /** + * Delete image from S3 + * @param {string} imageUrl - Image URL to delete + * @returns {Promise} Success status + */ + async deleteImage(imageUrl) { + try { + if (!imageUrl) { + return false; + } + + const deleted = await deleteFromS3(imageUrl); + + if (deleted) { + // Image deleted successfully + } else { + console.warn( + '⚠️ S3UploadService: Image deletion failed or image not found', + ); + } + + return deleted; + } catch (error) { + console.error('❌ S3UploadService: Delete image failed:', error); + return false; + } + } + + /** + * Delete job and its associated image + * @param {number} jobId - Job ID + * @param {string} userId - User ID + * @returns {Promise} Success status + */ + async deleteJobWithImage(jobId, userId) { + try { + // Get job with image URL + const job = await prisma.job.findFirst({ + where: { + id: parseInt(jobId), + userId: userId, + }, + select: { + id: true, + imageUrl: true, + }, + }); + + if (!job) { + throw new Error('Job not found or access denied'); + } + + // Delete image from S3 if it exists + if (job.imageUrl) { + await this.deleteImage(job.imageUrl); + } + + // Delete job from database + await prisma.job.delete({ + where: { + id: parseInt(jobId), + }, + }); + + // Job and image deleted successfully + + return true; + } catch (error) { + console.error('❌ S3UploadService: Delete job failed:', error); + throw error; + } + } + + /** + * Validate file for upload + * @param {Object} file - Multer file object + * @returns {Object} Validation result + */ + validateFile(file) { + const errors = []; + + if (!file) { + errors.push('No file provided'); + return { isValid: false, errors }; + } + + // Check file type + if (!file.mimetype.startsWith('image/')) { + errors.push('Only image files are allowed'); + } + + // Check file size (5MB limit) + if (file.size > 5 * 1024 * 1024) { + errors.push('File size exceeds 5MB limit'); + } + + // Check file extension + const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + const fileExtension = file.originalname + .toLowerCase() + .substring(file.originalname.lastIndexOf('.')); + + if (!allowedExtensions.includes(fileExtension)) { + errors.push('Invalid file extension. Allowed: jpg, jpeg, png, gif, webp'); + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * Get S3 configuration status + * @returns {Object} Configuration status + */ + getConfigurationStatus() { + return { + isConfigured: this.isConfigured, + requiredEnvVars: [ + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_S3_BUCKET_NAME', + ], + optionalEnvVars: ['AWS_REGION'], + }; + } } -export default new S3UploadService(); \ No newline at end of file +export default new S3UploadService(); diff --git a/apps/api/services/seekerService.js b/apps/api/services/seekerService.js index 3834586..e2fd24c 100644 --- a/apps/api/services/seekerService.js +++ b/apps/api/services/seekerService.js @@ -5,177 +5,193 @@ const prisma = new PrismaClient(); // Helper function to translate city names async function translateCityName(cityName, lang = 'ru') { - try { - // First, find the city by name - const city = await prisma.city.findFirst({ - where: { name: cityName }, - include: { - translations: { - where: { lang } - } - } - }); - - // Return translated name if available, otherwise return original name - return city?.translations[0]?.name || cityName; - } catch (error) { - console.error('Error translating city name:', error); - return cityName; // Fallback to original name - } + try { + // First, find the city by name + const city = await prisma.city.findFirst({ + where: { name: cityName }, + include: { + translations: { + where: { lang }, + }, + }, + }); + + // Return translated name if available, otherwise return original name + return city?.translations[0]?.name || cityName; + } catch (error) { + console.error('Error translating city name:', error); + return cityName; // Fallback to original name + } } function generateSlug(name, description) { - return (name + '-' + description.split('\\n')[0]) - .toLowerCase() - .replace(/[^a-zа-я0-9]+/gi, '-') - .replace(/^-+|-+$/g, ''); + return (name + '-' + description.split('\\n')[0]) + .toLowerCase() + .replace(/[^a-zа-я0-9]+/gi, '-') + .replace(/^-+|-+$/g, ''); } export async function getAllSeekers(query = {}) { - const { - page = 1, - limit = 10, - city, - category, - employment, - documentType, - languages, - gender, - isDemanded, - lang = 'ru' - } = query; - - // Service processing query - - // Build where clause for filtering - const whereClause = { - isActive: true, - }; - - if (city) { - whereClause.city = city; - // City filter applied - } - - if (category) { - whereClause.category = category; - // Category filter applied - } - - if (employment) { - whereClause.employment = employment; - // Employment filter applied - } - - if (documentType) { - whereClause.documentType = documentType; - // Document type filter applied - } - - if (languages && Array.isArray(languages) && languages.length > 0) { - // Filter seekers who have any of the selected languages - whereClause.languages = { - hasSome: languages - }; - // Languages filter applied - } - - if (gender) { - whereClause.gender = gender; - // Gender filter applied - } - - if (isDemanded !== undefined) { - whereClause.isDemanded = isDemanded === 'true' || isDemanded === true; - // IsDemanded filter applied - } - - // Calculate pagination - const skip = (parseInt(page) - 1) * parseInt(limit); - const take = parseInt(limit); - - // Get total count for pagination - const totalCount = await prisma.seeker.count({ - where: whereClause, - }); - - // Get seekers with pagination and filtering - const seekers = await prisma.seeker.findMany({ - where: whereClause, - orderBy: { createdAt: 'desc' }, - skip, - take, - }); - - // Seekers retrieved successfully - - // Translate city names for all seekers - const seekersWithTranslatedCities = await Promise.all( - seekers.map(async (seeker) => ({ - ...seeker, - city: await translateCityName(seeker.city, lang) - })) - ); - - // Calculate pagination info - const totalPages = Math.ceil(totalCount / take); - - return { - seekers: seekersWithTranslatedCities, - pagination: { - currentPage: parseInt(page), - totalPages, - totalCount, - hasNextPage: parseInt(page) < totalPages, - hasPrevPage: parseInt(page) > 1, - } - }; + const { + page = 1, + limit = 10, + city, + category, + employment, + documentType, + languages, + gender, + isDemanded, + lang = 'ru', + } = query; + + // Service processing query + + // Build where clause for filtering + const whereClause = { + isActive: true, + }; + + if (city) { + whereClause.city = city; + // City filter applied + } + + if (category) { + whereClause.category = category; + // Category filter applied + } + + if (employment) { + whereClause.employment = employment; + // Employment filter applied + } + + if (documentType) { + whereClause.documentType = documentType; + // Document type filter applied + } + + if (languages && Array.isArray(languages) && languages.length > 0) { + // Filter seekers who have any of the selected languages + whereClause.languages = { + hasSome: languages, + }; + // Languages filter applied + } + + if (gender) { + whereClause.gender = gender; + // Gender filter applied + } + + if (isDemanded !== undefined) { + whereClause.isDemanded = isDemanded === 'true' || isDemanded === true; + // IsDemanded filter applied + } + + // Calculate pagination + const skip = (parseInt(page) - 1) * parseInt(limit); + const take = parseInt(limit); + + // Get total count for pagination + const totalCount = await prisma.seeker.count({ + where: whereClause, + }); + + // Get seekers with pagination and filtering + const seekers = await prisma.seeker.findMany({ + where: whereClause, + orderBy: { createdAt: 'desc' }, + skip, + take, + }); + + // Seekers retrieved successfully + + // Translate city names for all seekers + const seekersWithTranslatedCities = await Promise.all( + seekers.map(async (seeker) => ({ + ...seeker, + city: await translateCityName(seeker.city, lang), + })), + ); + + // Calculate pagination info + const totalPages = Math.ceil(totalCount / take); + + return { + seekers: seekersWithTranslatedCities, + pagination: { + currentPage: parseInt(page), + totalPages, + totalCount, + hasNextPage: parseInt(page) < totalPages, + hasPrevPage: parseInt(page) > 1, + }, + }; } export async function createSeeker(data) { - const { name, contact, city, description, gender, isDemanded, facebook, languages, nativeLanguage, category, employment, documents, announcement, note, documentType } = data; - - // Create the seeker - const seeker = await prisma.seeker.create({ - data: { - name, - contact, - city, - description, - gender, - isDemanded, - facebook, - languages, - nativeLanguage, - category, - employment, - documents, - announcement, - note, - documentType, - slug: generateSlug(name, description) - } - }); - - // Send notification to all users about the new candidate - try { - await sendSingleCandidateNotification(seeker); - } catch (error) { - console.error('❌ Failed to send notification for new candidate:', error); - // Don't fail the seeker creation if notification fails - } - - return seeker; + const { + name, + contact, + city, + description, + gender, + isDemanded, + facebook, + languages, + nativeLanguage, + category, + employment, + documents, + announcement, + note, + documentType, + } = data; + + // Create the seeker + const seeker = await prisma.seeker.create({ + data: { + name, + contact, + city, + description, + gender, + isDemanded, + facebook, + languages, + nativeLanguage, + category, + employment, + documents, + announcement, + note, + documentType, + slug: generateSlug(name, description), + }, + }); + + // Send notification to all users about the new candidate + try { + await sendSingleCandidateNotification(seeker); + } catch (error) { + console.error('❌ Failed to send notification for new candidate:', error); + // Don't fail the seeker creation if notification fails + } + + return seeker; } export async function getSeekerBySlug(slug) { - return prisma.seeker.findUnique({ where: { slug } }); + return prisma.seeker.findUnique({ where: { slug } }); } export async function deleteSeeker(id) { - return prisma.seeker.delete({ where: { id: Number(id) } }); + return prisma.seeker.delete({ where: { id: Number(id) } }); } export async function getSeekerById(id) { - return prisma.seeker.findUnique({ where: { id: Number(id) } }); -} \ No newline at end of file + return prisma.seeker.findUnique({ where: { id: Number(id) } }); +} diff --git a/apps/api/services/snsService.js b/apps/api/services/snsService.js index 3265dcd..58c4e96 100644 --- a/apps/api/services/snsService.js +++ b/apps/api/services/snsService.js @@ -1,22 +1,25 @@ -import AWS from 'aws-sdk'; import { PrismaClient } from '@prisma/client'; -import { sendEmail } from '../utils/mailer.js'; +import AWS from 'aws-sdk'; import { Resend } from 'resend'; +import { sendEmail } from '../utils/mailer.js'; const prisma = new PrismaClient(); const resend = new Resend(process.env.RESEND_API_KEY); // Configure AWS -const hasAwsCredentials = process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY; +const hasAwsCredentials = + process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY; if (hasAwsCredentials) { - AWS.config.update({ - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, - region: process.env.AWS_REGION || 'us-east-1' - }); + AWS.config.update({ + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + region: process.env.AWS_REGION || 'us-east-1', + }); } else { - console.log('⚠️ AWS credentials not found. SNS email verification will be simulated for development.'); + console.log( + '⚠️ AWS credentials not found. SNS email verification will be simulated for development.', + ); } const sns = hasAwsCredentials ? new AWS.SNS() : null; @@ -25,21 +28,21 @@ const sns = hasAwsCredentials ? new AWS.SNS() : null; * Generate a random verification code */ export function generateVerificationCode() { - return Math.floor(100000 + Math.random() * 900000).toString(); + return Math.floor(100000 + Math.random() * 900000).toString(); } /** * Send verification code via email using Resend with Gmail fallback */ export async function sendVerificationCode(email, code) { - try { - // Try Resend first (if available) - if (process.env.RESEND_API_KEY) { - console.log('📧 Attempting to send verification code via Resend...'); - - try { - const emailSubject = 'WorkNow - Код подтверждения'; - const emailContent = ` + try { + // Try Resend first (if available) + if (process.env.RESEND_API_KEY) { + console.log('📧 Attempting to send verification code via Resend...'); + + try { + const emailSubject = 'WorkNow - Код подтверждения'; + const emailContent = `

WORKNOW

@@ -60,27 +63,30 @@ export async function sendVerificationCode(email, code) {
`; - - const result = await resend.emails.send({ - from: 'WorkNow ', - to: email, - subject: emailSubject, - html: emailContent - }); - - console.log('✅ Verification code sent via Resend:', email); - return { success: true, messageId: result.id || 'resend-' + Date.now() }; - } catch (resendError) { - console.error('❌ Resend failed, trying Gmail fallback:', resendError); - } - } - - // Try Gmail fallback - console.log('📧 Attempting to send verification code via Gmail...'); - - try { - const emailSubject = 'WorkNow - Код подтверждения'; - const emailContent = ` + + const result = await resend.emails.send({ + from: 'WorkNow ', + to: email, + subject: emailSubject, + html: emailContent, + }); + + console.log('✅ Verification code sent via Resend:', email); + return { + success: true, + messageId: result.id || 'resend-' + Date.now(), + }; + } catch (resendError) { + console.error('❌ Resend failed, trying Gmail fallback:', resendError); + } + } + + // Try Gmail fallback + console.log('📧 Attempting to send verification code via Gmail...'); + + try { + const emailSubject = 'WorkNow - Код подтверждения'; + const emailContent = `

WORKNOW

@@ -101,47 +107,55 @@ export async function sendVerificationCode(email, code) {
`; - - await sendEmail(email, emailSubject, emailContent); - console.log('✅ Verification code sent via Gmail fallback:', email); - return { success: true, messageId: 'gmail-fallback-' + Date.now() }; - } catch (gmailError) { - console.error('❌ Gmail fallback failed:', gmailError); - console.log('📧 [DEV MODE] Verification code would be sent to:', email); - console.log('📧 [DEV MODE] Verification code:', code); - console.log('📧 [DEV MODE] In production, this would be sent via AWS SNS'); - console.log('🔢 FOR TESTING - Your verification code is:', code); - console.log('📧 Email address:', email); - - // Simulate a delay to mimic real email sending - await new Promise(resolve => setTimeout(resolve, 1000)); - - return { success: true, messageId: 'dev-simulation-' + Date.now() }; - } - - // Create SNS topic for email notifications - const topicName = 'worknow-email-verification'; - - // Try to create topic or get existing one - let topicArn; - try { - const createTopicResult = await sns.createTopic({ Name: topicName }).promise(); - topicArn = createTopicResult.TopicArn; - } catch (error) { - if (error.code === 'AlreadyExistsException') { - // Topic already exists, get its ARN - const listTopicsResult = await sns.listTopics().promise(); - const topic = listTopicsResult.Topics.find(t => t.TopicArn.includes(topicName)); - topicArn = topic.TopicArn; - } else if (error.code === 'AuthorizationError') { - // Handle authorization error gracefully - try Resend or Gmail fallback - console.log('⚠️ AWS SNS Authorization Error. Trying Resend/Gmail fallback...'); - - // Try Resend first - if (process.env.RESEND_API_KEY) { - try { - const emailSubject = 'WorkNow - Код подтверждения'; - const emailContent = ` + + await sendEmail(email, emailSubject, emailContent); + console.log('✅ Verification code sent via Gmail fallback:', email); + return { success: true, messageId: 'gmail-fallback-' + Date.now() }; + } catch (gmailError) { + console.error('❌ Gmail fallback failed:', gmailError); + console.log('📧 [DEV MODE] Verification code would be sent to:', email); + console.log('📧 [DEV MODE] Verification code:', code); + console.log( + '📧 [DEV MODE] In production, this would be sent via AWS SNS', + ); + console.log('🔢 FOR TESTING - Your verification code is:', code); + console.log('📧 Email address:', email); + + // Simulate a delay to mimic real email sending + await new Promise((resolve) => setTimeout(resolve, 1000)); + + return { success: true, messageId: 'dev-simulation-' + Date.now() }; + } + + // Create SNS topic for email notifications + const topicName = 'worknow-email-verification'; + + // Try to create topic or get existing one + let topicArn; + try { + const createTopicResult = await sns + .createTopic({ Name: topicName }) + .promise(); + topicArn = createTopicResult.TopicArn; + } catch (error) { + if (error.code === 'AlreadyExistsException') { + // Topic already exists, get its ARN + const listTopicsResult = await sns.listTopics().promise(); + const topic = listTopicsResult.Topics.find((t) => + t.TopicArn.includes(topicName), + ); + topicArn = topic.TopicArn; + } else if (error.code === 'AuthorizationError') { + // Handle authorization error gracefully - try Resend or Gmail fallback + console.log( + '⚠️ AWS SNS Authorization Error. Trying Resend/Gmail fallback...', + ); + + // Try Resend first + if (process.env.RESEND_API_KEY) { + try { + const emailSubject = 'WorkNow - Код подтверждения'; + const emailContent = `

WORKNOW

@@ -162,25 +176,31 @@ export async function sendVerificationCode(email, code) {
`; - - const result = await resend.emails.send({ - from: 'WorkNow ', - to: email, - subject: emailSubject, - html: emailContent - }); - - console.log('✅ Verification code sent via Resend after SNS error:', email); - return { success: true, messageId: result.id || 'resend-' + Date.now() }; - } catch (resendError) { - console.error('❌ Resend also failed, trying Gmail:', resendError); - } - } - - // Try Gmail fallback - try { - const emailSubject = 'WorkNow - Код подтверждения'; - const emailContent = ` + + const result = await resend.emails.send({ + from: 'WorkNow ', + to: email, + subject: emailSubject, + html: emailContent, + }); + + console.log( + '✅ Verification code sent via Resend after SNS error:', + email, + ); + return { + success: true, + messageId: result.id || 'resend-' + Date.now(), + }; + } catch (resendError) { + console.error('❌ Resend also failed, trying Gmail:', resendError); + } + } + + // Try Gmail fallback + try { + const emailSubject = 'WorkNow - Код подтверждения'; + const emailContent = `

WORKNOW

@@ -201,163 +221,171 @@ export async function sendVerificationCode(email, code) {
`; - - await sendEmail(email, emailSubject, emailContent); - console.log('✅ Verification code sent via Gmail fallback after SNS error:', email); - return { success: true, messageId: 'gmail-fallback-' + Date.now() }; - } catch (gmailError) { - console.error('❌ Gmail fallback also failed:', gmailError); - console.log('📧 [DEV MODE] Would create SNS topic:', topicName); - console.log('📧 [DEV MODE] Would send email to:', email); - console.log('📧 [DEV MODE] Verification code:', code); - console.log('🔢 FOR TESTING - Your verification code is:', code); - console.log('📧 Email address:', email); - - // Simulate successful email sending - return { success: true, messageId: 'dev-simulation-' + Date.now() }; - } - } else { - throw error; - } - } - - // Subscribe email to topic - try { - await sns.subscribe({ - TopicArn: topicArn, - Protocol: 'email', - Endpoint: email - }).promise(); - } catch (subscribeError) { - // If already subscribed, that's fine - if (subscribeError.code !== 'AlreadySubscribedException') { - throw subscribeError; - } - } - - // Send verification message - const message = `Ваш код подтверждения для подписки на рассылку WorkNow: ${code}. Код действителен в течение 10 минут.`; - - const publishResult = await sns.publish({ - TopicArn: topicArn, - Subject: 'WorkNow - Код подтверждения', - Message: message - }).promise(); - - console.log('✅ Verification code sent via SNS:', email); - return { success: true, messageId: publishResult.MessageId }; - - } catch (error) { - console.error('❌ Error sending verification code via SNS:', error); - throw error; - } + + await sendEmail(email, emailSubject, emailContent); + console.log( + '✅ Verification code sent via Gmail fallback after SNS error:', + email, + ); + return { success: true, messageId: 'gmail-fallback-' + Date.now() }; + } catch (gmailError) { + console.error('❌ Gmail fallback also failed:', gmailError); + console.log('📧 [DEV MODE] Would create SNS topic:', topicName); + console.log('📧 [DEV MODE] Would send email to:', email); + console.log('📧 [DEV MODE] Verification code:', code); + console.log('🔢 FOR TESTING - Your verification code is:', code); + console.log('📧 Email address:', email); + + // Simulate successful email sending + return { success: true, messageId: 'dev-simulation-' + Date.now() }; + } + } else { + throw error; + } + } + + // Subscribe email to topic + try { + await sns + .subscribe({ + TopicArn: topicArn, + Protocol: 'email', + Endpoint: email, + }) + .promise(); + } catch (subscribeError) { + // If already subscribed, that's fine + if (subscribeError.code !== 'AlreadySubscribedException') { + throw subscribeError; + } + } + + // Send verification message + const message = `Ваш код подтверждения для подписки на рассылку WorkNow: ${code}. Код действителен в течение 10 минут.`; + + const publishResult = await sns + .publish({ + TopicArn: topicArn, + Subject: 'WorkNow - Код подтверждения', + Message: message, + }) + .promise(); + + console.log('✅ Verification code sent via SNS:', email); + return { success: true, messageId: publishResult.MessageId }; + } catch (error) { + console.error('❌ Error sending verification code via SNS:', error); + throw error; + } } /** * Store verification code in database */ export async function storeVerificationCode(email, code) { - try { - // Store verification code with expiration (10 minutes) - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes from now - - await prisma.newsletterVerification.upsert({ - where: { email }, - update: { - code, - expiresAt, - attempts: 0 - }, - create: { - email, - code, - expiresAt, - attempts: 0 - } - }); - - console.log('✅ Verification code stored for:', email); - return true; - } catch (error) { - console.error('❌ Error storing verification code:', error); - throw error; - } + try { + // Store verification code with expiration (10 minutes) + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes from now + + await prisma.newsletterVerification.upsert({ + where: { email }, + update: { + code, + expiresAt, + attempts: 0, + }, + create: { + email, + code, + expiresAt, + attempts: 0, + }, + }); + + console.log('✅ Verification code stored for:', email); + return true; + } catch (error) { + console.error('❌ Error storing verification code:', error); + throw error; + } } /** * Verify the provided code */ export async function verifyCode(email, providedCode) { - try { - const verification = await prisma.newsletterVerification.findUnique({ - where: { email } - }); - - if (!verification) { - return { valid: false, message: 'Verification code not found' }; - } - - // Check if code has expired - if (new Date() > verification.expiresAt) { - // Delete expired verification - await prisma.newsletterVerification.delete({ - where: { email } - }); - return { valid: false, message: 'Verification code has expired' }; - } - - // Check if too many attempts - if (verification.attempts >= 3) { - // Delete verification after too many attempts - await prisma.newsletterVerification.delete({ - where: { email } - }); - return { valid: false, message: 'Too many attempts. Please request a new code.' }; - } - - // Increment attempts - await prisma.newsletterVerification.update({ - where: { email }, - data: { attempts: verification.attempts + 1 } - }); - - // Check if code matches - if (verification.code === providedCode) { - // Delete verification after successful verification - await prisma.newsletterVerification.delete({ - where: { email } - }); - return { valid: true, message: 'Verification successful' }; - } else { - return { valid: false, message: 'Invalid verification code' }; - } - - } catch (error) { - console.error('❌ Error verifying code:', error); - throw error; - } + try { + const verification = await prisma.newsletterVerification.findUnique({ + where: { email }, + }); + + if (!verification) { + return { valid: false, message: 'Verification code not found' }; + } + + // Check if code has expired + if (new Date() > verification.expiresAt) { + // Delete expired verification + await prisma.newsletterVerification.delete({ + where: { email }, + }); + return { valid: false, message: 'Verification code has expired' }; + } + + // Check if too many attempts + if (verification.attempts >= 3) { + // Delete verification after too many attempts + await prisma.newsletterVerification.delete({ + where: { email }, + }); + return { + valid: false, + message: 'Too many attempts. Please request a new code.', + }; + } + + // Increment attempts + await prisma.newsletterVerification.update({ + where: { email }, + data: { attempts: verification.attempts + 1 }, + }); + + // Check if code matches + if (verification.code === providedCode) { + // Delete verification after successful verification + await prisma.newsletterVerification.delete({ + where: { email }, + }); + return { valid: true, message: 'Verification successful' }; + } else { + return { valid: false, message: 'Invalid verification code' }; + } + } catch (error) { + console.error('❌ Error verifying code:', error); + throw error; + } } /** * Clean up expired verification codes */ export async function cleanupExpiredVerifications() { - try { - const result = await prisma.newsletterVerification.deleteMany({ - where: { - expiresAt: { - lt: new Date() - } - } - }); - - if (result.count > 0) { - console.log(`✅ Cleaned up ${result.count} expired verification codes`); - } - - return result.count; - } catch (error) { - console.error('❌ Error cleaning up expired verifications:', error); - throw error; - } -} \ No newline at end of file + try { + const result = await prisma.newsletterVerification.deleteMany({ + where: { + expiresAt: { + lt: new Date(), + }, + }, + }); + + if (result.count > 0) { + console.log(`✅ Cleaned up ${result.count} expired verification codes`); + } + + return result.count; + } catch (error) { + console.error('❌ Error cleaning up expired verifications:', error); + throw error; + } +} diff --git a/apps/api/services/updateUserService.js b/apps/api/services/updateUserService.js index 87f61a3..d88ac1d 100755 --- a/apps/api/services/updateUserService.js +++ b/apps/api/services/updateUserService.js @@ -3,17 +3,20 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); export const getUserByClerkIdService = async (clerkUserId) => { - try { - const user = await prisma.user.findUnique({ - where: { clerkUserId }, - }); + try { + const user = await prisma.user.findUnique({ + where: { clerkUserId }, + }); - if (!user) { - return { error: 'Пользователь не найден' }; - } + if (!user) { + return { error: 'Пользователь не найден' }; + } - return { user }; - } catch (error) { - return { error: 'Ошибка получения данных пользователя', details: error.message }; - } + return { user }; + } catch (error) { + return { + error: 'Ошибка получения данных пользователя', + details: error.message, + }; + } }; diff --git a/apps/api/services/userService.js b/apps/api/services/userService.js index 4cce2ef..f502b41 100755 --- a/apps/api/services/userService.js +++ b/apps/api/services/userService.js @@ -1,7 +1,7 @@ /* eslint-disable no-undef */ import { PrismaClient } from '@prisma/client'; -import fetch from 'node-fetch'; import dotenv from 'dotenv'; +import fetch from 'node-fetch'; import { Webhook } from 'svix'; dotenv.config({ path: '.env.local' }); @@ -11,183 +11,209 @@ const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY; const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; console.log('🔍 UserService - CLERK_SECRET_KEY available:', !!CLERK_SECRET_KEY); -console.log('🔍 UserService - CLERK_SECRET_KEY length:', CLERK_SECRET_KEY ? CLERK_SECRET_KEY.length : 0); +console.log( + '🔍 UserService - CLERK_SECRET_KEY length:', + CLERK_SECRET_KEY ? CLERK_SECRET_KEY.length : 0, +); export const syncUserService = async (clerkUserId) => { - if (!clerkUserId) return { error: 'Missing Clerk user ID' }; - - try { - console.log('🔍 UserService - Starting sync for clerkUserId:', clerkUserId); - - if (!CLERK_SECRET_KEY) { - console.error('❌ UserService - CLERK_SECRET_KEY is missing'); - return { error: 'Clerk secret key is not configured' }; - } - - let user = await prisma.user.findUnique({ where: { clerkUserId } }); - - if (!user) { - console.log('🔍 UserService - User not found in DB, fetching from Clerk...'); - - console.log('🔍 UserService - Making API call to Clerk with key:', CLERK_SECRET_KEY.substring(0, 10) + '...'); - - const response = await fetch(`https://api.clerk.com/v1/users/${clerkUserId}`, { - headers: { - 'Authorization': `Bearer ${CLERK_SECRET_KEY}`, - 'Content-Type': 'application/json' - } - }); - - console.log('🔍 UserService - Clerk API response status:', response.status); - - if (!response.ok) { - const errorText = await response.text(); - console.error('❌ UserService - Clerk API error:', response.status, errorText); - return { error: `Ошибка Clerk API: ${response.status} ${response.statusText}` }; - } - - const clerkUser = await response.json(); - console.log('🔍 UserService - Clerk user data received:', { - id: clerkUser.id, - email: clerkUser.email_addresses?.[0]?.email_address, - firstName: clerkUser.first_name, - lastName: clerkUser.last_name - }); - - user = await prisma.user.upsert({ - where: { clerkUserId: clerkUser.id }, // ✅ Add this - update: { // ✅ Add this section - email: clerkUser.email_addresses[0]?.email_address || null, - firstName: clerkUser.first_name || null, - lastName: clerkUser.last_name || null, - imageUrl: clerkUser.image_url || null - }, - create: { // ✅ Add this section - clerkUserId: clerkUser.id, - email: clerkUser.email_addresses[0]?.email_address || null, - firstName: clerkUser.first_name || null, - lastName: clerkUser.last_name || null, - imageUrl: clerkUser.image_url || null - } - }); - - console.log('✅ UserService - User created successfully:', user.id); - } else { - console.log('✅ UserService - User found in DB:', user.id); - } - - return { success: true, user }; - } catch (error) { - console.error('❌ UserService - Error syncing user:', error); - return { error: 'Failed to sync user', details: error.message }; - } + if (!clerkUserId) return { error: 'Missing Clerk user ID' }; + + try { + console.log('🔍 UserService - Starting sync for clerkUserId:', clerkUserId); + + if (!CLERK_SECRET_KEY) { + console.error('❌ UserService - CLERK_SECRET_KEY is missing'); + return { error: 'Clerk secret key is not configured' }; + } + + let user = await prisma.user.findUnique({ where: { clerkUserId } }); + + if (!user) { + console.log( + '🔍 UserService - User not found in DB, fetching from Clerk...', + ); + + console.log( + '🔍 UserService - Making API call to Clerk with key:', + CLERK_SECRET_KEY.substring(0, 10) + '...', + ); + + const response = await fetch( + `https://api.clerk.com/v1/users/${clerkUserId}`, + { + headers: { + Authorization: `Bearer ${CLERK_SECRET_KEY}`, + 'Content-Type': 'application/json', + }, + }, + ); + + console.log( + '🔍 UserService - Clerk API response status:', + response.status, + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error( + '❌ UserService - Clerk API error:', + response.status, + errorText, + ); + return { + error: `Ошибка Clerk API: ${response.status} ${response.statusText}`, + }; + } + + const clerkUser = await response.json(); + console.log('🔍 UserService - Clerk user data received:', { + id: clerkUser.id, + email: clerkUser.email_addresses?.[0]?.email_address, + firstName: clerkUser.first_name, + lastName: clerkUser.last_name, + }); + + user = await prisma.user.upsert({ + where: { clerkUserId: clerkUser.id }, // ✅ Add this + update: { + // ✅ Add this section + email: clerkUser.email_addresses[0]?.email_address || null, + firstName: clerkUser.first_name || null, + lastName: clerkUser.last_name || null, + imageUrl: clerkUser.image_url || null, + }, + create: { + // ✅ Add this section + clerkUserId: clerkUser.id, + email: clerkUser.email_addresses[0]?.email_address || null, + firstName: clerkUser.first_name || null, + lastName: clerkUser.last_name || null, + imageUrl: clerkUser.image_url || null, + }, + }); + + console.log('✅ UserService - User created successfully:', user.id); + } else { + console.log('✅ UserService - User found in DB:', user.id); + } + + return { success: true, user }; + } catch (error) { + console.error('❌ UserService - Error syncing user:', error); + return { error: 'Failed to sync user', details: error.message }; + } }; export const getUserByClerkIdService = async (clerkUserId) => { - try { - const user = await prisma.user.findUnique({ where: { clerkUserId } }); - if (!user) return { error: 'Пользователь не найден' }; - return { user }; - } catch (error) { - return { error: 'Ошибка получения пользователя', details: error.message }; - } + try { + const user = await prisma.user.findUnique({ where: { clerkUserId } }); + if (!user) return { error: 'Пользователь не найден' }; + return { user }; + } catch (error) { + return { error: 'Ошибка получения пользователя', details: error.message }; + } }; export const getUserJobsService = async (clerkUserId, query) => { - - const { page = 1, limit = 5 } = query; - const pageInt = parseInt(page); - const limitInt = parseInt(limit); - const skip = (pageInt - 1) * limitInt; - - try { - let user = await prisma.user.findUnique({ where: { clerkUserId } }); - - if (!user) { - const syncResult = await syncUserService(clerkUserId); - if (syncResult.error) { - return { error: "Ошибка синхронизации пользователя", details: syncResult.error }; - } - user = syncResult.user; - } - - - const jobs = await prisma.job.findMany({ - where: { userId: user.id }, - include: { city: true, user: true, category: { include: { translations: true } } }, - skip, - take: limitInt, - orderBy: { createdAt: "desc" }, - }); - - const totalJobs = await prisma.job.count({ where: { userId: user.id } }); - - return { - jobs, - totalJobs, - totalPages: Math.ceil(totalJobs / limitInt), - currentPage: pageInt, - }; - } catch (error) { - console.error("❌ Ошибка получения объявлений пользователя:", error); - return { error: "Ошибка сервера", details: error.message }; - } + const { page = 1, limit = 5 } = query; + const pageInt = parseInt(page); + const limitInt = parseInt(limit); + const skip = (pageInt - 1) * limitInt; + + try { + let user = await prisma.user.findUnique({ where: { clerkUserId } }); + + if (!user) { + const syncResult = await syncUserService(clerkUserId); + if (syncResult.error) { + return { + error: 'Ошибка синхронизации пользователя', + details: syncResult.error, + }; + } + user = syncResult.user; + } + + const jobs = await prisma.job.findMany({ + where: { userId: user.id }, + include: { + city: true, + user: true, + category: { include: { translations: true } }, + }, + skip, + take: limitInt, + orderBy: { createdAt: 'desc' }, + }); + + const totalJobs = await prisma.job.count({ where: { userId: user.id } }); + + return { + jobs, + totalJobs, + totalPages: Math.ceil(totalJobs / limitInt), + currentPage: pageInt, + }; + } catch (error) { + console.error('❌ Ошибка получения объявлений пользователя:', error); + return { error: 'Ошибка сервера', details: error.message }; + } }; - // Обработка Clerk Webhook export const handleClerkWebhookService = async (req) => { - const svix_id = req.headers['svix-id']; - const svix_timestamp = req.headers['svix-timestamp']; - const svix_signature = req.headers['svix-signature']; - - if (!svix_id || !svix_timestamp || !svix_signature) { - return { error: 'Missing Svix headers' }; - } - - try { - const wh = new Webhook(WEBHOOK_SECRET); - const evt = wh.verify(req.rawBody, { - 'svix-id': svix_id, - 'svix-timestamp': svix_timestamp, - 'svix-signature': svix_signature, - }); - - const userId = evt.data.id; - - if (evt.type === 'user.created' || evt.type === 'user.updated') { - const email_addresses = evt.data.email_addresses; - const first_name = evt.data.first_name; - const last_name = evt.data.last_name; - const image_url = evt.data.image_url; - - await prisma.user.upsert({ - where: { clerkUserId: userId }, - update: { - email: email_addresses[0].email_address, - firstName: first_name || null, - lastName: last_name || null, - imageUrl: image_url || null, - }, - create: { - clerkUserId: userId, - email: email_addresses[0].email_address, - firstName: first_name || null, - lastName: last_name || null, - imageUrl: image_url || null, - }, - }); - } - - if (evt.type === 'user.deleted') { - await prisma.user.delete({ - where: { clerkUserId: userId }, - }); - } - - return { success: true }; - } catch (err) { - console.error('Webhook verification failed', err); - return { error: 'Webhook verification failed' }; - } + const svix_id = req.headers['svix-id']; + const svix_timestamp = req.headers['svix-timestamp']; + const svix_signature = req.headers['svix-signature']; + + if (!svix_id || !svix_timestamp || !svix_signature) { + return { error: 'Missing Svix headers' }; + } + + try { + const wh = new Webhook(WEBHOOK_SECRET); + const evt = wh.verify(req.rawBody, { + 'svix-id': svix_id, + 'svix-timestamp': svix_timestamp, + 'svix-signature': svix_signature, + }); + + const userId = evt.data.id; + + if (evt.type === 'user.created' || evt.type === 'user.updated') { + const email_addresses = evt.data.email_addresses; + const first_name = evt.data.first_name; + const last_name = evt.data.last_name; + const image_url = evt.data.image_url; + + await prisma.user.upsert({ + where: { clerkUserId: userId }, + update: { + email: email_addresses[0].email_address, + firstName: first_name || null, + lastName: last_name || null, + imageUrl: image_url || null, + }, + create: { + clerkUserId: userId, + email: email_addresses[0].email_address, + firstName: first_name || null, + lastName: last_name || null, + imageUrl: image_url || null, + }, + }); + } + + if (evt.type === 'user.deleted') { + await prisma.user.delete({ + where: { clerkUserId: userId }, + }); + } + + return { success: true }; + } catch (err) { + console.error('Webhook verification failed', err); + return { error: 'Webhook verification failed' }; + } }; diff --git a/apps/api/services/userSyncService.js b/apps/api/services/userSyncService.js index ed9e770..8202be2 100755 --- a/apps/api/services/userSyncService.js +++ b/apps/api/services/userSyncService.js @@ -1,6 +1,6 @@ import { PrismaClient } from '@prisma/client'; -import fetch from 'node-fetch'; import dotenv from 'dotenv'; +import fetch from 'node-fetch'; dotenv.config({ path: '.env.local' }); dotenv.config(); @@ -9,65 +9,83 @@ const prisma = new PrismaClient(); // eslint-disable-next-line no-undef const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY; -console.log('🔍 UserSyncService - CLERK_SECRET_KEY available:', !!CLERK_SECRET_KEY); +console.log( + '🔍 UserSyncService - CLERK_SECRET_KEY available:', + !!CLERK_SECRET_KEY, +); export const syncUserService = async (clerkUserId) => { - try { - console.log('🔍 UserSyncService - Starting sync for clerkUserId:', clerkUserId); - - if (!CLERK_SECRET_KEY) { - console.error('❌ UserSyncService - CLERK_SECRET_KEY is missing'); - return { error: 'Clerk secret key is not configured' }; - } + try { + console.log( + '🔍 UserSyncService - Starting sync for clerkUserId:', + clerkUserId, + ); + + if (!CLERK_SECRET_KEY) { + console.error('❌ UserSyncService - CLERK_SECRET_KEY is missing'); + return { error: 'Clerk secret key is not configured' }; + } - // Получаем актуальные данные из Clerk - console.log('🔍 UserSyncService - Fetching user data from Clerk API...'); - const response = await fetch(`https://api.clerk.com/v1/users/${clerkUserId}`, { - headers: { - 'Authorization': `Bearer ${CLERK_SECRET_KEY}`, - 'Content-Type': 'application/json' - } - }); + // Получаем актуальные данные из Clerk + console.log('🔍 UserSyncService - Fetching user data from Clerk API...'); + const response = await fetch( + `https://api.clerk.com/v1/users/${clerkUserId}`, + { + headers: { + Authorization: `Bearer ${CLERK_SECRET_KEY}`, + 'Content-Type': 'application/json', + }, + }, + ); - console.log('🔍 UserSyncService - Clerk API response status:', response.status); + console.log( + '🔍 UserSyncService - Clerk API response status:', + response.status, + ); - if (!response.ok) { - const errorText = await response.text(); - console.error('❌ UserSyncService - Clerk API error:', response.status, errorText); - return { error: `Ошибка Clerk API: ${response.status} ${response.statusText}` }; - } + if (!response.ok) { + const errorText = await response.text(); + console.error( + '❌ UserSyncService - Clerk API error:', + response.status, + errorText, + ); + return { + error: `Ошибка Clerk API: ${response.status} ${response.statusText}`, + }; + } - const clerkUser = await response.json(); - console.log('🔍 UserSyncService - Clerk user data received:', { - id: clerkUser.id, - email: clerkUser.email_addresses?.[0]?.email_address, - firstName: clerkUser.first_name, - lastName: clerkUser.last_name - }); + const clerkUser = await response.json(); + console.log('🔍 UserSyncService - Clerk user data received:', { + id: clerkUser.id, + email: clerkUser.email_addresses?.[0]?.email_address, + firstName: clerkUser.first_name, + lastName: clerkUser.last_name, + }); - // Обновляем или создаём пользователя в базе - console.log('🔍 UserSyncService - Upserting user in database...'); - const user = await prisma.user.upsert({ - where: { clerkUserId }, - update: { - email: clerkUser.email_addresses[0]?.email_address || null, - firstName: clerkUser.first_name || null, - lastName: clerkUser.last_name || null, - imageUrl: clerkUser.image_url || null - }, - create: { - clerkUserId: clerkUser.id, - email: clerkUser.email_addresses[0]?.email_address || null, - firstName: clerkUser.first_name || null, - lastName: clerkUser.last_name || null, - imageUrl: clerkUser.image_url || null - } - }); + // Обновляем или создаём пользователя в базе + console.log('🔍 UserSyncService - Upserting user in database...'); + const user = await prisma.user.upsert({ + where: { clerkUserId }, + update: { + email: clerkUser.email_addresses[0]?.email_address || null, + firstName: clerkUser.first_name || null, + lastName: clerkUser.last_name || null, + imageUrl: clerkUser.image_url || null, + }, + create: { + clerkUserId: clerkUser.id, + email: clerkUser.email_addresses[0]?.email_address || null, + firstName: clerkUser.first_name || null, + lastName: clerkUser.last_name || null, + imageUrl: clerkUser.image_url || null, + }, + }); - console.log('✅ UserSyncService - User synced successfully:', user.id); - return { success: true, user }; - } catch (error) { - console.error('❌ UserSyncService - Error syncing user:', error); - return { error: 'Failed to sync user', details: error.message }; - } + console.log('✅ UserSyncService - User synced successfully:', user.id); + return { success: true, user }; + } catch (error) { + console.error('❌ UserSyncService - Error syncing user:', error); + return { error: 'Failed to sync user', details: error.message }; + } }; diff --git a/apps/api/services/webhookService.js b/apps/api/services/webhookService.js index 80a2c9b..cef43b6 100755 --- a/apps/api/services/webhookService.js +++ b/apps/api/services/webhookService.js @@ -3,39 +3,39 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); export const processClerkWebhookService = async (evt) => { - const userId = evt.data.id; + const userId = evt.data.id; - try { - if (evt.type === 'user.created' || evt.type === 'user.updated') { - const { email_addresses, first_name, last_name, image_url } = evt.data; + try { + if (evt.type === 'user.created' || evt.type === 'user.updated') { + const { email_addresses, first_name, last_name, image_url } = evt.data; - await prisma.user.upsert({ - where: { clerkUserId: userId }, - update: { - email: email_addresses[0].email_address, - firstName: first_name || null, - lastName: last_name || null, - imageUrl: image_url || null, - }, - create: { - clerkUserId: userId, - email: email_addresses[0].email_address, - firstName: first_name || null, - lastName: last_name || null, - imageUrl: image_url || null, - }, - }); - } + await prisma.user.upsert({ + where: { clerkUserId: userId }, + update: { + email: email_addresses[0].email_address, + firstName: first_name || null, + lastName: last_name || null, + imageUrl: image_url || null, + }, + create: { + clerkUserId: userId, + email: email_addresses[0].email_address, + firstName: first_name || null, + lastName: last_name || null, + imageUrl: image_url || null, + }, + }); + } - if (evt.type === 'user.deleted') { - await prisma.user.delete({ - where: { clerkUserId: userId }, - }); - } + if (evt.type === 'user.deleted') { + await prisma.user.delete({ + where: { clerkUserId: userId }, + }); + } - return { success: true }; - } catch (error) { - console.error('Ошибка обработки вебхука:', error); - return { error: 'Ошибка обработки вебхука', details: error.message }; - } + return { success: true }; + } catch (error) { + console.error('Ошибка обработки вебхука:', error); + return { error: 'Ошибка обработки вебхука', details: error.message }; + } }; diff --git a/apps/api/utils/attachJobsToUsers.js b/apps/api/utils/attachJobsToUsers.js index 5d4244c..1a0b129 100755 --- a/apps/api/utils/attachJobsToUsers.js +++ b/apps/api/utils/attachJobsToUsers.js @@ -1,7 +1,9 @@ import pkg from '@prisma/client'; + const { PrismaClient } = pkg; -import { createFakeUser } from './fakeUsers.js'; + import { faker } from '@faker-js/faker'; +import { createFakeUser } from './fakeUsers.js'; const prisma = new PrismaClient(); @@ -9,34 +11,39 @@ const prisma = new PrismaClient(); * Функция для распределения вакансий среди фейковых пользователей */ export const assignJobsToFakeUsers = async (jobs) => { - - for (let job of jobs) { - try { - let fakeUser = await prisma.user.findFirst({ - where: { clerkUserId: { startsWith: 'user_' } }, - orderBy: { jobs: { _count: 'asc' } }, // Балансируем вакансии - include: { jobs: true } - }); - - if (!fakeUser) { - fakeUser = await createFakeUser(); - } - - await prisma.job.create({ - data: { - title: job.title, - salary: String(job.salary), - description: job.description, - phone: faker.phone.number('+972 ###-###-####'), - city: { connectOrCreate: { where: { name: job.city }, create: { name: job.city } } }, - user: { connect: { id: fakeUser.id } }, - createdAt: new Date(), - }, - }); - - } catch (error) { - console.error(`❌ Ошибка при привязке вакансии "${job.title}":`, error.message); - } - } - }; - \ No newline at end of file + for (let job of jobs) { + try { + let fakeUser = await prisma.user.findFirst({ + where: { clerkUserId: { startsWith: 'user_' } }, + orderBy: { jobs: { _count: 'asc' } }, // Балансируем вакансии + include: { jobs: true }, + }); + + if (!fakeUser) { + fakeUser = await createFakeUser(); + } + + await prisma.job.create({ + data: { + title: job.title, + salary: String(job.salary), + description: job.description, + phone: faker.phone.number('+972 ###-###-####'), + city: { + connectOrCreate: { + where: { name: job.city }, + create: { name: job.city }, + }, + }, + user: { connect: { id: fakeUser.id } }, + createdAt: new Date(), + }, + }); + } catch (error) { + console.error( + `❌ Ошибка при привязке вакансии "${job.title}":`, + error.message, + ); + } + } +}; diff --git a/apps/api/utils/badWordsList.js b/apps/api/utils/badWordsList.js index a4ec8cc..559ac78 100755 --- a/apps/api/utils/badWordsList.js +++ b/apps/api/utils/badWordsList.js @@ -1,228 +1,1220 @@ const badWordsList = [ - // Русский мат (включая производные формы) - "блядь", "блять", "сука", "ебать", "ебаный", "ебанутый", "ебучий", "ебло", - "пиздец", "пизда", "пиздюк", "хуй", "хуя", "хуйн", "хуевый", "нахуй", - "гандон", "мразь", "пидор", "пидорас", "чмо", "долбоеб", "уебок", "еблан", - "сучка", "шлюха", "гнида", "мудак", "хуесос", "залупа", "блядина", "выблядок", - "манда", "ебырь", "опущенный", "шлюшка", "пидр", "сукин сын", "хер", "засранец", - "дристун", "гнида", "обосранец", "подонок", "говноед", "ебарь", "говно", - "дрочить", "дрочер", "отсос", "минет", "анальный", "анус", "влагалище", - "пилотка", "пердун", "вонючка", "манда", "ебал", "хуятина", "хуесос", "педик", "херня", "херь", - "ебало", "ебальник", "ебануть", "ебануться", "ебать", "ебаться", "ебись", "ебнутый", - "ебнуться", "ебсти", "ебстись", "ебти", "ебтись", "ебут", "ебучий", "ебущий", - "хуйло", "хуйня", "хуйнятина", "хуйняшка", "хуйняшка", "хуйняшка", "хуйняшка", - "пиздабол", "пиздаболка", "пиздаболы", "пиздаболы", "пиздаболы", "пиздаболы", - "блядство", "блядствовать", "блядство", "блядство", "блядство", "блядство", - "сучара", "сучарка", "сучарка", "сучарка", "сучарка", "сучарка", "сучарка", - "гандон", "гандон", "гандон", "гандон", "гандон", "гандон", "гандон", "гандон", - "мудак", "мудак", "мудак", "мудак", "мудак", "мудак", "мудак", "мудак", - "говно", "говно", "говно", "говно", "говно", "говно", "говно", "говно", - "залупа", "залупа", "залупа", "залупа", "залупа", "залупа", "залупа", "залупа", - "манда", "манда", "манда", "манда", "манда", "манда", "манда", "манда", - "шлюха", "шлюха", "шлюха", "шлюха", "шлюха", "шлюха", "шлюха", "шлюха", - "гнида", "гнида", "гнида", "гнида", "гнида", "гнида", "гнида", "гнида", - "пидорас", "пидорас", "пидорас", "пидорас", "пидорас", "пидорас", "пидорас", "пидорас", - "чмо", "чмо", "чмо", "чмо", "чмо", "чмо", "чмо", "чмо", "чмо", "чмо", - "долбоеб", "долбоеб", "долбоеб", "долбоеб", "долбоеб", "долбоеб", "долбоеб", "долбоеб", - "уебок", "уебок", "уебок", "уебок", "уебок", "уебок", "уебок", "уебок", - "еблан", "еблан", "еблан", "еблан", "еблан", "еблан", "еблан", "еблан", - "сучка", "сучка", "сучка", "сучка", "сучка", "сучка", "сучка", "сучка", - "шлюшка", "шлюшка", "шлюшка", "шлюшка", "шлюшка", "шлюшка", "шлюшка", "шлюшка", - "пидр", "пидр", "пидр", "пидр", "пидр", "пидр", "пидр", "пидр", - "сукин сын", "сукин сын", "сукин сын", "сукин сын", "сукин сын", "сукин сын", "сукин сын", "сукин сын", - "хер", "хер", "хер", "хер", "хер", "хер", "хер", "хер", - "засранец", "засранец", "засранец", "засранец", "засранец", "засранец", "засранец", "засранец", - "дристун", "дристун", "дристун", "дристун", "дристун", "дристун", "дристун", "дристун", - "гнида", "гнида", "гнида", "гнида", "гнида", "гнида", "гнида", "гнида", - "обосранец", "обосранец", "обосранец", "обосранец", "обосранец", "обосранец", "обосранец", "обосранец", - "подонок", "подонок", "подонок", "подонок", "подонок", "подонок", "подонок", "подонок", - "говноед", "говноед", "говноед", "говноед", "говноед", "говноед", "говноед", "говноед", - "ебарь", "ебарь", "ебарь", "ебарь", "ебарь", "ебарь", "ебарь", "ебарь", - "говно", "говно", "говно", "говно", "говно", "говно", "говно", "говно", - "хуятина", "хуятина", "хуятина", "хуятина", "хуятина", "хуятина", "хуятина", "хуятина", - "хуесос", "хуесос", "хуесос", "хуесос", "хуесос", "хуесос", "хуесос", "хуесос", - "педик", "педик", "педик", "педик", "педик", "педик", "педик", "педик", - "херня", "херня", "херня", "херня", "херня", "херня", "херня", "херня", - "херь", "херь", "херь", "херь", "херь", "херь", "херь", "херь", - - // Английский мат (с сокращениями и альтернативными формами) - "fuck", "fucking", "fucker", "motherfucker", "mothafucka", "shit", "shitty", - "bitch", "bastard", "asshole", "dick", "dickhead", "cunt", "cock", "slut", - "whore", "hoe", "dumbass", "faggot", "fag", "nigga", "nigger", "pussy", - "retard", "moron", "jerkoff", "jackass", "suck my dick", "twat", "bollocks", - "douche", "douchebag", "arse", "arsehole", "wanker", "prick", "tosser", - "cum", "spunk", "skank", "dipshit", "nutjob", "screw you", "go fuck yourself", - "dipstick", "motherfuckin", "cocksucker", "buttfuck", "cockmunch", "shitface", - "shithead", "tits", "boobs", "booty", "dildo", "cumdumpster", "cumslut", - "semen", "gangbang", "rimjob", "deepthroat", "pecker", "jizz", "scumbag", - "ass", "asswipe", "asshat", "assclown", "assface", "asshole", "asslicker", - "ballbag", "ballsack", "bellend", "bloodclaat", "bloodclot", "bloody hell", - "bugger", "bugger off", "buggered", "buggery", "bullshit", "bum", "bumhole", - "crap", "cripple", "crippled", "cuntface", "cuntlicker", "cuntrag", "cunts", - "damn", "damned", "damnit", "darn", "darnit", "dickbag", "dickface", "dickhead", - "dickhole", "dickless", "dicks", "dicksucker", "dickwad", "dickweed", "dickwod", - "dumb", "dumbass", "dumbfuck", "dumbshit", "dumshit", "dyke", "dykes", "fag", - "faggot", "faggots", "fagot", "fagots", "fatass", "fatfuck", "fatso", "fuck", - "fucka", "fuckass", "fuckbag", "fuckboy", "fuckbrain", "fuckbutt", "fucked", - "fucker", "fuckers", "fuckface", "fuckhead", "fuckhole", "fuckin", "fucking", - "fucknut", "fuckoff", "fucks", "fuckstick", "fucktard", "fuckup", "fuckwad", - "fuckwit", "fuckwitt", "fuckwitt", "fuckwitt", "fuckwitt", "fuckwitt", "fuckwitt", - "goddamn", "goddamned", "goddamnit", "goddamnit", "goddamnit", "goddamnit", - "hell", "hells", "horseshit", "jackass", "jackasses", "jackoff", "jackshit", - "jerk", "jerkass", "jerkoff", "jerkwad", "jizz", "jizzbag", "jizzface", - "motherfucker", "motherfuckers", "motherfucking", "motherfuckin", "motherfuckin", - "nigga", "nigger", "niggers", "niglet", "niglets", "piss", "pissed", "pisser", - "pissers", "pisses", "pissflaps", "pissin", "pissing", "pissoff", "pisswhore", - "prick", "pricks", "prickteaser", "pussy", "pussies", "pussycat", "pussydestroyer", - "pussyeater", "pussyfucker", "pussylicker", "pussylips", "pussylover", "pussypounder", - "pussys", "pussytight", "pussywagon", "pussywhip", "pussywhip", "pussywhip", - "shit", "shitass", "shitbag", "shitbagger", "shitbrains", "shitbreath", "shitcanned", - "shitcunt", "shitdick", "shitface", "shitfaced", "shithead", "shitheel", "shithole", - "shithouse", "shiting", "shitings", "shits", "shitspitter", "shitstain", "shitter", - "shitters", "shittier", "shittiest", "shitting", "shittings", "shitty", "shiz", - "shiznit", "skank", "skanky", "slut", "sluts", "sluttier", "sluttiest", "slutting", - "slutty", "slutwear", "slutwhore", "smegma", "snatch", "snatches", "snatchs", - "sonofabitch", "sonofabitches", "sonofabitch", "sonofabitch", "sonofabitch", - "tard", "tards", "tart", "tits", "tittie5", "tittiefucker", "titties", "titty", - "tittyfuck", "tittyfucker", "tittywank", "titwank", "twat", "twathead", "twatish", - "twats", "twatty", "twunt", "twunter", "vag", "vagina", "vaginas", "vajina", - "vajinas", "vulva", "vulvas", "wank", "wanked", "wanker", "wankered", "wankers", - "wanking", "wanks", "wankstain", "wanky", "whore", "whorealicious", "whorebag", - "whored", "whoreface", "whorehopper", "whorehouse", "whores", "whoring", "whorish", - "whorism", "whorism", "whorism", "whorism", "whorism", "whorism", "whorism", - - // Hebrew bad words - "זבל", "בן זונה", "זונה", "כוס", "זין", "תחת", "חרא", "בן של זונה", - "זבל", "בן זונה", "זונה", "כוס", "זין", "תחת", "חרא", "בן של זונה", - - // Arabic bad words - "زبالة", "ابن الكلب", "كلب", "عرص", "زبي", "طيز", "خرا", "ابن الحرام", - "زبالة", "ابن الكلب", "كلب", "عرص", "زبي", "طيز", "خرا", "ابن الحرام", - - // English spam and scam related words - "earn money fast", "make money online", "work from home", "get rich quick", - "make $1000 daily", "earn $5000 weekly", "bitcoin investment", "crypto investment", - "forex trading", "binary options", "mlm", "multi level marketing", "pyramid scheme", - "get paid to click", "paid surveys", "work at home", "home based business", - "make money from home", "earn from home", "online income", "passive income", - "quick money", "fast cash", "easy money", "money making", "cash earning", - "work online", "online job", "home job", "remote work", "telecommute", - "earn cash", "make cash", "get cash", "quick cash", "fast money", - "money online", "online earning", "home earning", "work from anywhere", - "earn daily", "make daily", "get daily", "daily income", "daily earning", - "weekly income", "monthly income", "yearly income", "annual income", - "get rich", "become rich", "rich quick", "wealthy", "millionaire", - "billionaire", "financial freedom", "financial independence", "retire early", - "early retirement", "financial success", "money success", "wealth building", - "investment opportunity", "business opportunity", "money opportunity", - "earn opportunity", "make opportunity", "get opportunity", "cash opportunity", - "money making opportunity", "income opportunity", "earning opportunity", - "work opportunity", "job opportunity", "business opportunity", "career opportunity", - "financial opportunity", "wealth opportunity", "rich opportunity", "success opportunity", - "money making scheme", "income scheme", "earning scheme", "cash scheme", - "money scheme", "wealth scheme", "rich scheme", "success scheme", - "get paid", "paid work", "paid job", "paid opportunity", "paid scheme", - "paid program", "paid system", "paid method", "paid technique", "paid strategy", - "paid formula", "paid secret", "paid tip", "paid advice", "paid guidance", - "paid instruction", "paid tutorial", "paid course", "paid training", "paid education", - "paid learning", "paid knowledge", "paid information", "paid data", "paid facts", - "paid truth", "paid reality", "paid truth", "paid fact", "paid reality", - "paid truth", "paid fact", "paid reality", "paid truth", "paid fact", - "paid reality", "paid truth", "paid fact", "paid reality", "paid truth", - - // Russian spam and scam related words - "заработать деньги быстро", "заработать деньги онлайн", "работать из дома", "разбогатеть быстро", - "заработать 1000 долларов в день", "заработать 5000 долларов в неделю", "инвестиции в биткоин", "инвестиции в криптовалюту", - "торговля форекс", "бинарные опционы", "млм", "многоуровневый маркетинг", "пирамида", - "получать деньги за клики", "платные опросы", "работать дома", "домашний бизнес", - "зарабатывать деньги из дома", "зарабатывать из дома", "онлайн доход", "пассивный доход", - "быстрые деньги", "быстрые наличные", "легкие деньги", "заработок денег", "заработок наличных", - "работать онлайн", "онлайн работа", "домашняя работа", "удаленная работа", "телеработа", - "зарабатывать наличные", "делать наличные", "получать наличные", "быстрые наличные", "быстрые деньги", - "деньги онлайн", "онлайн заработок", "домашний заработок", "работать откуда угодно", - "зарабатывать ежедневно", "делать ежедневно", "получать ежедневно", "ежедневный доход", "ежедневный заработок", - "еженедельный доход", "ежемесячный доход", "годовой доход", "годовой заработок", - "разбогатеть", "стать богатым", "быстро разбогатеть", "богатый", "миллионер", - "миллиардер", "финансовая свобода", "финансовая независимость", "ранний выход на пенсию", - "ранняя пенсия", "финансовый успех", "успех в деньгах", "создание богатства", - "инвестиционная возможность", "бизнес возможность", "денежная возможность", - "возможность заработать", "возможность сделать", "возможность получить", "возможность наличных", - "возможность заработать деньги", "возможность дохода", "возможность заработка", - "возможность работы", "возможность работы", "бизнес возможность", "карьерная возможность", - "финансовая возможность", "возможность богатства", "возможность богатства", "возможность успеха", - "схема заработка денег", "схема дохода", "схема заработка", "схема наличных", - "денежная схема", "схема богатства", "схема богатства", "схема успеха", - "получать деньги", "платная работа", "платная работа", "платная возможность", "платная схема", - "платная программа", "платная система", "платный метод", "платная техника", "платная стратегия", - "платная формула", "платный секрет", "платный совет", "платный совет", "платное руководство", - "платная инструкция", "платный урок", "платный курс", "платное обучение", "платное образование", - "платное обучение", "платные знания", "платная информация", "платные данные", "платные факты", - "платная правда", "платная реальность", "платная правда", "платный факт", "платная реальность", - "платная правда", "платный факт", "платная реальность", "платная правда", "платный факт", - "платная реальность", "платная правда", "платный факт", "платная реальность", "платная правда", - - // Hebrew spam and scam related words - "להרוויח כסף מהר", "להרוויח כסף באינטרנט", "לעבוד מהבית", "להתעשר מהר", - "להרוויח 1000 דולר ביום", "להרוויח 5000 דולר בשבוע", "השקעה בביטקוין", "השקעה בקריפטו", - "מסחר בפורקס", "אופציות בינאריות", "שיווק רב שכבתי", "פירמידה", "סכמה", - "להתקבל כסף על לחיצות", "סקרים בתשלום", "לעבוד מהבית", "עסק מהבית", - "להרוויח כסף מהבית", "להרוויח מהבית", "הכנסה באינטרנט", "הכנסה פסיבית", - "כסף מהיר", "מזומן מהיר", "כסף קל", "הרווחת כסף", "הרווחת מזומן", - "לעבוד באינטרנט", "עבודה באינטרנט", "עבודה מהבית", "עבודה מרחוק", "טלעבודה", - "להרוויח מזומן", "לעשות מזומן", "לקבל מזומן", "מזומן מהיר", "כסף מהיר", - "כסף באינטרנט", "הרווחה באינטרנט", "הרווחה מהבית", "לעבוד מכל מקום", - "להרוויח יומי", "לעשות יומי", "לקבל יומי", "הכנסה יומית", "הרווחה יומית", - "הכנסה שבועית", "הכנסה חודשית", "הכנסה שנתית", "הרווחה שנתית", - "להתעשר", "להיות עשיר", "להתעשר מהר", "עשיר", "מיליונר", - "מיליארדר", "חופש פיננסי", "עצמאות פיננסית", "פרישה מוקדמת", - "פרישה מוקדמת", "הצלחה פיננסית", "הצלחה בכסף", "בניית עושר", - "הזדמנות השקעה", "הזדמנות עסקית", "הזדמנות כספית", - "הזדמנות להרוויח", "הזדמנות לעשות", "הזדמנות לקבל", "הזדמנות מזומן", - "הזדמנות להרוויח כסף", "הזדמנות הכנסה", "הזדמנות הרווחה", - "הזדמנות עבודה", "הזדמנות עבודה", "הזדמנות עסקית", "הזדמנות קריירה", - "הזדמנות פיננסית", "הזדמנות עושר", "הזדמנות עושר", "הזדמנות הצלחה", - "סכמה להרוויח כסף", "סכמה הכנסה", "סכמה הרווחה", "סכמה מזומן", - "סכמה כספית", "סכמה עושר", "סכמה עושר", "סכמה הצלחה", - "לקבל כסף", "עבודה בתשלום", "עבודה בתשלום", "הזדמנות בתשלום", "סכמה בתשלום", - "תוכנית בתשלום", "מערכת בתשלום", "שיטה בתשלום", "טכניקה בתשלום", "אסטרטגיה בתשלום", - "נוסחה בתשלום", "סוד בתשלום", "טיפ בתשלום", "ייעוץ בתשלום", "הדרכה בתשלום", - "הוראות בתשלום", "שיעור בתשלום", "קורס בתשלום", "אימון בתשלום", "חינוך בתשלום", - "למידה בתשלום", "ידע בתשלום", "מידע בתשלום", "נתונים בתשלום", "עובדות בתשלום", - "אמת בתשלום", "מציאות בתשלום", "אמת בתשלום", "עובדה בתשלום", "מציאות בתשלום", - "אמת בתשלום", "עובדה בתשלום", "מציאות בתשלום", "אמת בתשלום", "עובדה בתשלום", - "מציאות בתשלום", "אמת בתשלום", "עובדה בתשלום", "מציאות בתשלום", "אמת בתשלום", - - // Arabic spam and scam related words - "كسب المال بسرعة", "كسب المال عبر الإنترنت", "العمل من المنزل", "الثراء السريع", - "كسب 1000 دولار يومياً", "كسب 5000 دولار أسبوعياً", "استثمار البيتكوين", "استثمار العملات الرقمية", - "تداول الفوركس", "الخيارات الثنائية", "التسويق متعدد المستويات", "الهرم", "المخطط", - "الحصول على المال مقابل النقر", "الاستطلاعات المدفوعة", "العمل من المنزل", "الأعمال المنزلية", - "كسب المال من المنزل", "الكسب من المنزل", "الدخل عبر الإنترنت", "الدخل السلبي", - "المال السريع", "النقد السريع", "المال السهل", "كسب المال", "كسب النقد", - "العمل عبر الإنترنت", "الوظيفة عبر الإنترنت", "الوظيفة المنزلية", "العمل عن بعد", "العمل عن بعد", - "كسب النقد", "جعل النقد", "الحصول على النقد", "النقد السريع", "المال السريع", - "المال عبر الإنترنت", "الكسب عبر الإنترنت", "الكسب من المنزل", "العمل من أي مكان", - "الكسب اليومي", "الجعل اليومي", "الحصول اليومي", "الدخل اليومي", "الكسب اليومي", - "الدخل الأسبوعي", "الدخل الشهري", "الدخل السنوي", "الكسب السنوي", - "الثري", "أن تصبح غنياً", "الثراء السريع", "الغني", "المليونير", - "الملياردير", "الحرية المالية", "الاستقلال المالي", "التقاعد المبكر", - "التقاعد المبكر", "النجاح المالي", "نجاح المال", "بناء الثروة", - "فرصة الاستثمار", "الفرصة التجارية", "الفرصة المالية", - "فرصة الكسب", "فرصة الجعل", "فرصة الحصول", "فرصة النقد", - "فرصة كسب المال", "فرصة الدخل", "فرصة الكسب", - "فرصة العمل", "فرصة الوظيفة", "الفرصة التجارية", "فرصة المهنة", - "الفرصة المالية", "فرصة الثروة", "فرصة الثروة", "فرصة النجاح", - "مخطط كسب المال", "مخطط الدخل", "مخطط الكسب", "مخطط النقد", - "مخطط المال", "مخطط الثروة", "مخطط الثروة", "مخطط النجاح", - "الحصول على المال", "العمل المدفوع", "الوظيفة المدفوعة", "الفرصة المدفوعة", "المخطط المدفوع", - "البرنامج المدفوع", "النظام المدفوع", "الطريقة المدفوعة", "التقنية المدفوعة", "الاستراتيجية المدفوعة", - "الصيغة المدفوعة", "السر المدفوع", "النصيحة المدفوعة", "النصيحة المدفوعة", "الإرشاد المدفوع", - "التعليمات المدفوعة", "الدرس المدفوع", "الدورة المدفوعة", "التدريب المدفوع", "التعليم المدفوع", - "التعلم المدفوع", "المعرفة المدفوعة", "المعلومات المدفوعة", "البيانات المدفوعة", "الحقائق المدفوعة", - "الحقيقة المدفوعة", "الواقع المدفوع", "الحقيقة المدفوعة", "الحقيقة المدفوعة", "الواقع المدفوع", - "الحقيقة المدفوعة", "الحقيقة المدفوعة", "الواقع المدفوع", "الحقيقة المدفوعة", "الحقيقة المدفوعة", - "الواقع المدفوع", "الحقيقة المدفوعة", "الحقيقة المدفوعة", "الواقع المدفوع", "الحقيقة المدفوعة" - ]; - - export default badWordsList; - \ No newline at end of file + // Русский мат (включая производные формы) + 'блядь', + 'блять', + 'сука', + 'ебать', + 'ебаный', + 'ебанутый', + 'ебучий', + 'ебло', + 'пиздец', + 'пизда', + 'пиздюк', + 'хуй', + 'хуя', + 'хуйн', + 'хуевый', + 'нахуй', + 'гандон', + 'мразь', + 'пидор', + 'пидорас', + 'чмо', + 'долбоеб', + 'уебок', + 'еблан', + 'сучка', + 'шлюха', + 'гнида', + 'мудак', + 'хуесос', + 'залупа', + 'блядина', + 'выблядок', + 'манда', + 'ебырь', + 'опущенный', + 'шлюшка', + 'пидр', + 'сукин сын', + 'хер', + 'засранец', + 'дристун', + 'гнида', + 'обосранец', + 'подонок', + 'говноед', + 'ебарь', + 'говно', + 'дрочить', + 'дрочер', + 'отсос', + 'минет', + 'анальный', + 'анус', + 'влагалище', + 'пилотка', + 'пердун', + 'вонючка', + 'манда', + 'ебал', + 'хуятина', + 'хуесос', + 'педик', + 'херня', + 'херь', + 'ебало', + 'ебальник', + 'ебануть', + 'ебануться', + 'ебать', + 'ебаться', + 'ебись', + 'ебнутый', + 'ебнуться', + 'ебсти', + 'ебстись', + 'ебти', + 'ебтись', + 'ебут', + 'ебучий', + 'ебущий', + 'хуйло', + 'хуйня', + 'хуйнятина', + 'хуйняшка', + 'хуйняшка', + 'хуйняшка', + 'хуйняшка', + 'пиздабол', + 'пиздаболка', + 'пиздаболы', + 'пиздаболы', + 'пиздаболы', + 'пиздаболы', + 'блядство', + 'блядствовать', + 'блядство', + 'блядство', + 'блядство', + 'блядство', + 'сучара', + 'сучарка', + 'сучарка', + 'сучарка', + 'сучарка', + 'сучарка', + 'сучарка', + 'гандон', + 'гандон', + 'гандон', + 'гандон', + 'гандон', + 'гандон', + 'гандон', + 'гандон', + 'мудак', + 'мудак', + 'мудак', + 'мудак', + 'мудак', + 'мудак', + 'мудак', + 'мудак', + 'говно', + 'говно', + 'говно', + 'говно', + 'говно', + 'говно', + 'говно', + 'говно', + 'залупа', + 'залупа', + 'залупа', + 'залупа', + 'залупа', + 'залупа', + 'залупа', + 'залупа', + 'манда', + 'манда', + 'манда', + 'манда', + 'манда', + 'манда', + 'манда', + 'манда', + 'шлюха', + 'шлюха', + 'шлюха', + 'шлюха', + 'шлюха', + 'шлюха', + 'шлюха', + 'шлюха', + 'гнида', + 'гнида', + 'гнида', + 'гнида', + 'гнида', + 'гнида', + 'гнида', + 'гнида', + 'пидорас', + 'пидорас', + 'пидорас', + 'пидорас', + 'пидорас', + 'пидорас', + 'пидорас', + 'пидорас', + 'чмо', + 'чмо', + 'чмо', + 'чмо', + 'чмо', + 'чмо', + 'чмо', + 'чмо', + 'чмо', + 'чмо', + 'долбоеб', + 'долбоеб', + 'долбоеб', + 'долбоеб', + 'долбоеб', + 'долбоеб', + 'долбоеб', + 'долбоеб', + 'уебок', + 'уебок', + 'уебок', + 'уебок', + 'уебок', + 'уебок', + 'уебок', + 'уебок', + 'еблан', + 'еблан', + 'еблан', + 'еблан', + 'еблан', + 'еблан', + 'еблан', + 'еблан', + 'сучка', + 'сучка', + 'сучка', + 'сучка', + 'сучка', + 'сучка', + 'сучка', + 'сучка', + 'шлюшка', + 'шлюшка', + 'шлюшка', + 'шлюшка', + 'шлюшка', + 'шлюшка', + 'шлюшка', + 'шлюшка', + 'пидр', + 'пидр', + 'пидр', + 'пидр', + 'пидр', + 'пидр', + 'пидр', + 'пидр', + 'сукин сын', + 'сукин сын', + 'сукин сын', + 'сукин сын', + 'сукин сын', + 'сукин сын', + 'сукин сын', + 'сукин сын', + 'хер', + 'хер', + 'хер', + 'хер', + 'хер', + 'хер', + 'хер', + 'хер', + 'засранец', + 'засранец', + 'засранец', + 'засранец', + 'засранец', + 'засранец', + 'засранец', + 'засранец', + 'дристун', + 'дристун', + 'дристун', + 'дристун', + 'дристун', + 'дристун', + 'дристун', + 'дристун', + 'гнида', + 'гнида', + 'гнида', + 'гнида', + 'гнида', + 'гнида', + 'гнида', + 'гнида', + 'обосранец', + 'обосранец', + 'обосранец', + 'обосранец', + 'обосранец', + 'обосранец', + 'обосранец', + 'обосранец', + 'подонок', + 'подонок', + 'подонок', + 'подонок', + 'подонок', + 'подонок', + 'подонок', + 'подонок', + 'говноед', + 'говноед', + 'говноед', + 'говноед', + 'говноед', + 'говноед', + 'говноед', + 'говноед', + 'ебарь', + 'ебарь', + 'ебарь', + 'ебарь', + 'ебарь', + 'ебарь', + 'ебарь', + 'ебарь', + 'говно', + 'говно', + 'говно', + 'говно', + 'говно', + 'говно', + 'говно', + 'говно', + 'хуятина', + 'хуятина', + 'хуятина', + 'хуятина', + 'хуятина', + 'хуятина', + 'хуятина', + 'хуятина', + 'хуесос', + 'хуесос', + 'хуесос', + 'хуесос', + 'хуесос', + 'хуесос', + 'хуесос', + 'хуесос', + 'педик', + 'педик', + 'педик', + 'педик', + 'педик', + 'педик', + 'педик', + 'педик', + 'херня', + 'херня', + 'херня', + 'херня', + 'херня', + 'херня', + 'херня', + 'херня', + 'херь', + 'херь', + 'херь', + 'херь', + 'херь', + 'херь', + 'херь', + 'херь', + + // Английский мат (с сокращениями и альтернативными формами) + 'fuck', + 'fucking', + 'fucker', + 'motherfucker', + 'mothafucka', + 'shit', + 'shitty', + 'bitch', + 'bastard', + 'asshole', + 'dick', + 'dickhead', + 'cunt', + 'cock', + 'slut', + 'whore', + 'hoe', + 'dumbass', + 'faggot', + 'fag', + 'nigga', + 'nigger', + 'pussy', + 'retard', + 'moron', + 'jerkoff', + 'jackass', + 'suck my dick', + 'twat', + 'bollocks', + 'douche', + 'douchebag', + 'arse', + 'arsehole', + 'wanker', + 'prick', + 'tosser', + 'cum', + 'spunk', + 'skank', + 'dipshit', + 'nutjob', + 'screw you', + 'go fuck yourself', + 'dipstick', + 'motherfuckin', + 'cocksucker', + 'buttfuck', + 'cockmunch', + 'shitface', + 'shithead', + 'tits', + 'boobs', + 'booty', + 'dildo', + 'cumdumpster', + 'cumslut', + 'semen', + 'gangbang', + 'rimjob', + 'deepthroat', + 'pecker', + 'jizz', + 'scumbag', + 'ass', + 'asswipe', + 'asshat', + 'assclown', + 'assface', + 'asshole', + 'asslicker', + 'ballbag', + 'ballsack', + 'bellend', + 'bloodclaat', + 'bloodclot', + 'bloody hell', + 'bugger', + 'bugger off', + 'buggered', + 'buggery', + 'bullshit', + 'bum', + 'bumhole', + 'crap', + 'cripple', + 'crippled', + 'cuntface', + 'cuntlicker', + 'cuntrag', + 'cunts', + 'damn', + 'damned', + 'damnit', + 'darn', + 'darnit', + 'dickbag', + 'dickface', + 'dickhead', + 'dickhole', + 'dickless', + 'dicks', + 'dicksucker', + 'dickwad', + 'dickweed', + 'dickwod', + 'dumb', + 'dumbass', + 'dumbfuck', + 'dumbshit', + 'dumshit', + 'dyke', + 'dykes', + 'fag', + 'faggot', + 'faggots', + 'fagot', + 'fagots', + 'fatass', + 'fatfuck', + 'fatso', + 'fuck', + 'fucka', + 'fuckass', + 'fuckbag', + 'fuckboy', + 'fuckbrain', + 'fuckbutt', + 'fucked', + 'fucker', + 'fuckers', + 'fuckface', + 'fuckhead', + 'fuckhole', + 'fuckin', + 'fucking', + 'fucknut', + 'fuckoff', + 'fucks', + 'fuckstick', + 'fucktard', + 'fuckup', + 'fuckwad', + 'fuckwit', + 'fuckwitt', + 'fuckwitt', + 'fuckwitt', + 'fuckwitt', + 'fuckwitt', + 'fuckwitt', + 'goddamn', + 'goddamned', + 'goddamnit', + 'goddamnit', + 'goddamnit', + 'goddamnit', + 'hell', + 'hells', + 'horseshit', + 'jackass', + 'jackasses', + 'jackoff', + 'jackshit', + 'jerk', + 'jerkass', + 'jerkoff', + 'jerkwad', + 'jizz', + 'jizzbag', + 'jizzface', + 'motherfucker', + 'motherfuckers', + 'motherfucking', + 'motherfuckin', + 'motherfuckin', + 'nigga', + 'nigger', + 'niggers', + 'niglet', + 'niglets', + 'piss', + 'pissed', + 'pisser', + 'pissers', + 'pisses', + 'pissflaps', + 'pissin', + 'pissing', + 'pissoff', + 'pisswhore', + 'prick', + 'pricks', + 'prickteaser', + 'pussy', + 'pussies', + 'pussycat', + 'pussydestroyer', + 'pussyeater', + 'pussyfucker', + 'pussylicker', + 'pussylips', + 'pussylover', + 'pussypounder', + 'pussys', + 'pussytight', + 'pussywagon', + 'pussywhip', + 'pussywhip', + 'pussywhip', + 'shit', + 'shitass', + 'shitbag', + 'shitbagger', + 'shitbrains', + 'shitbreath', + 'shitcanned', + 'shitcunt', + 'shitdick', + 'shitface', + 'shitfaced', + 'shithead', + 'shitheel', + 'shithole', + 'shithouse', + 'shiting', + 'shitings', + 'shits', + 'shitspitter', + 'shitstain', + 'shitter', + 'shitters', + 'shittier', + 'shittiest', + 'shitting', + 'shittings', + 'shitty', + 'shiz', + 'shiznit', + 'skank', + 'skanky', + 'slut', + 'sluts', + 'sluttier', + 'sluttiest', + 'slutting', + 'slutty', + 'slutwear', + 'slutwhore', + 'smegma', + 'snatch', + 'snatches', + 'snatchs', + 'sonofabitch', + 'sonofabitches', + 'sonofabitch', + 'sonofabitch', + 'sonofabitch', + 'tard', + 'tards', + 'tart', + 'tits', + 'tittie5', + 'tittiefucker', + 'titties', + 'titty', + 'tittyfuck', + 'tittyfucker', + 'tittywank', + 'titwank', + 'twat', + 'twathead', + 'twatish', + 'twats', + 'twatty', + 'twunt', + 'twunter', + 'vag', + 'vagina', + 'vaginas', + 'vajina', + 'vajinas', + 'vulva', + 'vulvas', + 'wank', + 'wanked', + 'wanker', + 'wankered', + 'wankers', + 'wanking', + 'wanks', + 'wankstain', + 'wanky', + 'whore', + 'whorealicious', + 'whorebag', + 'whored', + 'whoreface', + 'whorehopper', + 'whorehouse', + 'whores', + 'whoring', + 'whorish', + 'whorism', + 'whorism', + 'whorism', + 'whorism', + 'whorism', + 'whorism', + 'whorism', + + // Hebrew bad words + 'זבל', + 'בן זונה', + 'זונה', + 'כוס', + 'זין', + 'תחת', + 'חרא', + 'בן של זונה', + 'זבל', + 'בן זונה', + 'זונה', + 'כוס', + 'זין', + 'תחת', + 'חרא', + 'בן של זונה', + + // Arabic bad words + 'زبالة', + 'ابن الكلب', + 'كلب', + 'عرص', + 'زبي', + 'طيز', + 'خرا', + 'ابن الحرام', + 'زبالة', + 'ابن الكلب', + 'كلب', + 'عرص', + 'زبي', + 'طيز', + 'خرا', + 'ابن الحرام', + + // English spam and scam related words + 'earn money fast', + 'make money online', + 'work from home', + 'get rich quick', + 'make $1000 daily', + 'earn $5000 weekly', + 'bitcoin investment', + 'crypto investment', + 'forex trading', + 'binary options', + 'mlm', + 'multi level marketing', + 'pyramid scheme', + 'get paid to click', + 'paid surveys', + 'work at home', + 'home based business', + 'make money from home', + 'earn from home', + 'online income', + 'passive income', + 'quick money', + 'fast cash', + 'easy money', + 'money making', + 'cash earning', + 'work online', + 'online job', + 'home job', + 'remote work', + 'telecommute', + 'earn cash', + 'make cash', + 'get cash', + 'quick cash', + 'fast money', + 'money online', + 'online earning', + 'home earning', + 'work from anywhere', + 'earn daily', + 'make daily', + 'get daily', + 'daily income', + 'daily earning', + 'weekly income', + 'monthly income', + 'yearly income', + 'annual income', + 'get rich', + 'become rich', + 'rich quick', + 'wealthy', + 'millionaire', + 'billionaire', + 'financial freedom', + 'financial independence', + 'retire early', + 'early retirement', + 'financial success', + 'money success', + 'wealth building', + 'investment opportunity', + 'business opportunity', + 'money opportunity', + 'earn opportunity', + 'make opportunity', + 'get opportunity', + 'cash opportunity', + 'money making opportunity', + 'income opportunity', + 'earning opportunity', + 'work opportunity', + 'job opportunity', + 'business opportunity', + 'career opportunity', + 'financial opportunity', + 'wealth opportunity', + 'rich opportunity', + 'success opportunity', + 'money making scheme', + 'income scheme', + 'earning scheme', + 'cash scheme', + 'money scheme', + 'wealth scheme', + 'rich scheme', + 'success scheme', + 'get paid', + 'paid work', + 'paid job', + 'paid opportunity', + 'paid scheme', + 'paid program', + 'paid system', + 'paid method', + 'paid technique', + 'paid strategy', + 'paid formula', + 'paid secret', + 'paid tip', + 'paid advice', + 'paid guidance', + 'paid instruction', + 'paid tutorial', + 'paid course', + 'paid training', + 'paid education', + 'paid learning', + 'paid knowledge', + 'paid information', + 'paid data', + 'paid facts', + 'paid truth', + 'paid reality', + 'paid truth', + 'paid fact', + 'paid reality', + 'paid truth', + 'paid fact', + 'paid reality', + 'paid truth', + 'paid fact', + 'paid reality', + 'paid truth', + 'paid fact', + 'paid reality', + 'paid truth', + + // Russian spam and scam related words + 'заработать деньги быстро', + 'заработать деньги онлайн', + 'работать из дома', + 'разбогатеть быстро', + 'заработать 1000 долларов в день', + 'заработать 5000 долларов в неделю', + 'инвестиции в биткоин', + 'инвестиции в криптовалюту', + 'торговля форекс', + 'бинарные опционы', + 'млм', + 'многоуровневый маркетинг', + 'пирамида', + 'получать деньги за клики', + 'платные опросы', + 'работать дома', + 'домашний бизнес', + 'зарабатывать деньги из дома', + 'зарабатывать из дома', + 'онлайн доход', + 'пассивный доход', + 'быстрые деньги', + 'быстрые наличные', + 'легкие деньги', + 'заработок денег', + 'заработок наличных', + 'работать онлайн', + 'онлайн работа', + 'домашняя работа', + 'удаленная работа', + 'телеработа', + 'зарабатывать наличные', + 'делать наличные', + 'получать наличные', + 'быстрые наличные', + 'быстрые деньги', + 'деньги онлайн', + 'онлайн заработок', + 'домашний заработок', + 'работать откуда угодно', + 'зарабатывать ежедневно', + 'делать ежедневно', + 'получать ежедневно', + 'ежедневный доход', + 'ежедневный заработок', + 'еженедельный доход', + 'ежемесячный доход', + 'годовой доход', + 'годовой заработок', + 'разбогатеть', + 'стать богатым', + 'быстро разбогатеть', + 'богатый', + 'миллионер', + 'миллиардер', + 'финансовая свобода', + 'финансовая независимость', + 'ранний выход на пенсию', + 'ранняя пенсия', + 'финансовый успех', + 'успех в деньгах', + 'создание богатства', + 'инвестиционная возможность', + 'бизнес возможность', + 'денежная возможность', + 'возможность заработать', + 'возможность сделать', + 'возможность получить', + 'возможность наличных', + 'возможность заработать деньги', + 'возможность дохода', + 'возможность заработка', + 'возможность работы', + 'возможность работы', + 'бизнес возможность', + 'карьерная возможность', + 'финансовая возможность', + 'возможность богатства', + 'возможность богатства', + 'возможность успеха', + 'схема заработка денег', + 'схема дохода', + 'схема заработка', + 'схема наличных', + 'денежная схема', + 'схема богатства', + 'схема богатства', + 'схема успеха', + 'получать деньги', + 'платная работа', + 'платная работа', + 'платная возможность', + 'платная схема', + 'платная программа', + 'платная система', + 'платный метод', + 'платная техника', + 'платная стратегия', + 'платная формула', + 'платный секрет', + 'платный совет', + 'платный совет', + 'платное руководство', + 'платная инструкция', + 'платный урок', + 'платный курс', + 'платное обучение', + 'платное образование', + 'платное обучение', + 'платные знания', + 'платная информация', + 'платные данные', + 'платные факты', + 'платная правда', + 'платная реальность', + 'платная правда', + 'платный факт', + 'платная реальность', + 'платная правда', + 'платный факт', + 'платная реальность', + 'платная правда', + 'платный факт', + 'платная реальность', + 'платная правда', + 'платный факт', + 'платная реальность', + 'платная правда', + + // Hebrew spam and scam related words + 'להרוויח כסף מהר', + 'להרוויח כסף באינטרנט', + 'לעבוד מהבית', + 'להתעשר מהר', + 'להרוויח 1000 דולר ביום', + 'להרוויח 5000 דולר בשבוע', + 'השקעה בביטקוין', + 'השקעה בקריפטו', + 'מסחר בפורקס', + 'אופציות בינאריות', + 'שיווק רב שכבתי', + 'פירמידה', + 'סכמה', + 'להתקבל כסף על לחיצות', + 'סקרים בתשלום', + 'לעבוד מהבית', + 'עסק מהבית', + 'להרוויח כסף מהבית', + 'להרוויח מהבית', + 'הכנסה באינטרנט', + 'הכנסה פסיבית', + 'כסף מהיר', + 'מזומן מהיר', + 'כסף קל', + 'הרווחת כסף', + 'הרווחת מזומן', + 'לעבוד באינטרנט', + 'עבודה באינטרנט', + 'עבודה מהבית', + 'עבודה מרחוק', + 'טלעבודה', + 'להרוויח מזומן', + 'לעשות מזומן', + 'לקבל מזומן', + 'מזומן מהיר', + 'כסף מהיר', + 'כסף באינטרנט', + 'הרווחה באינטרנט', + 'הרווחה מהבית', + 'לעבוד מכל מקום', + 'להרוויח יומי', + 'לעשות יומי', + 'לקבל יומי', + 'הכנסה יומית', + 'הרווחה יומית', + 'הכנסה שבועית', + 'הכנסה חודשית', + 'הכנסה שנתית', + 'הרווחה שנתית', + 'להתעשר', + 'להיות עשיר', + 'להתעשר מהר', + 'עשיר', + 'מיליונר', + 'מיליארדר', + 'חופש פיננסי', + 'עצמאות פיננסית', + 'פרישה מוקדמת', + 'פרישה מוקדמת', + 'הצלחה פיננסית', + 'הצלחה בכסף', + 'בניית עושר', + 'הזדמנות השקעה', + 'הזדמנות עסקית', + 'הזדמנות כספית', + 'הזדמנות להרוויח', + 'הזדמנות לעשות', + 'הזדמנות לקבל', + 'הזדמנות מזומן', + 'הזדמנות להרוויח כסף', + 'הזדמנות הכנסה', + 'הזדמנות הרווחה', + 'הזדמנות עבודה', + 'הזדמנות עבודה', + 'הזדמנות עסקית', + 'הזדמנות קריירה', + 'הזדמנות פיננסית', + 'הזדמנות עושר', + 'הזדמנות עושר', + 'הזדמנות הצלחה', + 'סכמה להרוויח כסף', + 'סכמה הכנסה', + 'סכמה הרווחה', + 'סכמה מזומן', + 'סכמה כספית', + 'סכמה עושר', + 'סכמה עושר', + 'סכמה הצלחה', + 'לקבל כסף', + 'עבודה בתשלום', + 'עבודה בתשלום', + 'הזדמנות בתשלום', + 'סכמה בתשלום', + 'תוכנית בתשלום', + 'מערכת בתשלום', + 'שיטה בתשלום', + 'טכניקה בתשלום', + 'אסטרטגיה בתשלום', + 'נוסחה בתשלום', + 'סוד בתשלום', + 'טיפ בתשלום', + 'ייעוץ בתשלום', + 'הדרכה בתשלום', + 'הוראות בתשלום', + 'שיעור בתשלום', + 'קורס בתשלום', + 'אימון בתשלום', + 'חינוך בתשלום', + 'למידה בתשלום', + 'ידע בתשלום', + 'מידע בתשלום', + 'נתונים בתשלום', + 'עובדות בתשלום', + 'אמת בתשלום', + 'מציאות בתשלום', + 'אמת בתשלום', + 'עובדה בתשלום', + 'מציאות בתשלום', + 'אמת בתשלום', + 'עובדה בתשלום', + 'מציאות בתשלום', + 'אמת בתשלום', + 'עובדה בתשלום', + 'מציאות בתשלום', + 'אמת בתשלום', + 'עובדה בתשלום', + 'מציאות בתשלום', + 'אמת בתשלום', + + // Arabic spam and scam related words + 'كسب المال بسرعة', + 'كسب المال عبر الإنترنت', + 'العمل من المنزل', + 'الثراء السريع', + 'كسب 1000 دولار يومياً', + 'كسب 5000 دولار أسبوعياً', + 'استثمار البيتكوين', + 'استثمار العملات الرقمية', + 'تداول الفوركس', + 'الخيارات الثنائية', + 'التسويق متعدد المستويات', + 'الهرم', + 'المخطط', + 'الحصول على المال مقابل النقر', + 'الاستطلاعات المدفوعة', + 'العمل من المنزل', + 'الأعمال المنزلية', + 'كسب المال من المنزل', + 'الكسب من المنزل', + 'الدخل عبر الإنترنت', + 'الدخل السلبي', + 'المال السريع', + 'النقد السريع', + 'المال السهل', + 'كسب المال', + 'كسب النقد', + 'العمل عبر الإنترنت', + 'الوظيفة عبر الإنترنت', + 'الوظيفة المنزلية', + 'العمل عن بعد', + 'العمل عن بعد', + 'كسب النقد', + 'جعل النقد', + 'الحصول على النقد', + 'النقد السريع', + 'المال السريع', + 'المال عبر الإنترنت', + 'الكسب عبر الإنترنت', + 'الكسب من المنزل', + 'العمل من أي مكان', + 'الكسب اليومي', + 'الجعل اليومي', + 'الحصول اليومي', + 'الدخل اليومي', + 'الكسب اليومي', + 'الدخل الأسبوعي', + 'الدخل الشهري', + 'الدخل السنوي', + 'الكسب السنوي', + 'الثري', + 'أن تصبح غنياً', + 'الثراء السريع', + 'الغني', + 'المليونير', + 'الملياردير', + 'الحرية المالية', + 'الاستقلال المالي', + 'التقاعد المبكر', + 'التقاعد المبكر', + 'النجاح المالي', + 'نجاح المال', + 'بناء الثروة', + 'فرصة الاستثمار', + 'الفرصة التجارية', + 'الفرصة المالية', + 'فرصة الكسب', + 'فرصة الجعل', + 'فرصة الحصول', + 'فرصة النقد', + 'فرصة كسب المال', + 'فرصة الدخل', + 'فرصة الكسب', + 'فرصة العمل', + 'فرصة الوظيفة', + 'الفرصة التجارية', + 'فرصة المهنة', + 'الفرصة المالية', + 'فرصة الثروة', + 'فرصة الثروة', + 'فرصة النجاح', + 'مخطط كسب المال', + 'مخطط الدخل', + 'مخطط الكسب', + 'مخطط النقد', + 'مخطط المال', + 'مخطط الثروة', + 'مخطط الثروة', + 'مخطط النجاح', + 'الحصول على المال', + 'العمل المدفوع', + 'الوظيفة المدفوعة', + 'الفرصة المدفوعة', + 'المخطط المدفوع', + 'البرنامج المدفوع', + 'النظام المدفوع', + 'الطريقة المدفوعة', + 'التقنية المدفوعة', + 'الاستراتيجية المدفوعة', + 'الصيغة المدفوعة', + 'السر المدفوع', + 'النصيحة المدفوعة', + 'النصيحة المدفوعة', + 'الإرشاد المدفوع', + 'التعليمات المدفوعة', + 'الدرس المدفوع', + 'الدورة المدفوعة', + 'التدريب المدفوع', + 'التعليم المدفوع', + 'التعلم المدفوع', + 'المعرفة المدفوعة', + 'المعلومات المدفوعة', + 'البيانات المدفوعة', + 'الحقائق المدفوعة', + 'الحقيقة المدفوعة', + 'الواقع المدفوع', + 'الحقيقة المدفوعة', + 'الحقيقة المدفوعة', + 'الواقع المدفوع', + 'الحقيقة المدفوعة', + 'الحقيقة المدفوعة', + 'الواقع المدفوع', + 'الحقيقة المدفوعة', + 'الحقيقة المدفوعة', + 'الواقع المدفوع', + 'الحقيقة المدفوعة', + 'الحقيقة المدفوعة', + 'الواقع المدفوع', + 'الحقيقة المدفوعة', +]; + +export default badWordsList; diff --git a/apps/api/utils/check-openai-status.js b/apps/api/utils/check-openai-status.js index 6c23efd..12c8d56 100644 --- a/apps/api/utils/check-openai-status.js +++ b/apps/api/utils/check-openai-status.js @@ -2,194 +2,205 @@ import OpenAI from 'openai'; // Initialize OpenAI client const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, + apiKey: process.env.OPENAI_API_KEY, }); /** * Check OpenAI account status and quota */ async function checkOpenAIStatus() { - console.log("🔍 Checking OpenAI Account Status...\n"); - - // 1. Check API key - console.log("1️⃣ Checking API key..."); - const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) { - console.error("❌ OPENAI_API_KEY not found in environment variables"); - console.log(" Please set OPENAI_API_KEY in your .env file"); - return; - } - console.log("✅ OPENAI_API_KEY found"); - console.log(` Key starts with: ${apiKey.substring(0, 10)}...`); - console.log(` Key length: ${apiKey.length} characters`); - console.log(""); - - // 2. Test basic connection - console.log("2️⃣ Testing basic connection..."); - try { - const models = await openai.models.list(); - console.log("✅ Connection successful"); - console.log(` Available models: ${models.data.length}`); - - // Check for specific models - const modelIds = models.data.map(m => m.id); - const hasGPT35 = modelIds.some(id => id.includes('gpt-3.5')); - const hasGPT4 = modelIds.some(id => id.includes('gpt-4')); - - console.log(` Has GPT-3.5 models: ${hasGPT35}`); - console.log(` Has GPT-4 models: ${hasGPT4}`); - - if (hasGPT35) { - console.log(" ✅ GPT-3.5-turbo should be available"); - } else { - console.log(" ⚠️ GPT-3.5-turbo not found in available models"); - } - } catch (error) { - console.error("❌ Connection failed:", error.message); - if (error.message.includes('401')) { - console.log(" 🔍 This is an authentication error - check your API key"); - } else if (error.message.includes('403')) { - console.log(" 🔍 This is an authorization error - check your account status"); - } - return; - } - console.log(""); - - // 3. Test a simple request - console.log("3️⃣ Testing simple request..."); - try { - const startTime = Date.now(); - const completion = await openai.chat.completions.create({ - model: "gpt-3.5-turbo", - messages: [ - { - role: "user", - content: "Say 'test'" - } - ], - max_tokens: 5, - }); - const endTime = Date.now(); - - console.log("✅ Simple request successful"); - console.log(` Response: "${completion.choices[0]?.message?.content}"`); - console.log(` Time: ${endTime - startTime}ms`); - console.log(` Model: ${completion.model}`); - console.log(` Usage: ${JSON.stringify(completion.usage)}`); - - // Check usage - if (completion.usage) { - console.log(` Prompt tokens: ${completion.usage.prompt_tokens}`); - console.log(` Completion tokens: ${completion.usage.completion_tokens}`); - console.log(` Total tokens: ${completion.usage.total_tokens}`); - } - } catch (error) { - console.error("❌ Simple request failed:", error.message); - - // Analyze error type - if (error.message.includes('429')) { - console.log(" 🔍 Rate limit error - you're making too many requests"); - console.log(" 💡 Try waiting a minute before making more requests"); - } else if (error.message.includes('quota') || error.message.includes('billing')) { - console.log(" 🔍 Quota/billing error - check your account billing"); - console.log(" 💡 You may need to add payment method or credits"); - } else if (error.message.includes('model')) { - console.log(" 🔍 Model error - the model might not be available"); - } else { - console.log(" 🔍 Other error - check the full error details"); - } - - console.error(" Full error:", error); - return; - } - console.log(""); - - // 4. Test rate limiting - console.log("4️⃣ Testing rate limiting..."); - console.log(" Making 3 quick requests to test rate limits..."); - - const results = []; - for (let i = 1; i <= 3; i++) { - try { - console.log(` Request ${i}/3...`); - const startTime = Date.now(); - const completion = await openai.chat.completions.create({ - model: "gpt-3.5-turbo", - messages: [ - { - role: "user", - content: `Test ${i}` - } - ], - max_tokens: 5, - }); - const endTime = Date.now(); - - results.push({ - success: true, - time: endTime - startTime, - response: completion.choices[0]?.message?.content - }); - - console.log(` ✅ Success (${endTime - startTime}ms): "${completion.choices[0]?.message?.content}"`); - - // Small delay between requests - if (i < 3) { - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } catch (error) { - results.push({ - success: false, - error: error.message - }); - - console.log(` ❌ Failed: ${error.message}`); - - if (error.message.includes('429')) { - console.log(" 🔍 Rate limit hit on request", i); - break; - } - } - } - - const successCount = results.filter(r => r.success).length; - console.log(` 📊 Results: ${successCount}/3 successful`); - - if (successCount === 3) { - console.log(" ✅ No rate limits hit - you can make multiple requests"); - } else if (successCount === 0) { - console.log(" ❌ All requests failed - check your account status"); - } else { - console.log(" ⚠️ Some requests failed - you may be hitting rate limits"); - } - console.log(""); - - // 5. Recommendations - console.log("5️⃣ Recommendations:"); - - if (successCount === 3) { - console.log(" ✅ Your OpenAI account is working well"); - console.log(" 💡 The issue might be in the job title generation logic"); - console.log(" 💡 Check the debug-ai-generation.js script for more details"); - } else if (successCount === 0) { - console.log(" ❌ Your OpenAI account has issues"); - console.log(" 💡 Check your billing and payment method"); - console.log(" 💡 Verify your API key is correct"); - console.log(" 💡 Consider upgrading your plan for higher limits"); - } else { - console.log(" ⚠️ Your account has partial issues"); - console.log(" 💡 You may be hitting rate limits"); - console.log(" 💡 Consider using the fallback system"); - console.log(" 💡 Or upgrade your plan for higher limits"); - } - - console.log(""); - console.log("📊 Rate Limit Information:"); - console.log(" - Free tier: 3 requests per minute"); - console.log(" - Paid tier: 60 requests per minute"); - console.log(" - Higher tiers: 3500 requests per minute"); - console.log(" - Your current limit depends on your plan"); + console.log('🔍 Checking OpenAI Account Status...\n'); + + // 1. Check API key + console.log('1️⃣ Checking API key...'); + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + console.error('❌ OPENAI_API_KEY not found in environment variables'); + console.log(' Please set OPENAI_API_KEY in your .env file'); + return; + } + console.log('✅ OPENAI_API_KEY found'); + console.log(` Key starts with: ${apiKey.substring(0, 10)}...`); + console.log(` Key length: ${apiKey.length} characters`); + console.log(''); + + // 2. Test basic connection + console.log('2️⃣ Testing basic connection...'); + try { + const models = await openai.models.list(); + console.log('✅ Connection successful'); + console.log(` Available models: ${models.data.length}`); + + // Check for specific models + const modelIds = models.data.map((m) => m.id); + const hasGPT35 = modelIds.some((id) => id.includes('gpt-3.5')); + const hasGPT4 = modelIds.some((id) => id.includes('gpt-4')); + + console.log(` Has GPT-3.5 models: ${hasGPT35}`); + console.log(` Has GPT-4 models: ${hasGPT4}`); + + if (hasGPT35) { + console.log(' ✅ GPT-3.5-turbo should be available'); + } else { + console.log(' ⚠️ GPT-3.5-turbo not found in available models'); + } + } catch (error) { + console.error('❌ Connection failed:', error.message); + if (error.message.includes('401')) { + console.log(' 🔍 This is an authentication error - check your API key'); + } else if (error.message.includes('403')) { + console.log( + ' 🔍 This is an authorization error - check your account status', + ); + } + return; + } + console.log(''); + + // 3. Test a simple request + console.log('3️⃣ Testing simple request...'); + try { + const startTime = Date.now(); + const completion = await openai.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { + role: 'user', + content: "Say 'test'", + }, + ], + max_tokens: 5, + }); + const endTime = Date.now(); + + console.log('✅ Simple request successful'); + console.log(` Response: "${completion.choices[0]?.message?.content}"`); + console.log(` Time: ${endTime - startTime}ms`); + console.log(` Model: ${completion.model}`); + console.log(` Usage: ${JSON.stringify(completion.usage)}`); + + // Check usage + if (completion.usage) { + console.log(` Prompt tokens: ${completion.usage.prompt_tokens}`); + console.log( + ` Completion tokens: ${completion.usage.completion_tokens}`, + ); + console.log(` Total tokens: ${completion.usage.total_tokens}`); + } + } catch (error) { + console.error('❌ Simple request failed:', error.message); + + // Analyze error type + if (error.message.includes('429')) { + console.log(" 🔍 Rate limit error - you're making too many requests"); + console.log(' 💡 Try waiting a minute before making more requests'); + } else if ( + error.message.includes('quota') || + error.message.includes('billing') + ) { + console.log(' 🔍 Quota/billing error - check your account billing'); + console.log(' 💡 You may need to add payment method or credits'); + } else if (error.message.includes('model')) { + console.log(' 🔍 Model error - the model might not be available'); + } else { + console.log(' 🔍 Other error - check the full error details'); + } + + console.error(' Full error:', error); + return; + } + console.log(''); + + // 4. Test rate limiting + console.log('4️⃣ Testing rate limiting...'); + console.log(' Making 3 quick requests to test rate limits...'); + + const results = []; + for (let i = 1; i <= 3; i++) { + try { + console.log(` Request ${i}/3...`); + const startTime = Date.now(); + const completion = await openai.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { + role: 'user', + content: `Test ${i}`, + }, + ], + max_tokens: 5, + }); + const endTime = Date.now(); + + results.push({ + success: true, + time: endTime - startTime, + response: completion.choices[0]?.message?.content, + }); + + console.log( + ` ✅ Success (${endTime - startTime}ms): "${completion.choices[0]?.message?.content}"`, + ); + + // Small delay between requests + if (i < 3) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } catch (error) { + results.push({ + success: false, + error: error.message, + }); + + console.log(` ❌ Failed: ${error.message}`); + + if (error.message.includes('429')) { + console.log(' 🔍 Rate limit hit on request', i); + break; + } + } + } + + const successCount = results.filter((r) => r.success).length; + console.log(` 📊 Results: ${successCount}/3 successful`); + + if (successCount === 3) { + console.log(' ✅ No rate limits hit - you can make multiple requests'); + } else if (successCount === 0) { + console.log(' ❌ All requests failed - check your account status'); + } else { + console.log(' ⚠️ Some requests failed - you may be hitting rate limits'); + } + console.log(''); + + // 5. Recommendations + console.log('5️⃣ Recommendations:'); + + if (successCount === 3) { + console.log(' ✅ Your OpenAI account is working well'); + console.log(' 💡 The issue might be in the job title generation logic'); + console.log( + ' 💡 Check the debug-ai-generation.js script for more details', + ); + } else if (successCount === 0) { + console.log(' ❌ Your OpenAI account has issues'); + console.log(' 💡 Check your billing and payment method'); + console.log(' 💡 Verify your API key is correct'); + console.log(' 💡 Consider upgrading your plan for higher limits'); + } else { + console.log(' ⚠️ Your account has partial issues'); + console.log(' 💡 You may be hitting rate limits'); + console.log(' 💡 Consider using the fallback system'); + console.log(' 💡 Or upgrade your plan for higher limits'); + } + + console.log(''); + console.log('📊 Rate Limit Information:'); + console.log(' - Free tier: 3 requests per minute'); + console.log(' - Paid tier: 60 requests per minute'); + console.log(' - Higher tiers: 3500 requests per minute'); + console.log(' - Your current limit depends on your plan'); } // Run the check -checkOpenAIStatus().catch(console.error); \ No newline at end of file +checkOpenAIStatus().catch(console.error); diff --git a/apps/api/utils/cron-jobs.js b/apps/api/utils/cron-jobs.js index 30b5faf..4a73056 100755 --- a/apps/api/utils/cron-jobs.js +++ b/apps/api/utils/cron-jobs.js @@ -1,8 +1,10 @@ +import pkg from '@prisma/client'; import cron from 'node-cron'; -import stripe from './stripe.js'; import nodemailer from 'nodemailer'; -import pkg from '@prisma/client'; +import stripe from './stripe.js'; + const { PrismaClient } = pkg; + import dotenv from 'dotenv'; import { Resend } from 'resend'; import { checkAndSendFilteredNewsletter } from '../services/newsletterService.js'; @@ -13,232 +15,248 @@ const resend = new Resend(process.env.RESEND_API_KEY); // Настраиваем транспорт для отправки email const transporter = nodemailer.createTransport({ - host: "smtp.gmail.com", - port: 587, - secure: false, // true для 465, false для 587 - auth: { - user: process.env.EMAIL_USER, - pass: process.env.EMAIL_PASS, - }, + host: 'smtp.gmail.com', + port: 587, + secure: false, // true для 465, false для 587 + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, }); // Функция для проверки объявлений и отправки уведомлений const checkLowRankedJobs = async () => { - try { - const jobsPerPage = 10; // Количество объявлений на страницу - const minPage = 3; // Если объявление на 3-й странице или ниже — отправляем уведомление - - // Получаем все объявления с сортировкой - const jobs = await prisma.job.findMany({ - include: { - user: true, - }, - orderBy: [ - { boostedAt: 'desc' }, - { createdAt: 'desc' }, - ], - }); - - // Проверяем, есть ли вообще объявления - if (jobs.length === 0) { - return; - } - - // Группируем объявления по страницам - const pagedJobs = jobs.reduce((acc, job, index) => { - const page = Math.floor(index / jobsPerPage) + 1; - if (page >= minPage) { - acc.push({ ...job, page }); - } - return acc; - }, []); - - // Проверяем, есть ли объявления на 3-й странице или ниже - if (pagedJobs.length === 0) { - return; - } - - // Собираем пользователей, которым надо отправить уведомления - const usersToNotify = new Map(); - - pagedJobs.forEach((job) => { - if (job.user?.email) { - if (!usersToNotify.has(job.user.email)) { - usersToNotify.set(job.user.email, []); - } - usersToNotify.get(job.user.email).push(job); - } - }); - - // Проверяем, есть ли пользователи для уведомления - if (usersToNotify.size === 0) { - return; - } - - // Отправка email - for (const [email, jobs] of usersToNotify.entries()) { - const jobTitles = jobs.map((j) => `- ${j.title} (страница ${j.page})`).join('\n'); - - const mailOptions = { - from: `"Worknow Notifications" <${process.env.EMAIL_USER}>`, - to: email, - subject: 'Ваши объявления опустились вниз', - text: `Здравствуйте!\n\nВаши объявления опустились на страницу ${minPage} или ниже:\n\n${jobTitles}\n\nРекомендуем поднять их, чтобы привлечь больше откликов.\n\nПоднимите объявления здесь: https://worknow.co.il/my-advertisements\n\nС уважением, Команда Worknow.`, - }; - - try { - await transporter.sendMail(mailOptions); - } catch (emailError) { - console.error(`❌ Ошибка отправки email пользователю ${email}:`, emailError); - } - } - - } catch (error) { - console.error("❌ Ошибка при проверке объявлений:", error); - } + try { + const jobsPerPage = 10; // Количество объявлений на страницу + const minPage = 3; // Если объявление на 3-й странице или ниже — отправляем уведомление + + // Получаем все объявления с сортировкой + const jobs = await prisma.job.findMany({ + include: { + user: true, + }, + orderBy: [{ boostedAt: 'desc' }, { createdAt: 'desc' }], + }); + + // Проверяем, есть ли вообще объявления + if (jobs.length === 0) { + return; + } + + // Группируем объявления по страницам + const pagedJobs = jobs.reduce((acc, job, index) => { + const page = Math.floor(index / jobsPerPage) + 1; + if (page >= minPage) { + acc.push({ ...job, page }); + } + return acc; + }, []); + + // Проверяем, есть ли объявления на 3-й странице или ниже + if (pagedJobs.length === 0) { + return; + } + + // Собираем пользователей, которым надо отправить уведомления + const usersToNotify = new Map(); + + pagedJobs.forEach((job) => { + if (job.user?.email) { + if (!usersToNotify.has(job.user.email)) { + usersToNotify.set(job.user.email, []); + } + usersToNotify.get(job.user.email).push(job); + } + }); + + // Проверяем, есть ли пользователи для уведомления + if (usersToNotify.size === 0) { + return; + } + + // Отправка email + for (const [email, jobs] of usersToNotify.entries()) { + const jobTitles = jobs + .map((j) => `- ${j.title} (страница ${j.page})`) + .join('\n'); + + const mailOptions = { + from: `"Worknow Notifications" <${process.env.EMAIL_USER}>`, + to: email, + subject: 'Ваши объявления опустились вниз', + text: `Здравствуйте!\n\nВаши объявления опустились на страницу ${minPage} или ниже:\n\n${jobTitles}\n\nРекомендуем поднять их, чтобы привлечь больше откликов.\n\nПоднимите объявления здесь: https://worknow.co.il/my-advertisements\n\nС уважением, Команда Worknow.`, + }; + + try { + await transporter.sendMail(mailOptions); + } catch (emailError) { + console.error( + `❌ Ошибка отправки email пользователю ${email}:`, + emailError, + ); + } + } + } catch (error) { + console.error('❌ Ошибка при проверке объявлений:', error); + } }; export const cancelAutoRenewal = async (req, res) => { - const { clerkUserId } = req.body; - - try { - const user = await prisma.user.findUnique({ - where: { clerkUserId }, - }); - - if (!user || !user.stripeSubscriptionId) { - return res.status(404).json({ error: 'Подписка не найдена' }); - } - - if (!user.isAutoRenewal) { - return res.status(400).json({ error: 'Автопродление уже отключено' }); - } - - // 🔹 Отключаем автопродление в Stripe - await stripe.subscriptions.update(user.stripeSubscriptionId, { - cancel_at_period_end: true, - }); - - // 🔹 Обновляем статус в базе - await prisma.user.update({ - where: { clerkUserId }, - data: { isAutoRenewal: false }, - }); - - // 🔹 Отправляем email пользователю - - const mailOptions = { - from: `"Worknow" <${process.env.EMAIL_USER}>`, - to: user.email, - subject: "Автопродление подписки отключено", - text: `Здравствуйте, ${user.firstName || "пользователь"}!\n\nВы успешно отключили автопродление подписки. Ваша премиум-подписка останется активной до ${user.premiumEndsAt.toLocaleDateString()}.\n\nСпасибо, что пользуетесь Worknow!`, - }; - - await transporter.sendMail(mailOptions); - - res.json({ success: true, message: 'Автопродление подписки отключено.' }); - } catch (error) { - console.error(' Ошибка при отключении автообновления:', error); - res.status(500).json({ error: 'Ошибка при отключении автообновления' }); - } + const { clerkUserId } = req.body; + + try { + const user = await prisma.user.findUnique({ + where: { clerkUserId }, + }); + + if (!user || !user.stripeSubscriptionId) { + return res.status(404).json({ error: 'Подписка не найдена' }); + } + + if (!user.isAutoRenewal) { + return res.status(400).json({ error: 'Автопродление уже отключено' }); + } + + // 🔹 Отключаем автопродление в Stripe + await stripe.subscriptions.update(user.stripeSubscriptionId, { + cancel_at_period_end: true, + }); + + // 🔹 Обновляем статус в базе + await prisma.user.update({ + where: { clerkUserId }, + data: { isAutoRenewal: false }, + }); + + // 🔹 Отправляем email пользователю + + const mailOptions = { + from: `"Worknow" <${process.env.EMAIL_USER}>`, + to: user.email, + subject: 'Автопродление подписки отключено', + text: `Здравствуйте, ${user.firstName || 'пользователь'}!\n\nВы успешно отключили автопродление подписки. Ваша премиум-подписка останется активной до ${user.premiumEndsAt.toLocaleDateString()}.\n\nСпасибо, что пользуетесь Worknow!`, + }; + + await transporter.sendMail(mailOptions); + + res.json({ success: true, message: 'Автопродление подписки отключено.' }); + } catch (error) { + console.error(' Ошибка при отключении автообновления:', error); + res.status(500).json({ error: 'Ошибка при отключении автообновления' }); + } }; // Запуск cron-задачи каждые 5 дней в 08:00 -cron.schedule('0 8 */5 * *', () => { - checkLowRankedJobs(); -}, { - timezone: "Europe/Moscow", -}); +cron.schedule( + '0 8 */5 * *', + () => { + checkLowRankedJobs(); + }, + { + timezone: 'Europe/Moscow', + }, +); // Крон-задача для отключения просроченного премиума const disableExpiredPremiums = async () => { - try { - const result = await prisma.user.updateMany({ - where: { - isPremium: true, - isAutoRenewal: false, - premiumEndsAt: { lt: new Date() } - }, - data: { isPremium: false } - }); - if (result.count > 0) { - // Premium subscriptions disabled silently - } - } catch (error) { - console.error('❌ Ошибка при отключении просроченного премиума:', error); - } + try { + const result = await prisma.user.updateMany({ + where: { + isPremium: true, + isAutoRenewal: false, + premiumEndsAt: { lt: new Date() }, + }, + data: { isPremium: false }, + }); + if (result.count > 0) { + // Premium subscriptions disabled silently + } + } catch (error) { + console.error('❌ Ошибка при отключении просроченного премиума:', error); + } }; // Запуск каждый час -cron.schedule('0 * * * *', () => { - disableExpiredPremiums(); -}, { - timezone: 'Europe/Prague', -}); +cron.schedule( + '0 * * * *', + () => { + disableExpiredPremiums(); + }, + { + timezone: 'Europe/Prague', + }, +); // Newsletter automation function const checkAndSendNewsletter = async () => { - try { - console.log("📧 Проверка новых соискателей для рассылки..."); - - // Get candidates created in the last 24 hours - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - - const newCandidates = await prisma.seeker.findMany({ - where: { - createdAt: { - gte: yesterday - }, - isActive: true - }, - orderBy: { createdAt: 'desc' } - }); - - console.log(`📧 Найдено ${newCandidates.length} новых соискателей за последние 24 часа`); - - if (newCandidates.length >= 5) { - // Get all active subscribers - const subscribers = await prisma.newsletterSubscriber.findMany({ - where: { isActive: true } - }); - - if (subscribers.length === 0) { - console.log("📧 Нет активных подписчиков для рассылки"); - return; - } - - console.log(`📧 Отправляем рассылку ${subscribers.length} подписчикам`); - - // Generate email content - const emailContent = generateNewsletterContent(newCandidates); - const emailSubject = `Найдено ${newCandidates.length} новых соискателей`; - - // Send emails to all subscribers - const emailPromises = subscribers.map(subscriber => - resend.emails.send({ - from: 'WorkNow ', - to: subscriber.email, - subject: emailSubject, - html: emailContent - }) - ); - - await Promise.all(emailPromises); - - console.log(`📧 Рассылка успешно отправлена ${subscribers.length} подписчикам`); - } else { - console.log(`📧 Недостаточно новых соискателей для автоматической рассылки (${newCandidates.length}/5)`); - } - - } catch (error) { - console.error("❌ Ошибка при автоматической рассылке:", error); - } + try { + console.log('📧 Проверка новых соискателей для рассылки...'); + + // Get candidates created in the last 24 hours + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + const newCandidates = await prisma.seeker.findMany({ + where: { + createdAt: { + gte: yesterday, + }, + isActive: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + console.log( + `📧 Найдено ${newCandidates.length} новых соискателей за последние 24 часа`, + ); + + if (newCandidates.length >= 5) { + // Get all active subscribers + const subscribers = await prisma.newsletterSubscriber.findMany({ + where: { isActive: true }, + }); + + if (subscribers.length === 0) { + console.log('📧 Нет активных подписчиков для рассылки'); + return; + } + + console.log(`📧 Отправляем рассылку ${subscribers.length} подписчикам`); + + // Generate email content + const emailContent = generateNewsletterContent(newCandidates); + const emailSubject = `Найдено ${newCandidates.length} новых соискателей`; + + // Send emails to all subscribers + const emailPromises = subscribers.map((subscriber) => + resend.emails.send({ + from: 'WorkNow ', + to: subscriber.email, + subject: emailSubject, + html: emailContent, + }), + ); + + await Promise.all(emailPromises); + + console.log( + `📧 Рассылка успешно отправлена ${subscribers.length} подписчикам`, + ); + } else { + console.log( + `📧 Недостаточно новых соискателей для автоматической рассылки (${newCandidates.length}/5)`, + ); + } + } catch (error) { + console.error('❌ Ошибка при автоматической рассылке:', error); + } }; // Generate newsletter email content const generateNewsletterContent = (candidates) => { - const candidatesHtml = candidates.map(candidate => ` + const candidatesHtml = candidates + .map( + (candidate) => `

${candidate.name} ${candidate.gender ? `(${candidate.gender})` : ''} @@ -265,9 +283,11 @@ const generateNewsletterContent = (candidates) => { "${candidate.description || 'Описание не указано'}"

- `).join(''); + `, + ) + .join(''); - return ` + return ` @@ -321,20 +341,28 @@ const generateNewsletterContent = (candidates) => { }; // Запуск cron-задачи для рассылки каждый день в 10:00 -cron.schedule('0 10 * * *', () => { - console.log("⏰ Запускаем проверку новых соискателей для рассылки..."); - checkAndSendNewsletter(); -}, { - timezone: "Europe/Moscow", -}); +cron.schedule( + '0 10 * * *', + () => { + console.log('⏰ Запускаем проверку новых соискателей для рассылки...'); + checkAndSendNewsletter(); + }, + { + timezone: 'Europe/Moscow', + }, +); // Запуск cron-задачи для отфильтрованной рассылки каждый час -cron.schedule('0 * * * *', () => { - console.log("⏰ Запускаем проверку отфильтрованных кандидатов..."); - checkAndSendFilteredNewsletter(); -}, { - timezone: "Europe/Moscow", -}); +cron.schedule( + '0 * * * *', + () => { + console.log('⏰ Запускаем проверку отфильтрованных кандидатов...'); + checkAndSendFilteredNewsletter(); + }, + { + timezone: 'Europe/Moscow', + }, +); export { disableExpiredPremiums }; export { checkLowRankedJobs }; diff --git a/apps/api/utils/debug-ai-generation.js b/apps/api/utils/debug-ai-generation.js index cc3abba..3b53f19 100644 --- a/apps/api/utils/debug-ai-generation.js +++ b/apps/api/utils/debug-ai-generation.js @@ -1,5 +1,5 @@ -import OpenAI from 'openai'; import pkg from '@prisma/client'; +import OpenAI from 'openai'; const { PrismaClient } = pkg; @@ -7,79 +7,79 @@ const prisma = new PrismaClient(); // Initialize OpenAI client const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, + apiKey: process.env.OPENAI_API_KEY, }); /** * Diagnostic script to investigate AI generation failures */ async function debugAIGeneration() { - console.log("🔍 Debugging AI Generation Issues...\n"); - - // 1. Check environment variables - console.log("1️⃣ Checking environment variables..."); - const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) { - console.error("❌ OPENAI_API_KEY not found in environment variables"); - return; - } - console.log("✅ OPENAI_API_KEY found"); - console.log(` Key starts with: ${apiKey.substring(0, 10)}...`); - console.log(` Key length: ${apiKey.length} characters`); - console.log(""); - - // 2. Test basic OpenAI connection - console.log("2️⃣ Testing basic OpenAI connection..."); - try { - const models = await openai.models.list(); - console.log("✅ OpenAI connection successful"); - console.log(` Available models: ${models.data.length}`); - console.log(` First model: ${models.data[0]?.id || 'None'}`); - } catch (error) { - console.error("❌ OpenAI connection failed:", error.message); - console.error(" Error details:", error); - return; - } - console.log(""); - - // 3. Test simple completion - console.log("3️⃣ Testing simple completion..."); - try { - const startTime = Date.now(); - const completion = await openai.chat.completions.create({ - model: "gpt-3.5-turbo", - messages: [ - { - role: "user", - content: "Say 'Hello World'" - } - ], - max_tokens: 10, - }); - const endTime = Date.now(); - - console.log("✅ Simple completion successful"); - console.log(` Response: "${completion.choices[0]?.message?.content}"`); - console.log(` Time: ${endTime - startTime}ms`); - console.log(` Model: ${completion.model}`); - console.log(` Usage: ${JSON.stringify(completion.usage)}`); - } catch (error) { - console.error("❌ Simple completion failed:", error.message); - console.error(" Error details:", error); - return; - } - console.log(""); - - // 4. Test job title generation - console.log("4️⃣ Testing job title generation..."); - try { - const startTime = Date.now(); - const completion = await openai.chat.completions.create({ - model: "gpt-3.5-turbo", - messages: [ - { - role: "system", - content: `You are an expert job title generator for the Israeli job market. + console.log('🔍 Debugging AI Generation Issues...\n'); + + // 1. Check environment variables + console.log('1️⃣ Checking environment variables...'); + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + console.error('❌ OPENAI_API_KEY not found in environment variables'); + return; + } + console.log('✅ OPENAI_API_KEY found'); + console.log(` Key starts with: ${apiKey.substring(0, 10)}...`); + console.log(` Key length: ${apiKey.length} characters`); + console.log(''); + + // 2. Test basic OpenAI connection + console.log('2️⃣ Testing basic OpenAI connection...'); + try { + const models = await openai.models.list(); + console.log('✅ OpenAI connection successful'); + console.log(` Available models: ${models.data.length}`); + console.log(` First model: ${models.data[0]?.id || 'None'}`); + } catch (error) { + console.error('❌ OpenAI connection failed:', error.message); + console.error(' Error details:', error); + return; + } + console.log(''); + + // 3. Test simple completion + console.log('3️⃣ Testing simple completion...'); + try { + const startTime = Date.now(); + const completion = await openai.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { + role: 'user', + content: "Say 'Hello World'", + }, + ], + max_tokens: 10, + }); + const endTime = Date.now(); + + console.log('✅ Simple completion successful'); + console.log(` Response: "${completion.choices[0]?.message?.content}"`); + console.log(` Time: ${endTime - startTime}ms`); + console.log(` Model: ${completion.model}`); + console.log(` Usage: ${JSON.stringify(completion.usage)}`); + } catch (error) { + console.error('❌ Simple completion failed:', error.message); + console.error(' Error details:', error); + return; + } + console.log(''); + + // 4. Test job title generation + console.log('4️⃣ Testing job title generation...'); + try { + const startTime = Date.now(); + const completion = await openai.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { + role: 'system', + content: `You are an expert job title generator for the Israeli job market. Your task is to analyze job descriptions and generate concise, professional job titles in Russian. Requirements: @@ -89,98 +89,111 @@ async function debugAIGeneration() { - Consider the job location, requirements, and industry - Avoid including salary, contact info, or extra details in the title - Return only the job title, nothing else.` - }, - { - role: "user", - content: "Требуется повар в ресторан. Работа с 8:00 до 16:00. Зарплата 45 шек/час. Опыт работы обязателен." - } - ], - max_tokens: 50, - temperature: 0.3, - }); - const endTime = Date.now(); - - console.log("✅ Job title generation successful"); - console.log(` Response: "${completion.choices[0]?.message?.content}"`); - console.log(` Time: ${endTime - startTime}ms`); - console.log(` Model: ${completion.model}`); - console.log(` Usage: ${JSON.stringify(completion.usage)}`); - } catch (error) { - console.error("❌ Job title generation failed:", error.message); - console.error(" Error details:", error); - - // Check if it's a rate limit error - if (error.message?.includes('429') || error.message?.includes('rate limit')) { - console.log(" 🔍 This appears to be a rate limit error"); - } - - // Check if it's a quota error - if (error.message?.includes('quota') || error.message?.includes('billing')) { - console.log(" 🔍 This appears to be a quota/billing error"); - } - - return; - } - console.log(""); - - // 5. Check rate limits (if possible) - console.log("5️⃣ Checking rate limit information..."); - try { - // Try to get rate limit info from headers (this might not work with OpenAI SDK) - console.log(" Note: Rate limit headers are not directly accessible via OpenAI SDK"); - console.log(" Rate limits are typically:"); - console.log(" - Free tier: 3 requests per minute"); - console.log(" - Paid tier: 60 requests per minute"); - console.log(" - Higher tiers: 3500 requests per minute"); - } catch (error) { - console.log(" Could not retrieve rate limit information"); - } - console.log(""); - - // 6. Test with delays - console.log("6️⃣ Testing with delays to simulate rate limiting..."); - for (let i = 1; i <= 3; i++) { - console.log(` Test ${i}/3: Making request with ${i * 5} second delay...`); - - try { - await new Promise(resolve => setTimeout(resolve, i * 5000)); - - const startTime = Date.now(); - const completion = await openai.chat.completions.create({ - model: "gpt-3.5-turbo", - messages: [ - { - role: "user", - content: `Test message ${i}` - } - ], - max_tokens: 10, - }); - const endTime = Date.now(); - - console.log(` ✅ Success: "${completion.choices[0]?.message?.content}" (${endTime - startTime}ms)`); - } catch (error) { - console.log(` ❌ Failed: ${error.message}`); - } - } - console.log(""); - - // 7. Recommendations - console.log("7️⃣ Recommendations:"); - console.log(" If you're hitting rate limits:"); - console.log(" - Check your OpenAI account billing status"); - console.log(" - Verify your API key is valid and has sufficient credits"); - console.log(" - Consider upgrading to a paid plan for higher rate limits"); - console.log(" - Use the fallback system for now"); - console.log(""); - console.log(" If you're getting other errors:"); - console.log(" - Check your internet connection"); - console.log(" - Verify the OpenAI API is accessible"); - console.log(" - Check if there are any firewall/proxy issues"); + Return only the job title, nothing else.`, + }, + { + role: 'user', + content: + 'Требуется повар в ресторан. Работа с 8:00 до 16:00. Зарплата 45 шек/час. Опыт работы обязателен.', + }, + ], + max_tokens: 50, + temperature: 0.3, + }); + const endTime = Date.now(); + + console.log('✅ Job title generation successful'); + console.log(` Response: "${completion.choices[0]?.message?.content}"`); + console.log(` Time: ${endTime - startTime}ms`); + console.log(` Model: ${completion.model}`); + console.log(` Usage: ${JSON.stringify(completion.usage)}`); + } catch (error) { + console.error('❌ Job title generation failed:', error.message); + console.error(' Error details:', error); + + // Check if it's a rate limit error + if ( + error.message?.includes('429') || + error.message?.includes('rate limit') + ) { + console.log(' 🔍 This appears to be a rate limit error'); + } + + // Check if it's a quota error + if ( + error.message?.includes('quota') || + error.message?.includes('billing') + ) { + console.log(' 🔍 This appears to be a quota/billing error'); + } + + return; + } + console.log(''); + + // 5. Check rate limits (if possible) + console.log('5️⃣ Checking rate limit information...'); + try { + // Try to get rate limit info from headers (this might not work with OpenAI SDK) + console.log( + ' Note: Rate limit headers are not directly accessible via OpenAI SDK', + ); + console.log(' Rate limits are typically:'); + console.log(' - Free tier: 3 requests per minute'); + console.log(' - Paid tier: 60 requests per minute'); + console.log(' - Higher tiers: 3500 requests per minute'); + } catch (error) { + console.log(' Could not retrieve rate limit information'); + } + console.log(''); + + // 6. Test with delays + console.log('6️⃣ Testing with delays to simulate rate limiting...'); + for (let i = 1; i <= 3; i++) { + console.log(` Test ${i}/3: Making request with ${i * 5} second delay...`); + + try { + await new Promise((resolve) => setTimeout(resolve, i * 5000)); + + const startTime = Date.now(); + const completion = await openai.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { + role: 'user', + content: `Test message ${i}`, + }, + ], + max_tokens: 10, + }); + const endTime = Date.now(); + + console.log( + ` ✅ Success: "${completion.choices[0]?.message?.content}" (${endTime - startTime}ms)`, + ); + } catch (error) { + console.log(` ❌ Failed: ${error.message}`); + } + } + console.log(''); + + // 7. Recommendations + console.log('7️⃣ Recommendations:'); + console.log(" If you're hitting rate limits:"); + console.log(' - Check your OpenAI account billing status'); + console.log(' - Verify your API key is valid and has sufficient credits'); + console.log(' - Consider upgrading to a paid plan for higher rate limits'); + console.log(' - Use the fallback system for now'); + console.log(''); + console.log(" If you're getting other errors:"); + console.log(' - Check your internet connection'); + console.log(' - Verify the OpenAI API is accessible'); + console.log(' - Check if there are any firewall/proxy issues'); } // Run the diagnostic -debugAIGeneration().catch(console.error).finally(() => { - prisma.$disconnect(); -}); \ No newline at end of file +debugAIGeneration() + .catch(console.error) + .finally(() => { + prisma.$disconnect(); + }); diff --git a/apps/api/utils/fakeUsers.js b/apps/api/utils/fakeUsers.js index 50ca47f..a8313c0 100755 --- a/apps/api/utils/fakeUsers.js +++ b/apps/api/utils/fakeUsers.js @@ -1,5 +1,6 @@ import { faker } from '@faker-js/faker'; import pkg from '@prisma/client'; + const { PrismaClient } = pkg; const prisma = new PrismaClient(); @@ -8,22 +9,22 @@ const prisma = new PrismaClient(); * Функция создания фейкового пользователя */ export const createFakeUser = async () => { - const firstName = faker.person.firstName(); - const lastName = faker.person.lastName(); - const email = faker.internet.email({ firstName, lastName }); - const imageUrl = faker.image.avatar(); - const clerkUserId = `user_${faker.string.uuid()}`; + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + const email = faker.internet.email({ firstName, lastName }); + const imageUrl = faker.image.avatar(); + const clerkUserId = `user_${faker.string.uuid()}`; - const user = await prisma.user.create({ - data: { - clerkUserId, - firstName, - lastName, - email, - imageUrl, - }, - }); + const user = await prisma.user.create({ + data: { + clerkUserId, + firstName, + lastName, + email, + imageUrl, + }, + }); - console.log(`✅ Фейковый пользователь создан: ${email}`); - return user; + console.log(`✅ Фейковый пользователь создан: ${email}`); + return user; }; diff --git a/apps/api/utils/mailer.js b/apps/api/utils/mailer.js index 053ef30..b3a0f88 100644 --- a/apps/api/utils/mailer.js +++ b/apps/api/utils/mailer.js @@ -1,28 +1,29 @@ import nodemailer from 'nodemailer'; const transporter = nodemailer.createTransport({ - // eslint-disable-next-line no-undef - host: process.env.SMTP_HOST || 'smtp.gmail.com', - // eslint-disable-next-line no-undef - port: process.env.SMTP_PORT || 587, - secure: false, // Use STARTTLS - auth: { - // eslint-disable-next-line no-undef - user: process.env.SMTP_USER || process.env.EMAIL_USER, - // eslint-disable-next-line no-undef - pass: process.env.SMTP_PASS || process.env.EMAIL_PASS, - }, - tls: { - rejectUnauthorized: false - } + // eslint-disable-next-line no-undef + host: process.env.SMTP_HOST || 'smtp.gmail.com', + // eslint-disable-next-line no-undef + port: process.env.SMTP_PORT || 587, + secure: false, // Use STARTTLS + auth: { + // eslint-disable-next-line no-undef + user: process.env.SMTP_USER || process.env.EMAIL_USER, + // eslint-disable-next-line no-undef + pass: process.env.SMTP_PASS || process.env.EMAIL_PASS, + }, + tls: { + rejectUnauthorized: false, + }, }); export async function sendEmail(to, subject, html) { - return transporter.sendMail({ - // eslint-disable-next-line no-undef - from: process.env.SMTP_FROM || process.env.SMTP_USER || process.env.EMAIL_USER, - to, - subject, - html, - }); -} \ No newline at end of file + return transporter.sendMail({ + // eslint-disable-next-line no-undef + from: + process.env.SMTP_FROM || process.env.SMTP_USER || process.env.EMAIL_USER, + to, + subject, + html, + }); +} diff --git a/apps/api/utils/napcep.js b/apps/api/utils/napcep.js index f278c18..4f909f5 100755 --- a/apps/api/utils/napcep.js +++ b/apps/api/utils/napcep.js @@ -1,6 +1,8 @@ -import puppeteer from 'puppeteer'; import pkg from '@prisma/client'; +import puppeteer from 'puppeteer'; + const { PrismaClient } = pkg; + import { fakerRU as faker } from '@faker-js/faker'; import AIJobTitleService from '../services/aiJobTitleService.js'; @@ -9,860 +11,1031 @@ const MAX_JOBS = 200; // Function to extract and validate price from description function extractPriceFromDescription(description) { - // Look for patterns like "40 шек", "40 ШЕК", "40 shek", "40 SHEK", etc. - const pricePatterns = [ - /(\d+)\s*шек/gi, // 40 шек - /(\d+)\s*ШЕК/gi, // 40 ШЕК - /(\d+)\s*shek/gi, // 40 shek - /(\d+)\s*SHEK/gi, // 40 SHEK - /(\d+)\s*₪/gi, // 40 ₪ - /(\d+)\s*shekel/gi, // 40 shekel - /(\d+)\s*SHEKEL/gi, // 40 SHEKEL - /(\d+)\s*шек/gi, // 40 шек (lowercase) - /(\d+)\s*ШЕКЕЛЬ/gi, // 40 ШЕКЕЛЬ - /(\d+)\s*shekel/gi, // 40 shekel - /(\d+)\s*SHEKEL/gi, // 40 SHEKEL - /(\d+)\s*₪/gi, // 40 ₪ - /(\d+)\s*ILS/gi, // 40 ILS - /(\d+)\s*ils/gi, // 40 ils - /(\d+)\s*NIS/gi, // 40 NIS - /(\d+)\s*nis/gi, // 40 nis - ]; - - for (const pattern of pricePatterns) { - const match = description.match(pattern); - if (match) { - const price = parseInt(match[1], 10); - // Validate that price is reasonable (between 20 and 200 shekels per hour) - if (price >= 20 && price <= 200) { - return price; - } - } - } - - return null; + // Look for patterns like "40 шек", "40 ШЕК", "40 shek", "40 SHEK", etc. + const pricePatterns = [ + /(\d+)\s*шек/gi, // 40 шек + /(\d+)\s*ШЕК/gi, // 40 ШЕК + /(\d+)\s*shek/gi, // 40 shek + /(\d+)\s*SHEK/gi, // 40 SHEK + /(\d+)\s*₪/gi, // 40 ₪ + /(\d+)\s*shekel/gi, // 40 shekel + /(\d+)\s*SHEKEL/gi, // 40 SHEKEL + /(\d+)\s*шек/gi, // 40 шек (lowercase) + /(\d+)\s*ШЕКЕЛЬ/gi, // 40 ШЕКЕЛЬ + /(\d+)\s*shekel/gi, // 40 shekel + /(\d+)\s*SHEKEL/gi, // 40 SHEKEL + /(\d+)\s*₪/gi, // 40 ₪ + /(\d+)\s*ILS/gi, // 40 ILS + /(\d+)\s*ils/gi, // 40 ils + /(\d+)\s*NIS/gi, // 40 NIS + /(\d+)\s*nis/gi, // 40 nis + ]; + + for (const pattern of pricePatterns) { + const match = description.match(pattern); + if (match) { + const price = parseInt(match[1], 10); + // Validate that price is reasonable (between 20 and 200 shekels per hour) + if (price >= 20 && price <= 200) { + return price; + } + } + } + + return null; } // Function to validate job data consistency function validateJobData(job) { - const descriptionPrice = extractPriceFromDescription(job.description); - - if (!descriptionPrice) { - console.log(` ⚠️ Пропускаем вакансию "${job.title}" - нет валидной цены в описании`); - return false; - } - - // Update the job object with the validated price - job.validatedPrice = descriptionPrice; - console.log(` ✅ Валидная цена найдена: ${descriptionPrice} шекелей/час для "${job.title}"`); - - return true; + const descriptionPrice = extractPriceFromDescription(job.description); + + if (!descriptionPrice) { + console.log( + ` ⚠️ Пропускаем вакансию "${job.title}" - нет валидной цены в описании`, + ); + return false; + } + + // Update the job object with the validated price + job.validatedPrice = descriptionPrice; + console.log( + ` ✅ Валидная цена найдена: ${descriptionPrice} шекелей/час для "${job.title}"`, + ); + + return true; } // Очистка старых данных перед загрузкой новых async function clearOldData() { - console.log("🗑 Удаляем старые данные..."); - await prisma.job.deleteMany({}); - await prisma.user.deleteMany({ - where: { clerkUserId: { startsWith: "user_" } } - }); - console.log("✅ Очистка завершена!"); + console.log('🗑 Удаляем старые данные...'); + await prisma.job.deleteMany({}); + await prisma.user.deleteMany({ + where: { clerkUserId: { startsWith: 'user_' } }, + }); + console.log('✅ Очистка завершена!'); } // Парсинг вакансий с сайта async function fetchJobDescriptions() { - console.log("🔍 Запускаем Puppeteer для парсинга Orbita..."); - console.log("💰 Валидируем цены в описаниях для консистентности..."); - console.log(`🎯 Цель: собрать минимум 100 валидных вакансий (максимум ${MAX_JOBS})`); - console.log(`💡 Начнем обработку как только соберем 100 вакансий!`); - - const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); - const page = await browser.newPage(); - - // Увеличиваем timeout для лучшей стабильности - page.setDefaultTimeout(60000); // 60 seconds - page.setDefaultNavigationTimeout(60000); - - await page.goto('https://doska.orbita.co.il/jobs/required/', { waitUntil: 'networkidle2' }); - - let jobs = []; - let currentPage = 1; - let totalProcessed = 0; - let totalValidated = 0; - let consecutiveEmptyPages = 0; - let consecutiveTimeouts = 0; - let totalTimeouts = 0; - let skippedPages = []; - let stuckCounter = 0; - let lastPageChangeTime = Date.now(); - let earlyProcessingStarted = false; - const MAX_CONSECUTIVE_EMPTY_PAGES = 5; // Максимум 5 пустых страниц подряд - const MAX_CONSECUTIVE_TIMEOUTS = 2; // Уменьшаем до 2 таймаутов подряд - const MAX_TOTAL_TIMEOUTS = 10; // Максимум 10 таймаутов всего - const MAX_PAGE_RETRIES = 2; // Максимум 2 попытки загрузить страницу - const STUCK_TIMEOUT = 30000; // 30 секунд на страницу максимум - const MAX_STUCK_COUNT = 3; // Максимум 3 застревания подряд - const EARLY_PROCESSING_THRESHOLD = 100; // Начинаем обработку при 100 вакансиях - - while (jobs.length < MAX_JOBS) { - console.log(`📄 Парсим страницу ${currentPage}...`); - - // Проверяем общее количество таймаутов - if (totalTimeouts >= MAX_TOTAL_TIMEOUTS) { - console.log(`🛑 Достигнут лимит общих таймаутов (${MAX_TOTAL_TIMEOUTS}). Останавливаем парсинг.`); - console.log(`💡 Используем ${jobs.length} собранных вакансий без генерации дополнительных.`); - break; - } - - // Проверяем, не застряли ли мы на одной странице - const timeOnCurrentPage = Date.now() - lastPageChangeTime; - if (timeOnCurrentPage > STUCK_TIMEOUT) { - stuckCounter++; - console.log(`⚠️ Застряли на странице ${currentPage} на ${(timeOnCurrentPage / 1000).toFixed(0)} секунд!`); - - if (stuckCounter >= MAX_STUCK_COUNT) { - console.log(`🚨 КРИТИЧЕСКОЕ ЗАСТРЕВАНИЕ! Пропускаем ${Math.min(10, MAX_JOBS - jobs.length)} страниц вперед!`); - const pagesToSkip = Math.min(10, MAX_JOBS - jobs.length); - currentPage += pagesToSkip; - skippedPages.push(...Array.from({length: pagesToSkip}, (_, i) => currentPage - pagesToSkip + i)); - stuckCounter = 0; - lastPageChangeTime = Date.now(); - console.log(` ⏭️ Переходим к странице ${currentPage}`); - continue; - } else { - console.log(` 🔄 Попытка ${stuckCounter}/${MAX_STUCK_COUNT} преодоления застревания...`); - } - } - - // Проверяем, достигли ли мы порога для ранней обработки - if (!earlyProcessingStarted && jobs.length >= EARLY_PROCESSING_THRESHOLD) { - console.log(`🎉 Достигли порога в ${EARLY_PROCESSING_THRESHOLD} вакансий!`); - console.log(`🚀 Запускаем раннюю обработку в фоновом режиме...`); - earlyProcessingStarted = true; - - // Запускаем раннюю обработку асинхронно - processJobsEarly(jobs).catch(error => { - console.error(`❌ Ошибка при ранней обработке:`, error); - }); - } - - let pageRetries = 0; - let pageLoaded = false; - - // Попытки загрузить страницу - while (pageRetries < MAX_PAGE_RETRIES && !pageLoaded) { - try { - if (pageRetries > 0) { - console.log(` 🔄 Попытка ${pageRetries + 1} загрузки страницы ${currentPage}...`); - } - - const newJobs = await page.evaluate(() => { - const jobElements = document.querySelectorAll('.message'); - const jobData = []; - - jobElements.forEach((job) => { - let description = job.querySelector('.information')?.innerText.trim() || 'Описание отсутствует'; - let title = job.querySelector('.caption .cap')?.innerText.trim() || null; - let city = job.querySelector('.hidden-xs a')?.innerText.trim() || 'Не указан'; - - // 🔍 Проверяем наличие номера телефона в описании - let phoneMatch = description.match(/\+972[-\s]?\d{1,2}[-\s]?\d{3}[-\s]?\d{4,6}/); - let phone = phoneMatch ? phoneMatch[0].replace(/\s+/g, '') : null; - - if (!title) { - title = "Без названия"; - } - - // ✅ Только добавляем вакансии с телефонными номерами - if (phone) { - jobData.push({ title, description, city, phone }); - } - }); - - return jobData; - }); - - pageLoaded = true; - console.log(` 📊 Найдено ${newJobs.length} вакансий на странице ${currentPage}`); - - if (newJobs.length === 0) { - consecutiveEmptyPages++; - console.log(` ⚠️ Пустая страница ${currentPage} (${consecutiveEmptyPages}/${MAX_CONSECUTIVE_EMPTY_PAGES})`); - - if (consecutiveEmptyPages >= MAX_CONSECUTIVE_EMPTY_PAGES) { - console.log(` 🛑 Слишком много пустых страниц подряд (${consecutiveEmptyPages}). Останавливаем парсинг.`); - break; - } - } else { - consecutiveEmptyPages = 0; // Сбрасываем счетчик пустых страниц - consecutiveTimeouts = 0; // Сбрасываем счетчик таймаутов - } - - // 🔍 Валидируем цены для каждой вакансии - const validatedJobs = []; - for (const job of newJobs) { - totalProcessed++; - if (validateJobData(job)) { - validatedJobs.push(job); - totalValidated++; - } - } - - jobs = [...jobs, ...validatedJobs]; - console.log(` ✅ Валидировано: ${validatedJobs.length}/${newJobs.length} вакансий`); - console.log(` 📈 Всего собрано: ${jobs.length}/${MAX_JOBS} валидных вакансий`); - - if (jobs.length < EARLY_PROCESSING_THRESHOLD) { - console.log(` 🎯 Осталось до ранней обработки: ${EARLY_PROCESSING_THRESHOLD - jobs.length} вакансий`); - } else if (jobs.length < MAX_JOBS) { - console.log(` 🎯 Осталось до полного лимита: ${MAX_JOBS - jobs.length} вакансий`); - } - - if (jobs.length >= MAX_JOBS) { - console.log("✅ Достигли лимита валидных вакансий!"); - break; - } - - // Поиск ссылки "Следующая страница" - const nextPageUrl = await page.evaluate(() => { - const nextLink = Array.from(document.querySelectorAll('a')).find(a => a.title === "Следующая"); - return nextLink ? nextLink.href : null; - }); - - if (!nextPageUrl) { - console.log("✅ Больше страниц нет. Пытаемся найти альтернативные источники..."); - - // Попробуем другие URL или категории - const alternativeUrls = [ - 'https://doska.orbita.co.il/jobs/', - 'https://doska.orbita.co.il/jobs/offered/', - 'https://doska.orbita.co.il/jobs/required/' - ]; - - let foundAlternative = false; - for (const altUrl of alternativeUrls) { - try { - console.log(` 🔄 Пробуем альтернативный URL: ${altUrl}`); - await page.goto(altUrl, { waitUntil: 'networkidle2' }); - foundAlternative = true; - currentPage = 1; - consecutiveEmptyPages = 0; - consecutiveTimeouts = 0; - lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы - break; - } catch { - console.log(` ❌ Не удалось загрузить ${altUrl}`); - } - } - - if (!foundAlternative) { - console.log("❌ Не удалось найти больше вакансий. Останавливаем парсинг."); - break; - } - } else { - // Переход на следующую страницу - try { - await page.goto(nextPageUrl, { waitUntil: 'networkidle2' }); - currentPage++; - lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы - } catch (error) { - if (error.message.includes('Navigation timeout')) { - consecutiveTimeouts++; - totalTimeouts++; - console.log(` ⏰ Таймаут навигации на странице ${currentPage}: ${error.message}`); - - if (consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) { - console.log(` 🛑 Слишком много таймаутов подряд (${consecutiveTimeouts}). Пропускаем страницу ${currentPage}.`); - skippedPages.push(currentPage); - currentPage++; // Принудительно переходим к следующей странице - lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы - consecutiveTimeouts = 0; // Сбрасываем счетчик - continue; // Продолжаем с новой страницы - } - - console.log(` 🔄 Пропускаем страницу ${currentPage} и продолжаем...`); - skippedPages.push(currentPage); - currentPage++; // Принудительно переходим к следующей странице - lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы - } else { - console.log(` ❌ Ошибка при переходе на страницу ${currentPage}: ${error.message}`); - currentPage++; // Переходим к следующей странице - lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы - } - } - } - - } catch (error) { - pageRetries++; - - if (error.message.includes('Navigation timeout')) { - consecutiveTimeouts++; - totalTimeouts++; - console.log(` ⏰ Таймаут при парсинге страницы ${currentPage} (попытка ${pageRetries}): ${error.message}`); - - if (consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) { - console.log(` 🛑 Слишком много таймаутов подряд (${consecutiveTimeouts}). Пропускаем страницу ${currentPage}.`); - skippedPages.push(currentPage); - currentPage++; // Принудительно переходим к следующей странице - lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы - consecutiveTimeouts = 0; // Сбрасываем счетчик - break; // Выходим из цикла попыток - } - - if (pageRetries >= MAX_PAGE_RETRIES) { - console.log(` ⏭️ Исчерпаны попытки загрузки страницы ${currentPage}. Пропускаем.`); - skippedPages.push(currentPage); - currentPage++; // Принудительно переходим к следующей странице - lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы - break; // Выходим из цикла попыток - } - - // Попробуем перезагрузить страницу - try { - await page.reload({ waitUntil: 'networkidle2' }); - } catch (reloadError) { - console.log(` ❌ Не удалось перезагрузить страницу: ${reloadError.message}`); - } - } else { - console.log(` ❌ Ошибка при парсинге страницы ${currentPage}:`, error.message); - console.log(` 🔄 Продолжаем парсинг...`); - - if (pageRetries >= MAX_PAGE_RETRIES) { - console.log(` ⏭️ Исчерпаны попытки загрузки страницы ${currentPage}. Пропускаем.`); - skippedPages.push(currentPage); - currentPage++; // Принудительно переходим к следующей странице - lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы - break; // Выходим из цикла попыток - } - - // Попробуем перезагрузить страницу - try { - await page.reload({ waitUntil: 'networkidle2' }); - } catch (reloadError) { - console.log(` ❌ Не удалось перезагрузить страницу:`, reloadError.message); - } - } - } - } - - // Если страница не загрузилась после всех попыток, пропускаем её - if (!pageLoaded) { - console.log(` ⏭️ Страница ${currentPage} не загрузилась. Пропускаем и продолжаем.`); - skippedPages.push(currentPage); - currentPage++; - lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы - } - } - - await browser.close(); - console.log(`\n📊 Статистика валидации цен:`); - console.log(` Обработано вакансий: ${totalProcessed}`); - console.log(` Валидных вакансий: ${totalValidated}`); - console.log(` Процент валидных: ${((totalValidated / totalProcessed) * 100).toFixed(1)}%`); - console.log(` Собрано вакансий: ${jobs.length}/${MAX_JOBS}`); - console.log(` Пропущено страниц из-за таймаутов: ${skippedPages.length}`); - if (skippedPages.length > 0) { - console.log(` Пропущенные страницы: ${skippedPages.join(', ')}`); - } - - if (jobs.length < MAX_JOBS) { - console.log(`⚠️ Предупреждение: Собрано только ${jobs.length} вакансий из ${MAX_JOBS} запланированных`); - - // Проверяем, была ли остановка из-за таймаутов - if (totalTimeouts >= MAX_TOTAL_TIMEOUTS || skippedPages.length > 0) { - console.log(`💡 Остановка из-за таймаутов или проблем с загрузкой. Используем ${jobs.length} собранных вакансий без генерации дополнительных.`); - return jobs; // Возвращаем только реальные вакансии - } else { - console.log(`🔄 Генерируем дополнительные вакансии для достижения ${MAX_JOBS}...`); - - const additionalJobsNeeded = MAX_JOBS - jobs.length; - const additionalJobs = generateAdditionalJobs(additionalJobsNeeded); - - console.log(`✅ Сгенерировано ${additionalJobs.length} дополнительных вакансий`); - jobs.push(...additionalJobs); - } - } else { - console.log(`✅ Успешно собрано ${jobs.length} валидных вакансий!`); - } - - return jobs.slice(0, MAX_JOBS); + console.log('🔍 Запускаем Puppeteer для парсинга Orbita...'); + console.log('💰 Валидируем цены в описаниях для консистентности...'); + console.log( + `🎯 Цель: собрать минимум 100 валидных вакансий (максимум ${MAX_JOBS})`, + ); + console.log(`💡 Начнем обработку как только соберем 100 вакансий!`); + + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + const page = await browser.newPage(); + + // Увеличиваем timeout для лучшей стабильности + page.setDefaultTimeout(60000); // 60 seconds + page.setDefaultNavigationTimeout(60000); + + await page.goto('https://doska.orbita.co.il/jobs/required/', { + waitUntil: 'networkidle2', + }); + + let jobs = []; + let currentPage = 1; + let totalProcessed = 0; + let totalValidated = 0; + let consecutiveEmptyPages = 0; + let consecutiveTimeouts = 0; + let totalTimeouts = 0; + let skippedPages = []; + let stuckCounter = 0; + let lastPageChangeTime = Date.now(); + let earlyProcessingStarted = false; + const MAX_CONSECUTIVE_EMPTY_PAGES = 5; // Максимум 5 пустых страниц подряд + const MAX_CONSECUTIVE_TIMEOUTS = 2; // Уменьшаем до 2 таймаутов подряд + const MAX_TOTAL_TIMEOUTS = 10; // Максимум 10 таймаутов всего + const MAX_PAGE_RETRIES = 2; // Максимум 2 попытки загрузить страницу + const STUCK_TIMEOUT = 30000; // 30 секунд на страницу максимум + const MAX_STUCK_COUNT = 3; // Максимум 3 застревания подряд + const EARLY_PROCESSING_THRESHOLD = 100; // Начинаем обработку при 100 вакансиях + + while (jobs.length < MAX_JOBS) { + console.log(`📄 Парсим страницу ${currentPage}...`); + + // Проверяем общее количество таймаутов + if (totalTimeouts >= MAX_TOTAL_TIMEOUTS) { + console.log( + `🛑 Достигнут лимит общих таймаутов (${MAX_TOTAL_TIMEOUTS}). Останавливаем парсинг.`, + ); + console.log( + `💡 Используем ${jobs.length} собранных вакансий без генерации дополнительных.`, + ); + break; + } + + // Проверяем, не застряли ли мы на одной странице + const timeOnCurrentPage = Date.now() - lastPageChangeTime; + if (timeOnCurrentPage > STUCK_TIMEOUT) { + stuckCounter++; + console.log( + `⚠️ Застряли на странице ${currentPage} на ${(timeOnCurrentPage / 1000).toFixed(0)} секунд!`, + ); + + if (stuckCounter >= MAX_STUCK_COUNT) { + console.log( + `🚨 КРИТИЧЕСКОЕ ЗАСТРЕВАНИЕ! Пропускаем ${Math.min(10, MAX_JOBS - jobs.length)} страниц вперед!`, + ); + const pagesToSkip = Math.min(10, MAX_JOBS - jobs.length); + currentPage += pagesToSkip; + skippedPages.push( + ...Array.from( + { length: pagesToSkip }, + (_, i) => currentPage - pagesToSkip + i, + ), + ); + stuckCounter = 0; + lastPageChangeTime = Date.now(); + console.log(` ⏭️ Переходим к странице ${currentPage}`); + continue; + } else { + console.log( + ` 🔄 Попытка ${stuckCounter}/${MAX_STUCK_COUNT} преодоления застревания...`, + ); + } + } + + // Проверяем, достигли ли мы порога для ранней обработки + if (!earlyProcessingStarted && jobs.length >= EARLY_PROCESSING_THRESHOLD) { + console.log( + `🎉 Достигли порога в ${EARLY_PROCESSING_THRESHOLD} вакансий!`, + ); + console.log(`🚀 Запускаем раннюю обработку в фоновом режиме...`); + earlyProcessingStarted = true; + + // Запускаем раннюю обработку асинхронно + processJobsEarly(jobs).catch((error) => { + console.error(`❌ Ошибка при ранней обработке:`, error); + }); + } + + let pageRetries = 0; + let pageLoaded = false; + + // Попытки загрузить страницу + while (pageRetries < MAX_PAGE_RETRIES && !pageLoaded) { + try { + if (pageRetries > 0) { + console.log( + ` 🔄 Попытка ${pageRetries + 1} загрузки страницы ${currentPage}...`, + ); + } + + const newJobs = await page.evaluate(() => { + const jobElements = document.querySelectorAll('.message'); + const jobData = []; + + jobElements.forEach((job) => { + let description = + job.querySelector('.information')?.innerText.trim() || + 'Описание отсутствует'; + let title = + job.querySelector('.caption .cap')?.innerText.trim() || null; + let city = + job.querySelector('.hidden-xs a')?.innerText.trim() || + 'Не указан'; + + // 🔍 Проверяем наличие номера телефона в описании + let phoneMatch = description.match( + /\+972[-\s]?\d{1,2}[-\s]?\d{3}[-\s]?\d{4,6}/, + ); + let phone = phoneMatch ? phoneMatch[0].replace(/\s+/g, '') : null; + + if (!title) { + title = 'Без названия'; + } + + // ✅ Только добавляем вакансии с телефонными номерами + if (phone) { + jobData.push({ title, description, city, phone }); + } + }); + + return jobData; + }); + + pageLoaded = true; + console.log( + ` 📊 Найдено ${newJobs.length} вакансий на странице ${currentPage}`, + ); + + if (newJobs.length === 0) { + consecutiveEmptyPages++; + console.log( + ` ⚠️ Пустая страница ${currentPage} (${consecutiveEmptyPages}/${MAX_CONSECUTIVE_EMPTY_PAGES})`, + ); + + if (consecutiveEmptyPages >= MAX_CONSECUTIVE_EMPTY_PAGES) { + console.log( + ` 🛑 Слишком много пустых страниц подряд (${consecutiveEmptyPages}). Останавливаем парсинг.`, + ); + break; + } + } else { + consecutiveEmptyPages = 0; // Сбрасываем счетчик пустых страниц + consecutiveTimeouts = 0; // Сбрасываем счетчик таймаутов + } + + // 🔍 Валидируем цены для каждой вакансии + const validatedJobs = []; + for (const job of newJobs) { + totalProcessed++; + if (validateJobData(job)) { + validatedJobs.push(job); + totalValidated++; + } + } + + jobs = [...jobs, ...validatedJobs]; + console.log( + ` ✅ Валидировано: ${validatedJobs.length}/${newJobs.length} вакансий`, + ); + console.log( + ` 📈 Всего собрано: ${jobs.length}/${MAX_JOBS} валидных вакансий`, + ); + + if (jobs.length < EARLY_PROCESSING_THRESHOLD) { + console.log( + ` 🎯 Осталось до ранней обработки: ${EARLY_PROCESSING_THRESHOLD - jobs.length} вакансий`, + ); + } else if (jobs.length < MAX_JOBS) { + console.log( + ` 🎯 Осталось до полного лимита: ${MAX_JOBS - jobs.length} вакансий`, + ); + } + + if (jobs.length >= MAX_JOBS) { + console.log('✅ Достигли лимита валидных вакансий!'); + break; + } + + // Поиск ссылки "Следующая страница" + const nextPageUrl = await page.evaluate(() => { + const nextLink = Array.from(document.querySelectorAll('a')).find( + (a) => a.title === 'Следующая', + ); + return nextLink ? nextLink.href : null; + }); + + if (!nextPageUrl) { + console.log( + '✅ Больше страниц нет. Пытаемся найти альтернативные источники...', + ); + + // Попробуем другие URL или категории + const alternativeUrls = [ + 'https://doska.orbita.co.il/jobs/', + 'https://doska.orbita.co.il/jobs/offered/', + 'https://doska.orbita.co.il/jobs/required/', + ]; + + let foundAlternative = false; + for (const altUrl of alternativeUrls) { + try { + console.log(` 🔄 Пробуем альтернативный URL: ${altUrl}`); + await page.goto(altUrl, { waitUntil: 'networkidle2' }); + foundAlternative = true; + currentPage = 1; + consecutiveEmptyPages = 0; + consecutiveTimeouts = 0; + lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы + break; + } catch { + console.log(` ❌ Не удалось загрузить ${altUrl}`); + } + } + + if (!foundAlternative) { + console.log( + '❌ Не удалось найти больше вакансий. Останавливаем парсинг.', + ); + break; + } + } else { + // Переход на следующую страницу + try { + await page.goto(nextPageUrl, { waitUntil: 'networkidle2' }); + currentPage++; + lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы + } catch (error) { + if (error.message.includes('Navigation timeout')) { + consecutiveTimeouts++; + totalTimeouts++; + console.log( + ` ⏰ Таймаут навигации на странице ${currentPage}: ${error.message}`, + ); + + if (consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) { + console.log( + ` 🛑 Слишком много таймаутов подряд (${consecutiveTimeouts}). Пропускаем страницу ${currentPage}.`, + ); + skippedPages.push(currentPage); + currentPage++; // Принудительно переходим к следующей странице + lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы + consecutiveTimeouts = 0; // Сбрасываем счетчик + continue; // Продолжаем с новой страницы + } + + console.log( + ` 🔄 Пропускаем страницу ${currentPage} и продолжаем...`, + ); + skippedPages.push(currentPage); + currentPage++; // Принудительно переходим к следующей странице + lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы + } else { + console.log( + ` ❌ Ошибка при переходе на страницу ${currentPage}: ${error.message}`, + ); + currentPage++; // Переходим к следующей странице + lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы + } + } + } + } catch (error) { + pageRetries++; + + if (error.message.includes('Navigation timeout')) { + consecutiveTimeouts++; + totalTimeouts++; + console.log( + ` ⏰ Таймаут при парсинге страницы ${currentPage} (попытка ${pageRetries}): ${error.message}`, + ); + + if (consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) { + console.log( + ` 🛑 Слишком много таймаутов подряд (${consecutiveTimeouts}). Пропускаем страницу ${currentPage}.`, + ); + skippedPages.push(currentPage); + currentPage++; // Принудительно переходим к следующей странице + lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы + consecutiveTimeouts = 0; // Сбрасываем счетчик + break; // Выходим из цикла попыток + } + + if (pageRetries >= MAX_PAGE_RETRIES) { + console.log( + ` ⏭️ Исчерпаны попытки загрузки страницы ${currentPage}. Пропускаем.`, + ); + skippedPages.push(currentPage); + currentPage++; // Принудительно переходим к следующей странице + lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы + break; // Выходим из цикла попыток + } + + // Попробуем перезагрузить страницу + try { + await page.reload({ waitUntil: 'networkidle2' }); + } catch (reloadError) { + console.log( + ` ❌ Не удалось перезагрузить страницу: ${reloadError.message}`, + ); + } + } else { + console.log( + ` ❌ Ошибка при парсинге страницы ${currentPage}:`, + error.message, + ); + console.log(` 🔄 Продолжаем парсинг...`); + + if (pageRetries >= MAX_PAGE_RETRIES) { + console.log( + ` ⏭️ Исчерпаны попытки загрузки страницы ${currentPage}. Пропускаем.`, + ); + skippedPages.push(currentPage); + currentPage++; // Принудительно переходим к следующей странице + lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы + break; // Выходим из цикла попыток + } + + // Попробуем перезагрузить страницу + try { + await page.reload({ waitUntil: 'networkidle2' }); + } catch (reloadError) { + console.log( + ` ❌ Не удалось перезагрузить страницу:`, + reloadError.message, + ); + } + } + } + } + + // Если страница не загрузилась после всех попыток, пропускаем её + if (!pageLoaded) { + console.log( + ` ⏭️ Страница ${currentPage} не загрузилась. Пропускаем и продолжаем.`, + ); + skippedPages.push(currentPage); + currentPage++; + lastPageChangeTime = Date.now(); // Обновляем время последнего изменения страницы + } + } + + await browser.close(); + console.log(`\n📊 Статистика валидации цен:`); + console.log(` Обработано вакансий: ${totalProcessed}`); + console.log(` Валидных вакансий: ${totalValidated}`); + console.log( + ` Процент валидных: ${((totalValidated / totalProcessed) * 100).toFixed(1)}%`, + ); + console.log(` Собрано вакансий: ${jobs.length}/${MAX_JOBS}`); + console.log(` Пропущено страниц из-за таймаутов: ${skippedPages.length}`); + if (skippedPages.length > 0) { + console.log(` Пропущенные страницы: ${skippedPages.join(', ')}`); + } + + if (jobs.length < MAX_JOBS) { + console.log( + `⚠️ Предупреждение: Собрано только ${jobs.length} вакансий из ${MAX_JOBS} запланированных`, + ); + + // Проверяем, была ли остановка из-за таймаутов + if (totalTimeouts >= MAX_TOTAL_TIMEOUTS || skippedPages.length > 0) { + console.log( + `💡 Остановка из-за таймаутов или проблем с загрузкой. Используем ${jobs.length} собранных вакансий без генерации дополнительных.`, + ); + return jobs; // Возвращаем только реальные вакансии + } else { + console.log( + `🔄 Генерируем дополнительные вакансии для достижения ${MAX_JOBS}...`, + ); + + const additionalJobsNeeded = MAX_JOBS - jobs.length; + const additionalJobs = generateAdditionalJobs(additionalJobsNeeded); + + console.log( + `✅ Сгенерировано ${additionalJobs.length} дополнительных вакансий`, + ); + jobs.push(...additionalJobs); + } + } else { + console.log(`✅ Успешно собрано ${jobs.length} валидных вакансий!`); + } + + return jobs.slice(0, MAX_JOBS); } // Генерация дополнительных вакансий для достижения лимита function generateAdditionalJobs(count) { - console.log(`🔧 Генерируем ${count} дополнительных вакансий...`); - - const jobTemplates = [ - { - title: "Работник на склад", - description: "Требуется работник на склад. Работа с 8:00 до 17:00. Оплата 45 шек в час. Звоните +972-50-123-4567", - city: "Тель-Авив", - phone: "+972-50-123-4567", - validatedPrice: 45 - }, - { - title: "Водитель доставки", - description: "Ищу водителя для доставки. Права категории B. Работа 6 дней в неделю. Оплата 50 шек в час. +972-52-234-5678", - city: "Хайфа", - phone: "+972-52-234-5678", - validatedPrice: 50 - }, - { - title: "Уборщица", - description: "Требуется уборщица в офис. Работа с 9:00 до 18:00. Оплата 40 шек в час. Звоните +972-54-345-6789", - city: "Иерусалим", - phone: "+972-54-345-6789", - validatedPrice: 40 - }, - { - title: "Продавец в магазин", - description: "Ищу продавца в магазин одежды. Опыт работы приветствуется. Оплата 55 шек в час. +972-55-456-7890", - city: "Ашдод", - phone: "+972-55-456-7890", - validatedPrice: 55 - }, - { - title: "Работник на кухню", - description: "Требуется работник на кухню ресторана. Работа с 10:00 до 22:00. Оплата 48 шек в час. Звоните +972-56-567-8901", - city: "Ришон-ле-Цион", - phone: "+972-56-567-8901", - validatedPrice: 48 - }, - { - title: "Строитель", - description: "Ищу строителя для работы на стройке. Опыт работы обязателен. Оплата 60 шек в час. +972-57-678-9012", - city: "Петах-Тиква", - phone: "+972-57-678-9012", - validatedPrice: 60 - }, - { - title: "Электрик", - description: "Требуется электрик для работы в жилых домах. Лицензия обязательна. Оплата 70 шек в час. Звоните +972-58-789-0123", - city: "Холон", - phone: "+972-58-789-0123", - validatedPrice: 70 - }, - { - title: "Сантехник", - description: "Ищу сантехника для ремонтных работ. Опыт работы 3+ года. Оплата 65 шек в час. +972-59-890-1234", - city: "Рамат-Ган", - phone: "+972-59-890-1234", - validatedPrice: 65 - }, - { - title: "Садовник", - description: "Требуется садовник для ухода за садом. Работа 5 дней в неделю. Оплата 45 шек в час. Звоните +972-60-901-2345", - city: "Гиватаим", - phone: "+972-60-901-2345", - validatedPrice: 45 - }, - { - title: "Няня", - description: "Ищу няню для ребенка 3 лет. Работа с 8:00 до 16:00. Оплата 50 шек в час. +972-61-012-3456", - city: "Кфар-Саба", - phone: "+972-61-012-3456", - validatedPrice: 50 - } - ]; - - const cities = ["Тель-Авив", "Хайфа", "Иерусалим", "Ашдод", "Ришон-ле-Цион", "Петах-Тиква", "Холон", "Рамат-Ган", "Гиватаим", "Кфар-Саба"]; - const additionalJobs = []; - - for (let i = 0; i < count; i++) { - const template = jobTemplates[i % jobTemplates.length]; - const city = cities[i % cities.length]; - const phoneSuffix = String(i + 1000).padStart(4, '0'); - - const job = { - title: template.title, - description: template.description.replace(/\+972-\d{2}-\d{3}-\d{4}/, `+972-50-${phoneSuffix}`), - city: city, - phone: `+972-50-${phoneSuffix}`, - validatedPrice: template.validatedPrice, - categoryId: determineCategoryFromTitle(template.title) // Определяем категорию для сгенерированных вакансий - }; - - additionalJobs.push(job); - } - - console.log(`✅ Сгенерировано ${additionalJobs.length} дополнительных вакансий`); - return additionalJobs; + console.log(`🔧 Генерируем ${count} дополнительных вакансий...`); + + const jobTemplates = [ + { + title: 'Работник на склад', + description: + 'Требуется работник на склад. Работа с 8:00 до 17:00. Оплата 45 шек в час. Звоните +972-50-123-4567', + city: 'Тель-Авив', + phone: '+972-50-123-4567', + validatedPrice: 45, + }, + { + title: 'Водитель доставки', + description: + 'Ищу водителя для доставки. Права категории B. Работа 6 дней в неделю. Оплата 50 шек в час. +972-52-234-5678', + city: 'Хайфа', + phone: '+972-52-234-5678', + validatedPrice: 50, + }, + { + title: 'Уборщица', + description: + 'Требуется уборщица в офис. Работа с 9:00 до 18:00. Оплата 40 шек в час. Звоните +972-54-345-6789', + city: 'Иерусалим', + phone: '+972-54-345-6789', + validatedPrice: 40, + }, + { + title: 'Продавец в магазин', + description: + 'Ищу продавца в магазин одежды. Опыт работы приветствуется. Оплата 55 шек в час. +972-55-456-7890', + city: 'Ашдод', + phone: '+972-55-456-7890', + validatedPrice: 55, + }, + { + title: 'Работник на кухню', + description: + 'Требуется работник на кухню ресторана. Работа с 10:00 до 22:00. Оплата 48 шек в час. Звоните +972-56-567-8901', + city: 'Ришон-ле-Цион', + phone: '+972-56-567-8901', + validatedPrice: 48, + }, + { + title: 'Строитель', + description: + 'Ищу строителя для работы на стройке. Опыт работы обязателен. Оплата 60 шек в час. +972-57-678-9012', + city: 'Петах-Тиква', + phone: '+972-57-678-9012', + validatedPrice: 60, + }, + { + title: 'Электрик', + description: + 'Требуется электрик для работы в жилых домах. Лицензия обязательна. Оплата 70 шек в час. Звоните +972-58-789-0123', + city: 'Холон', + phone: '+972-58-789-0123', + validatedPrice: 70, + }, + { + title: 'Сантехник', + description: + 'Ищу сантехника для ремонтных работ. Опыт работы 3+ года. Оплата 65 шек в час. +972-59-890-1234', + city: 'Рамат-Ган', + phone: '+972-59-890-1234', + validatedPrice: 65, + }, + { + title: 'Садовник', + description: + 'Требуется садовник для ухода за садом. Работа 5 дней в неделю. Оплата 45 шек в час. Звоните +972-60-901-2345', + city: 'Гиватаим', + phone: '+972-60-901-2345', + validatedPrice: 45, + }, + { + title: 'Няня', + description: + 'Ищу няню для ребенка 3 лет. Работа с 8:00 до 16:00. Оплата 50 шек в час. +972-61-012-3456', + city: 'Кфар-Саба', + phone: '+972-61-012-3456', + validatedPrice: 50, + }, + ]; + + const cities = [ + 'Тель-Авив', + 'Хайфа', + 'Иерусалим', + 'Ашдод', + 'Ришон-ле-Цион', + 'Петах-Тиква', + 'Холон', + 'Рамат-Ган', + 'Гиватаим', + 'Кфар-Саба', + ]; + const additionalJobs = []; + + for (let i = 0; i < count; i++) { + const template = jobTemplates[i % jobTemplates.length]; + const city = cities[i % cities.length]; + const phoneSuffix = String(i + 1000).padStart(4, '0'); + + const job = { + title: template.title, + description: template.description.replace( + /\+972-\d{2}-\d{3}-\d{4}/, + `+972-50-${phoneSuffix}`, + ), + city: city, + phone: `+972-50-${phoneSuffix}`, + validatedPrice: template.validatedPrice, + categoryId: determineCategoryFromTitle(template.title), // Определяем категорию для сгенерированных вакансий + }; + + additionalJobs.push(job); + } + + console.log( + `✅ Сгенерировано ${additionalJobs.length} дополнительных вакансий`, + ); + return additionalJobs; } // Определение категории на основе заголовка вакансии function determineCategoryFromTitle(title) { - const titleLower = title.toLowerCase(); - - // Маппинг ключевых слов к категориям (обновленные ID из базы данных) - const categoryMapping = { - // Строительство и ремонт - 'строитель': 30, // Стройка - 'строительство': 30, - 'стройка': 30, - 'строительн': 30, - 'плотник': 44, // Плотник - 'плотнич': 44, - 'сварщик': 49, // Сварщик - 'сварка': 49, - 'электрик': 57, // Электрик - 'электрич': 57, - 'ремонт': 45, // Ремонт - 'ремонтн': 45, - - // Транспорт и доставка - 'водитель': 35, // Перевозка - 'шофер': 35, - 'доставка': 34, // Доставка - 'курьер': 34, - 'транспорт': 53, // Транспорт - 'перевозка': 35, - - // Склад и производство - 'склад': 48, // Склад - 'складск': 48, - 'завод': 37, // Завод - 'производство': 55, // Производство - 'производств': 55, - - // Торговля и офис - 'продавец': 54, // Торговля - 'продаж': 54, - 'кассир': 54, - 'офис': 42, // Офис - 'офисн': 42, - 'секретарь': 42, - 'администратор': 42, - - // Общепит и гостиницы - 'кухня': 43, // Общепит - 'повар': 43, - 'официант': 43, - 'бармен': 43, - 'ресторан': 43, - 'кафе': 43, - 'гостиница': 33, // Гостиницы - 'отель': 33, - 'hotel': 33, - - // Уборка и обслуживание - 'уборщица': 31, // Уборка - 'уборщик': 31, - 'уборка': 31, - 'клининг': 31, - 'cleaning': 31, - - // Медицина и здоровье - 'медицин': 47, // Медицина - 'врач': 47, - 'медсестра': 47, - 'здоровье': 38, // Здоровье - 'медицинск': 47, - - // Образование и няни - 'учитель': 46, // Образование - 'преподаватель': 46, - 'репетитор': 46, - 'образование': 46, - 'няня': 40, // Няни - 'нянь': 40, - 'babysitter': 40, - - // Охрана и безопасность - 'охранник': 41, // Охрана - 'охрана': 41, - 'security': 41, - 'security guard': 41, - - // Бьюти-индустрия - 'парикмахер': 32, // Бьюти-индустрия - 'массажист': 32, - 'косметолог': 32, - 'маникюр': 32, - 'салон': 32, - 'beauty': 32, - - // Автосервис - 'автосервис': 36, // Автосервис - 'механик': 36, - 'авто': 36, - 'car': 36, - 'garage': 36, - - // Связь и телекоммуникации - 'связь': 50, // Связь-телекоммуникации - 'телеком': 50, - 'программист': 50, - 'it': 50, - 'developer': 50, - - // Сельское хозяйство - 'сельское': 51, // Сельское хозяйство - 'фермер': 51, - 'садовник': 51, - 'сельскохозяйств': 51, - - // Уход за пожилыми - 'уход': 52, // Уход за пожилыми - 'пожил': 52, - 'сиделка': 52, - 'caregiver': 52, - - // Швеи - 'швея': 56, // Швеи - 'портной': 56, - 'швейн': 56, - 'seamstress': 56, - - // Инженеры - 'инженер': 39, // Инженеры - 'engineer': 39, - 'техник': 39, - - // Общие слова для разных категорий - 'рабочий': 58, // Разное - 'работник': 58, - 'помощник': 58, - 'assistant': 58, - 'worker': 58 - }; - - // Проверяем каждое ключевое слово - for (const [keyword, categoryId] of Object.entries(categoryMapping)) { - if (titleLower.includes(keyword)) { - return categoryId; - } - } - - // Если не найдено точное совпадение, возвращаем "Разное" - return 58; // Разное - новый ID + const titleLower = title.toLowerCase(); + + // Маппинг ключевых слов к категориям (обновленные ID из базы данных) + const categoryMapping = { + // Строительство и ремонт + строитель: 30, // Стройка + строительство: 30, + стройка: 30, + строительн: 30, + плотник: 44, // Плотник + плотнич: 44, + сварщик: 49, // Сварщик + сварка: 49, + электрик: 57, // Электрик + электрич: 57, + ремонт: 45, // Ремонт + ремонтн: 45, + + // Транспорт и доставка + водитель: 35, // Перевозка + шофер: 35, + доставка: 34, // Доставка + курьер: 34, + транспорт: 53, // Транспорт + перевозка: 35, + + // Склад и производство + склад: 48, // Склад + складск: 48, + завод: 37, // Завод + производство: 55, // Производство + производств: 55, + + // Торговля и офис + продавец: 54, // Торговля + продаж: 54, + кассир: 54, + офис: 42, // Офис + офисн: 42, + секретарь: 42, + администратор: 42, + + // Общепит и гостиницы + кухня: 43, // Общепит + повар: 43, + официант: 43, + бармен: 43, + ресторан: 43, + кафе: 43, + гостиница: 33, // Гостиницы + отель: 33, + hotel: 33, + + // Уборка и обслуживание + уборщица: 31, // Уборка + уборщик: 31, + уборка: 31, + клининг: 31, + cleaning: 31, + + // Медицина и здоровье + медицин: 47, // Медицина + врач: 47, + медсестра: 47, + здоровье: 38, // Здоровье + медицинск: 47, + + // Образование и няни + учитель: 46, // Образование + преподаватель: 46, + репетитор: 46, + образование: 46, + няня: 40, // Няни + нянь: 40, + babysitter: 40, + + // Охрана и безопасность + охранник: 41, // Охрана + охрана: 41, + security: 41, + 'security guard': 41, + + // Бьюти-индустрия + парикмахер: 32, // Бьюти-индустрия + массажист: 32, + косметолог: 32, + маникюр: 32, + салон: 32, + beauty: 32, + + // Автосервис + автосервис: 36, // Автосервис + механик: 36, + авто: 36, + car: 36, + garage: 36, + + // Связь и телекоммуникации + связь: 50, // Связь-телекоммуникации + телеком: 50, + программист: 50, + it: 50, + developer: 50, + + // Сельское хозяйство + сельское: 51, // Сельское хозяйство + фермер: 51, + садовник: 51, + сельскохозяйств: 51, + + // Уход за пожилыми + уход: 52, // Уход за пожилыми + пожил: 52, + сиделка: 52, + caregiver: 52, + + // Швеи + швея: 56, // Швеи + портной: 56, + швейн: 56, + seamstress: 56, + + // Инженеры + инженер: 39, // Инженеры + engineer: 39, + техник: 39, + + // Общие слова для разных категорий + рабочий: 58, // Разное + работник: 58, + помощник: 58, + assistant: 58, + worker: 58, + }; + + // Проверяем каждое ключевое слово + for (const [keyword, categoryId] of Object.entries(categoryMapping)) { + if (titleLower.includes(keyword)) { + return categoryId; + } + } + + // Если не найдено точное совпадение, возвращаем "Разное" + return 58; // Разное - новый ID } // Генерация заголовков и определение категорий для вакансий async function generateJobTitles(jobs) { - console.log("🤖 Генерируем заголовки и определяем категории для вакансий..."); - console.log("💡 Используем надежную fallback систему (rule-based)"); - console.log("✅ Нет ограничений по rate limits или quota"); - console.log("⚡ Мгновенная обработка без задержек"); - console.log("🏷️ Автоматическое определение категорий на основе заголовков"); - - let successCount = 0; - let fallbackCount = 0; - let categoryAssignedCount = 0; - let totalTime = 0; - - // Статистика по категориям - const categoryStats = {}; - - for (let i = 0; i < jobs.length; i++) { - const job = jobs[i]; - - try { - if (job.title === "Без названия") { - console.log(` 🔄 Генерируем заголовок для вакансии ${i + 1}/${jobs.length}...`); - - const startTime = Date.now(); - - // Используем fallback систему напрямую для надежности - const titleData = AIJobTitleService.fallbackTitleGeneration(job.description); - const endTime = Date.now(); - - job.title = titleData.title; - const processingTime = endTime - startTime; - totalTime += processingTime; - - console.log(` ✅ Успех: "${titleData.title}" (${titleData.method}, ${processingTime}ms)`); - console.log(` 🎯 Confidence: ${titleData.confidence.toFixed(2)}`); - - successCount++; - fallbackCount++; - } - - // Определяем категорию на основе заголовка - const categoryId = determineCategoryFromTitle(job.title); - job.categoryId = categoryId; - categoryAssignedCount++; - - // Обновляем статистику - categoryStats[categoryId] = (categoryStats[categoryId] || 0) + 1; - - console.log(` 🏷️ Категория определена: ID ${categoryId} для "${job.title}"`); - - // Небольшая задержка для логирования (не для rate limiting) - if (i % 10 === 0) { - console.log(` 📊 Прогресс: ${i + 1}/${jobs.length} (${((i + 1) / jobs.length * 100).toFixed(1)}%)`); - } - - } catch (error) { - console.error(` ❌ Ошибка обработки вакансии ${i + 1}:`, error.message); - - // Используем базовый fallback заголовок и категорию - job.title = "Общая вакансия"; - job.categoryId = 58; // Разное - новый ID - fallbackCount++; - categoryAssignedCount++; - categoryStats[58] = (categoryStats[58] || 0) + 1; - } - } - - console.log(`\n📊 Обработка завершена:`); - console.log(` Успешно обработано: ${successCount}`); - console.log(` Использовано fallback: ${fallbackCount}`); - console.log(` Категорий назначено: ${categoryAssignedCount}`); - console.log(` Среднее время обработки: ${(totalTime / successCount).toFixed(0)}ms`); - console.log(` Общее время: ${totalTime}ms`); - - console.log(`\n📈 Статистика по категориям:`); - for (const [categoryId, count] of Object.entries(categoryStats)) { - const percentage = ((count / jobs.length) * 100).toFixed(1); - console.log(` Категория ID ${categoryId}: ${count} вакансий (${percentage}%)`); - } - - console.log(`\n💡 Преимущества системы:`); - console.log(` ✅ Нет API затрат`); - console.log(` ✅ Нет rate limits`); - console.log(` ✅ Нет quota проблем`); - console.log(` ✅ Мгновенная обработка`); - console.log(` ✅ Надежные результаты`); - console.log(` ✅ Всегда доступна`); - console.log(` ✅ Автоматическое определение категорий`); - - return jobs; + console.log('🤖 Генерируем заголовки и определяем категории для вакансий...'); + console.log('💡 Используем надежную fallback систему (rule-based)'); + console.log('✅ Нет ограничений по rate limits или quota'); + console.log('⚡ Мгновенная обработка без задержек'); + console.log('🏷️ Автоматическое определение категорий на основе заголовков'); + + let successCount = 0; + let fallbackCount = 0; + let categoryAssignedCount = 0; + let totalTime = 0; + + // Статистика по категориям + const categoryStats = {}; + + for (let i = 0; i < jobs.length; i++) { + const job = jobs[i]; + + try { + if (job.title === 'Без названия') { + console.log( + ` 🔄 Генерируем заголовок для вакансии ${i + 1}/${jobs.length}...`, + ); + + const startTime = Date.now(); + + // Используем fallback систему напрямую для надежности + const titleData = AIJobTitleService.fallbackTitleGeneration( + job.description, + ); + const endTime = Date.now(); + + job.title = titleData.title; + const processingTime = endTime - startTime; + totalTime += processingTime; + + console.log( + ` ✅ Успех: "${titleData.title}" (${titleData.method}, ${processingTime}ms)`, + ); + console.log(` 🎯 Confidence: ${titleData.confidence.toFixed(2)}`); + + successCount++; + fallbackCount++; + } + + // Определяем категорию на основе заголовка + const categoryId = determineCategoryFromTitle(job.title); + job.categoryId = categoryId; + categoryAssignedCount++; + + // Обновляем статистику + categoryStats[categoryId] = (categoryStats[categoryId] || 0) + 1; + + console.log( + ` 🏷️ Категория определена: ID ${categoryId} для "${job.title}"`, + ); + + // Небольшая задержка для логирования (не для rate limiting) + if (i % 10 === 0) { + console.log( + ` 📊 Прогресс: ${i + 1}/${jobs.length} (${(((i + 1) / jobs.length) * 100).toFixed(1)}%)`, + ); + } + } catch (error) { + console.error(` ❌ Ошибка обработки вакансии ${i + 1}:`, error.message); + + // Используем базовый fallback заголовок и категорию + job.title = 'Общая вакансия'; + job.categoryId = 58; // Разное - новый ID + fallbackCount++; + categoryAssignedCount++; + categoryStats[58] = (categoryStats[58] || 0) + 1; + } + } + + console.log(`\n📊 Обработка завершена:`); + console.log(` Успешно обработано: ${successCount}`); + console.log(` Использовано fallback: ${fallbackCount}`); + console.log(` Категорий назначено: ${categoryAssignedCount}`); + console.log( + ` Среднее время обработки: ${(totalTime / successCount).toFixed(0)}ms`, + ); + console.log(` Общее время: ${totalTime}ms`); + + console.log(`\n📈 Статистика по категориям:`); + for (const [categoryId, count] of Object.entries(categoryStats)) { + const percentage = ((count / jobs.length) * 100).toFixed(1); + console.log( + ` Категория ID ${categoryId}: ${count} вакансий (${percentage}%)`, + ); + } + + console.log(`\n💡 Преимущества системы:`); + console.log(` ✅ Нет API затрат`); + console.log(` ✅ Нет rate limits`); + console.log(` ✅ Нет quota проблем`); + console.log(` ✅ Мгновенная обработка`); + console.log(` ✅ Надежные результаты`); + console.log(` ✅ Всегда доступна`); + console.log(` ✅ Автоматическое определение категорий`); + + return jobs; } // Создание фейковых пользователей и прикрепление вакансий async function createFakeUsersWithJobs(jobs) { - console.log(`👥 Создаем фейковых пользователей и привязываем вакансии...`); - - const defaultCategory = await prisma.category.findUnique({ - where: { name: 'Разное' }, - }); - - if (!defaultCategory) { - console.error('❌ Категория "Разное" не найдена. Пожалуйста, запустите `prisma db seed`'); - return; - } - - for (const job of jobs) { - const city = await prisma.city.findUnique({ where: { name: job.city } }); - - if (!city) { - console.log(`⚠️ Город "${job.city}" не найден в базе. Пропускаем вакансию.`); - continue; - } - - // Генерация русско-еврейских имен - const firstName = Math.random() < 0.5 ? faker.person.firstName() : faker.helpers.arrayElement([ - "Авраам", "Ицхак", "Яков", "Моше", "Шломо", "Давид", "Элиэзер", "Менахем", "Иехуда", "Шимон", - "Сара", "Рахель", "Лея", "Мириам", "Хана", "Батшева", "Ада", "Эстер", "Тамар", "Наоми" - ]); - const lastName = faker.person.lastName(); - const email = faker.internet.email({ firstName, lastName }); - - const clerkUserId = `user_${faker.string.uuid()}`; - - // ✅ Используем только реальный номер телефона из объявления - const phone = job.phone; - - // ✅ Используем валидированную цену из описания - const salary = job.validatedPrice ? `${job.validatedPrice}` : `${faker.number.int({ min: 35, max: 50 })}`; - - // 🟢 Генерация URL аватарки с инициалами - const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); - const imageUrl = `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=random&color=fff&size=128`; - - // Используем назначенную категорию или fallback на "Разное" - const categoryId = job.categoryId || defaultCategory.id; - - await prisma.user.create({ - data: { - clerkUserId, - firstName, - lastName, - email, - imageUrl, // Используем аватарку с инициалами - jobs: { - create: { - title: job.title, - description: job.description, - salary: salary, - phone: phone, - city: { connect: { id: city.id } }, - category: { connect: { id: categoryId } }, - }, - }, - }, - }); - } - - console.log("🎉 Все фейковые пользователи и вакансии успешно созданы!"); + console.log(`👥 Создаем фейковых пользователей и привязываем вакансии...`); + + const defaultCategory = await prisma.category.findUnique({ + where: { name: 'Разное' }, + }); + + if (!defaultCategory) { + console.error( + '❌ Категория "Разное" не найдена. Пожалуйста, запустите `prisma db seed`', + ); + return; + } + + for (const job of jobs) { + const city = await prisma.city.findUnique({ where: { name: job.city } }); + + if (!city) { + console.log( + `⚠️ Город "${job.city}" не найден в базе. Пропускаем вакансию.`, + ); + continue; + } + + // Генерация русско-еврейских имен + const firstName = + Math.random() < 0.5 + ? faker.person.firstName() + : faker.helpers.arrayElement([ + 'Авраам', + 'Ицхак', + 'Яков', + 'Моше', + 'Шломо', + 'Давид', + 'Элиэзер', + 'Менахем', + 'Иехуда', + 'Шимон', + 'Сара', + 'Рахель', + 'Лея', + 'Мириам', + 'Хана', + 'Батшева', + 'Ада', + 'Эстер', + 'Тамар', + 'Наоми', + ]); + const lastName = faker.person.lastName(); + const email = faker.internet.email({ firstName, lastName }); + + const clerkUserId = `user_${faker.string.uuid()}`; + + // ✅ Используем только реальный номер телефона из объявления + const phone = job.phone; + + // ✅ Используем валидированную цену из описания + const salary = job.validatedPrice + ? `${job.validatedPrice}` + : `${faker.number.int({ min: 35, max: 50 })}`; + + // 🟢 Генерация URL аватарки с инициалами + const initials = + `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); + const imageUrl = `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=random&color=fff&size=128`; + + // Используем назначенную категорию или fallback на "Разное" + const categoryId = job.categoryId || defaultCategory.id; + + await prisma.user.create({ + data: { + clerkUserId, + firstName, + lastName, + email, + imageUrl, // Используем аватарку с инициалами + jobs: { + create: { + title: job.title, + description: job.description, + salary: salary, + phone: phone, + city: { connect: { id: city.id } }, + category: { connect: { id: categoryId } }, + }, + }, + }, + }); + } + + console.log('🎉 Все фейковые пользователи и вакансии успешно созданы!'); } // Функция для ранней обработки вакансий async function processJobsEarly(jobs) { - console.log(`🚀 Начинаем раннюю обработку ${jobs.length} вакансий...`); - - try { - // Генерируем заголовки и категории - const jobsWithTitles = await generateJobTitles(jobs); - - // Создаем пользователей и вакансии - await createFakeUsersWithJobs(jobsWithTitles); - - console.log(`✅ Ранняя обработка завершена успешно! Обработано ${jobs.length} вакансий.`); - - } catch (error) { - console.error(`❌ Ошибка при ранней обработке:`, error); - throw error; - } + console.log(`🚀 Начинаем раннюю обработку ${jobs.length} вакансий...`); + + try { + // Генерируем заголовки и категории + const jobsWithTitles = await generateJobTitles(jobs); + + // Создаем пользователей и вакансии + await createFakeUsersWithJobs(jobsWithTitles); + + console.log( + `✅ Ранняя обработка завершена успешно! Обработано ${jobs.length} вакансий.`, + ); + } catch (error) { + console.error(`❌ Ошибка при ранней обработке:`, error); + throw error; + } } // Основная функция async function main() { - try { - console.log("🚀 Запуск скрипта с валидацией цен...\n"); - console.log("💡 Используем rule-based генерацию заголовков"); - console.log("💰 Валидируем цены в описаниях для консистентности"); - console.log("✅ Нет зависимости от OpenAI API"); - console.log("⚡ Быстрая и надежная обработка"); - console.log("🎯 Начинаем обработку при 100 вакансиях для эффективности\n"); - - await clearOldData(); - const jobs = await fetchJobDescriptions(); - - // Проверяем, была ли уже выполнена ранняя обработка - if (jobs.length >= 100) { - console.log("✅ Ранняя обработка уже выполнена! Пропускаем повторную обработку."); - } else { - console.log("🔄 Ранняя обработка не была выполнена. Обрабатываем все вакансии..."); - - // Генерируем заголовки с надежной fallback системой - const jobsWithTitles = await generateJobTitles(jobs); - - await createFakeUsersWithJobs(jobsWithTitles); - } - - console.log("\n✅ Скрипт успешно завершен!"); - console.log("📊 Статистика:"); - console.log(` - Всего валидных вакансий: ${jobs.length}`); - console.log(` - Успешно обработано: ${jobs.filter(j => j.title !== "Без названия").length}`); - console.log(` - Использовано fallback: ${jobs.filter(j => j.title !== "Без названия").length}`); - console.log(` - Валидированных цен: ${jobs.filter(j => j.validatedPrice).length}`); - - console.log("\n💡 Преимущества обновленной системы:"); - console.log(" - Нет API затрат"); - console.log(" - Нет rate limits или quota проблем"); - console.log(" - Мгновенная обработка"); - console.log(" - 100% надежность"); - console.log(" - Подходящие заголовки для израильского рынка"); - console.log(" - Консистентные цены между описанием и полем зарплаты"); - console.log(" - Ранняя обработка при 100 вакансиях для эффективности"); - console.log(" - Умная обработка таймаутов - используем реальные вакансии при ошибках"); - - } catch (error) { - console.error("❌ Критическая ошибка в скрипте:", error); - } finally { - await prisma.$disconnect(); - } + try { + console.log('🚀 Запуск скрипта с валидацией цен...\n'); + console.log('💡 Используем rule-based генерацию заголовков'); + console.log('💰 Валидируем цены в описаниях для консистентности'); + console.log('✅ Нет зависимости от OpenAI API'); + console.log('⚡ Быстрая и надежная обработка'); + console.log('🎯 Начинаем обработку при 100 вакансиях для эффективности\n'); + + await clearOldData(); + const jobs = await fetchJobDescriptions(); + + // Проверяем, была ли уже выполнена ранняя обработка + if (jobs.length >= 100) { + console.log( + '✅ Ранняя обработка уже выполнена! Пропускаем повторную обработку.', + ); + } else { + console.log( + '🔄 Ранняя обработка не была выполнена. Обрабатываем все вакансии...', + ); + + // Генерируем заголовки с надежной fallback системой + const jobsWithTitles = await generateJobTitles(jobs); + + await createFakeUsersWithJobs(jobsWithTitles); + } + + console.log('\n✅ Скрипт успешно завершен!'); + console.log('📊 Статистика:'); + console.log(` - Всего валидных вакансий: ${jobs.length}`); + console.log( + ` - Успешно обработано: ${jobs.filter((j) => j.title !== 'Без названия').length}`, + ); + console.log( + ` - Использовано fallback: ${jobs.filter((j) => j.title !== 'Без названия').length}`, + ); + console.log( + ` - Валидированных цен: ${jobs.filter((j) => j.validatedPrice).length}`, + ); + + console.log('\n💡 Преимущества обновленной системы:'); + console.log(' - Нет API затрат'); + console.log(' - Нет rate limits или quota проблем'); + console.log(' - Мгновенная обработка'); + console.log(' - 100% надежность'); + console.log(' - Подходящие заголовки для израильского рынка'); + console.log(' - Консистентные цены между описанием и полем зарплаты'); + console.log(' - Ранняя обработка при 100 вакансиях для эффективности'); + console.log( + ' - Умная обработка таймаутов - используем реальные вакансии при ошибках', + ); + } catch (error) { + console.error('❌ Критическая ошибка в скрипте:', error); + } finally { + await prisma.$disconnect(); + } } main().catch(console.error); diff --git a/apps/api/utils/s3Upload.js b/apps/api/utils/s3Upload.js index db48c8d..720a44b 100644 --- a/apps/api/utils/s3Upload.js +++ b/apps/api/utils/s3Upload.js @@ -1,16 +1,19 @@ import AWS from 'aws-sdk'; +import dotenv from 'dotenv'; import multer from 'multer'; import { v4 as uuidv4 } from 'uuid'; -import dotenv from 'dotenv'; -import { moderateImage, validateRekognitionConfig } from '../services/imageModerationService.js'; +import { + moderateImage, + validateRekognitionConfig, +} from '../services/imageModerationService.js'; dotenv.config(); // Configure AWS const s3 = new AWS.S3({ - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, - region: process.env.AWS_REGION || 'us-east-1' + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + region: process.env.AWS_REGION || 'us-east-1', }); const BUCKET_NAME = process.env.AWS_S3_BUCKET_NAME; @@ -20,20 +23,20 @@ const storage = multer.memoryStorage(); // File filter for images only const fileFilter = (req, file, cb) => { - if (file.mimetype.startsWith('image/')) { - cb(null, true); - } else { - cb(new Error('Only image files are allowed!'), false); - } + if (file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('Only image files are allowed!'), false); + } }; // Configure multer export const upload = multer({ - storage: storage, - fileFilter: fileFilter, - limits: { - fileSize: 5 * 1024 * 1024 // 5MB limit - } + storage: storage, + fileFilter: fileFilter, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit + }, }); /** @@ -44,54 +47,62 @@ export const upload = multer({ * @param {string} folder - Folder path in S3 (optional) * @returns {Promise} Public URL of uploaded image */ -export const uploadToS3 = async (fileBuffer, originalname, mimetype, folder = 'jobs') => { - try { - console.log('🔍 S3 Upload - Starting upload process...'); - console.log('🔍 S3 Upload - Bucket:', BUCKET_NAME); - console.log('🔍 S3 Upload - Region:', process.env.AWS_REGION); - console.log('🔍 S3 Upload - Access Key ID:', process.env.AWS_ACCESS_KEY_ID?.substring(0, 8) + '...'); - - // Generate unique filename - const fileExtension = originalname.split('.').pop(); - const fileName = `${folder}/${uuidv4()}.${fileExtension}`; - - const uploadParams = { - Bucket: BUCKET_NAME, - Key: fileName, - Body: fileBuffer, - ContentType: mimetype, - Metadata: { - 'original-name': originalname - } - }; - - console.log('🔍 S3 Upload - Upload params:', { - Bucket: uploadParams.Bucket, - Key: uploadParams.Key, - ContentType: uploadParams.ContentType, - BodySize: fileBuffer.length - }); - - const result = await s3.upload(uploadParams).promise(); - - console.log('✅ S3 upload successful:', { - fileName: fileName, - url: result.Location, - size: fileBuffer.length - }); - - return result.Location; - } catch (error) { - console.error('❌ S3 upload error details:', { - message: error.message, - code: error.code, - statusCode: error.statusCode, - requestId: error.requestId, - cfId: error.cfId, - extendedRequestId: error.extendedRequestId - }); - throw new Error(`Failed to upload image to S3: ${error.message}`); - } +export const uploadToS3 = async ( + fileBuffer, + originalname, + mimetype, + folder = 'jobs', +) => { + try { + console.log('🔍 S3 Upload - Starting upload process...'); + console.log('🔍 S3 Upload - Bucket:', BUCKET_NAME); + console.log('🔍 S3 Upload - Region:', process.env.AWS_REGION); + console.log( + '🔍 S3 Upload - Access Key ID:', + process.env.AWS_ACCESS_KEY_ID?.substring(0, 8) + '...', + ); + + // Generate unique filename + const fileExtension = originalname.split('.').pop(); + const fileName = `${folder}/${uuidv4()}.${fileExtension}`; + + const uploadParams = { + Bucket: BUCKET_NAME, + Key: fileName, + Body: fileBuffer, + ContentType: mimetype, + Metadata: { + 'original-name': originalname, + }, + }; + + console.log('🔍 S3 Upload - Upload params:', { + Bucket: uploadParams.Bucket, + Key: uploadParams.Key, + ContentType: uploadParams.ContentType, + BodySize: fileBuffer.length, + }); + + const result = await s3.upload(uploadParams).promise(); + + console.log('✅ S3 upload successful:', { + fileName: fileName, + url: result.Location, + size: fileBuffer.length, + }); + + return result.Location; + } catch (error) { + console.error('❌ S3 upload error details:', { + message: error.message, + code: error.code, + statusCode: error.statusCode, + requestId: error.requestId, + cfId: error.cfId, + extendedRequestId: error.extendedRequestId, + }); + throw new Error(`Failed to upload image to S3: ${error.message}`); + } }; /** @@ -100,29 +111,29 @@ export const uploadToS3 = async (fileBuffer, originalname, mimetype, folder = 'j * @returns {Promise} Success status */ export const deleteFromS3 = async (imageUrl) => { - try { - if (!imageUrl || !imageUrl.includes(BUCKET_NAME)) { - console.warn('⚠️ Invalid S3 URL for deletion:', imageUrl); - return false; - } - - // Extract key from URL - const urlParts = imageUrl.split('/'); - const key = urlParts.slice(3).join('/'); // Remove protocol, domain, and bucket name - - const deleteParams = { - Bucket: BUCKET_NAME, - Key: key - }; - - await s3.deleteObject(deleteParams).promise(); - - console.log('✅ S3 deletion successful:', key); - return true; - } catch (error) { - console.error('❌ S3 deletion error:', error); - return false; - } + try { + if (!imageUrl || !imageUrl.includes(BUCKET_NAME)) { + console.warn('⚠️ Invalid S3 URL for deletion:', imageUrl); + return false; + } + + // Extract key from URL + const urlParts = imageUrl.split('/'); + const key = urlParts.slice(3).join('/'); // Remove protocol, domain, and bucket name + + const deleteParams = { + Bucket: BUCKET_NAME, + Key: key, + }; + + await s3.deleteObject(deleteParams).promise(); + + console.log('✅ S3 deletion successful:', key); + return true; + } catch (error) { + console.error('❌ S3 deletion error:', error); + return false; + } }; /** @@ -130,46 +141,50 @@ export const deleteFromS3 = async (imageUrl) => { * @returns {Object} Configuration status with details */ export const validateS3Config = () => { - const requiredEnvVars = [ - 'AWS_ACCESS_KEY_ID', - 'AWS_SECRET_ACCESS_KEY', - 'AWS_S3_BUCKET_NAME' - ]; - - const missingVars = requiredEnvVars.filter(varName => !process.env[varName]); - - if (missingVars.length > 0) { - console.error('❌ Missing S3 environment variables:', missingVars); - console.error('💡 Please add the following to your .env file:'); - missingVars.forEach(varName => { - console.error(` ${varName}=your_${varName.toLowerCase()}`); - }); - console.error('📖 See SETUP_GUIDE.md for detailed instructions'); - return { - isValid: false, - missingVars, - message: `Missing S3 environment variables: ${missingVars.join(', ')}` - }; - } - - return { - isValid: true, - missingVars: [], - message: 'S3 configuration is valid' - }; + const requiredEnvVars = [ + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_S3_BUCKET_NAME', + ]; + + const missingVars = requiredEnvVars.filter( + (varName) => !process.env[varName], + ); + + if (missingVars.length > 0) { + console.error('❌ Missing S3 environment variables:', missingVars); + console.error('💡 Please add the following to your .env file:'); + missingVars.forEach((varName) => { + console.error(` ${varName}=your_${varName.toLowerCase()}`); + }); + console.error('📖 See SETUP_GUIDE.md for detailed instructions'); + return { + isValid: false, + missingVars, + message: `Missing S3 environment variables: ${missingVars.join(', ')}`, + }; + } + + return { + isValid: true, + missingVars: [], + message: 'S3 configuration is valid', + }; }; // Validate configurations on startup const s3ConfigStatus = validateS3Config(); if (!s3ConfigStatus.isValid) { - console.warn('⚠️ S3 configuration is incomplete. S3 uploads will not work.'); - console.warn('📖 See SETUP_GUIDE.md for configuration instructions'); + console.warn('⚠️ S3 configuration is incomplete. S3 uploads will not work.'); + console.warn('📖 See SETUP_GUIDE.md for configuration instructions'); } const rekognitionConfigStatus = validateRekognitionConfig(); if (!rekognitionConfigStatus.isValid) { - console.warn('⚠️ Rekognition configuration is incomplete. Image moderation will not work.'); - console.warn('📖 See SETUP_GUIDE.md for configuration instructions'); + console.warn( + '⚠️ Rekognition configuration is incomplete. Image moderation will not work.', + ); + console.warn('📖 See SETUP_GUIDE.md for configuration instructions'); } /** @@ -180,49 +195,62 @@ if (!rekognitionConfigStatus.isValid) { * @param {string} folder - Folder path in S3 (optional) * @returns {Promise} Upload result with moderation status */ -export const uploadToS3WithModeration = async (fileBuffer, originalname, mimetype, folder = 'jobs') => { - try { - console.log('🔍 S3 Upload with Moderation - Starting process...'); - - // Step 1: Moderate the image - console.log('🔍 S3 Upload with Moderation - Running content moderation...'); - const moderationResult = await moderateImage(fileBuffer); - - if (!moderationResult.isApproved) { - console.log('❌ S3 Upload with Moderation - Content rejected by moderation'); - return { - success: false, - error: 'Image content violates community guidelines', - code: 'CONTENT_REJECTED', - moderationResult - }; - } - - console.log('✅ S3 Upload with Moderation - Content approved, proceeding with upload'); - - // Step 2: Upload to S3 - const imageUrl = await uploadToS3(fileBuffer, originalname, mimetype, folder); - - return { - success: true, - imageUrl, - moderationResult - }; - - } catch (error) { - console.error('❌ S3 Upload with Moderation - Error:', error); - return { - success: false, - error: error.message, - code: 'UPLOAD_FAILED' - }; - } +export const uploadToS3WithModeration = async ( + fileBuffer, + originalname, + mimetype, + folder = 'jobs', +) => { + try { + console.log('🔍 S3 Upload with Moderation - Starting process...'); + + // Step 1: Moderate the image + console.log('🔍 S3 Upload with Moderation - Running content moderation...'); + const moderationResult = await moderateImage(fileBuffer); + + if (!moderationResult.isApproved) { + console.log( + '❌ S3 Upload with Moderation - Content rejected by moderation', + ); + return { + success: false, + error: 'Image content violates community guidelines', + code: 'CONTENT_REJECTED', + moderationResult, + }; + } + + console.log( + '✅ S3 Upload with Moderation - Content approved, proceeding with upload', + ); + + // Step 2: Upload to S3 + const imageUrl = await uploadToS3( + fileBuffer, + originalname, + mimetype, + folder, + ); + + return { + success: true, + imageUrl, + moderationResult, + }; + } catch (error) { + console.error('❌ S3 Upload with Moderation - Error:', error); + return { + success: false, + error: error.message, + code: 'UPLOAD_FAILED', + }; + } }; export default { - upload, - uploadToS3, - uploadToS3WithModeration, - deleteFromS3, - validateS3Config -}; \ No newline at end of file + upload, + uploadToS3, + uploadToS3WithModeration, + deleteFromS3, + validateS3Config, +}; diff --git a/apps/api/utils/showCurrentJobTitles.js b/apps/api/utils/showCurrentJobTitles.js index 06ac16a..15bbe53 100644 --- a/apps/api/utils/showCurrentJobTitles.js +++ b/apps/api/utils/showCurrentJobTitles.js @@ -1,70 +1,76 @@ import pkg from '@prisma/client'; + const { PrismaClient } = pkg; const prisma = new PrismaClient(); async function showCurrentJobTitles() { - console.log("📋 Current Job Titles in Database\n"); - - try { - const jobs = await prisma.job.findMany({ - include: { - city: true, - category: true - }, - orderBy: { - createdAt: 'desc' - }, - take: 20 - }); - - console.log(`📊 Showing ${jobs.length} recent jobs:\n`); - - jobs.forEach((job, index) => { - console.log(`${index + 1}. Title: "${job.title}"`); - console.log(` City: ${job.city?.name || 'Unknown'}`); - console.log(` Salary: ${job.salary} шек/час`); - console.log(` Description: ${job.description.substring(0, 100)}...`); - console.log('─'.repeat(60)); - }); - - // Count title types - const titleCounts = {}; - jobs.forEach(job => { - titleCounts[job.title] = (titleCounts[job.title] || 0) + 1; - }); - - console.log("\n📈 Title Distribution:"); - Object.entries(titleCounts) - .sort(([,a], [,b]) => b - a) - .forEach(([title, count]) => { - console.log(` "${title}": ${count} jobs`); - }); - - // Count specific vs generic titles - const specificTitles = jobs.filter(job => - job.title !== "Общая вакансия" && - !job.title.includes("Работник") && - job.title.length < 30 - ); - - const genericTitles = jobs.filter(job => - job.title === "Общая вакансия" || - job.title.includes("Работник") || - job.title.length >= 30 - ); - - console.log(`\n📊 Summary:`); - console.log(` Total jobs shown: ${jobs.length}`); - console.log(` Specific titles: ${specificTitles.length} (${((specificTitles.length / jobs.length) * 100).toFixed(1)}%)`); - console.log(` Generic titles: ${genericTitles.length} (${((genericTitles.length / jobs.length) * 100).toFixed(1)}%)`); - - } catch (error) { - console.error('❌ Error showing job titles:', error); - } finally { - await prisma.$disconnect(); - } + console.log('📋 Current Job Titles in Database\n'); + + try { + const jobs = await prisma.job.findMany({ + include: { + city: true, + category: true, + }, + orderBy: { + createdAt: 'desc', + }, + take: 20, + }); + + console.log(`📊 Showing ${jobs.length} recent jobs:\n`); + + jobs.forEach((job, index) => { + console.log(`${index + 1}. Title: "${job.title}"`); + console.log(` City: ${job.city?.name || 'Unknown'}`); + console.log(` Salary: ${job.salary} шек/час`); + console.log(` Description: ${job.description.substring(0, 100)}...`); + console.log('─'.repeat(60)); + }); + + // Count title types + const titleCounts = {}; + jobs.forEach((job) => { + titleCounts[job.title] = (titleCounts[job.title] || 0) + 1; + }); + + console.log('\n📈 Title Distribution:'); + Object.entries(titleCounts) + .sort(([, a], [, b]) => b - a) + .forEach(([title, count]) => { + console.log(` "${title}": ${count} jobs`); + }); + + // Count specific vs generic titles + const specificTitles = jobs.filter( + (job) => + job.title !== 'Общая вакансия' && + !job.title.includes('Работник') && + job.title.length < 30, + ); + + const genericTitles = jobs.filter( + (job) => + job.title === 'Общая вакансия' || + job.title.includes('Работник') || + job.title.length >= 30, + ); + + console.log(`\n📊 Summary:`); + console.log(` Total jobs shown: ${jobs.length}`); + console.log( + ` Specific titles: ${specificTitles.length} (${((specificTitles.length / jobs.length) * 100).toFixed(1)}%)`, + ); + console.log( + ` Generic titles: ${genericTitles.length} (${((genericTitles.length / jobs.length) * 100).toFixed(1)}%)`, + ); + } catch (error) { + console.error('❌ Error showing job titles:', error); + } finally { + await prisma.$disconnect(); + } } // Run the script -showCurrentJobTitles().catch(console.error); \ No newline at end of file +showCurrentJobTitles().catch(console.error); diff --git a/apps/api/utils/stripe.js b/apps/api/utils/stripe.js index 8cfc3ae..49fbd9f 100755 --- a/apps/api/utils/stripe.js +++ b/apps/api/utils/stripe.js @@ -1,12 +1,23 @@ -import Stripe from 'stripe'; import dotenv from 'dotenv'; +import Stripe from 'stripe'; dotenv.config({ path: '.env.local' }); dotenv.config(); -console.log('🔍 Stripe config - STRIPE_SECRET_KEY available:', !!process.env.STRIPE_SECRET_KEY); -console.log('🔍 Stripe config - STRIPE_SECRET_KEY length:', process.env.STRIPE_SECRET_KEY ? process.env.STRIPE_SECRET_KEY.length : 0); -console.log('🔍 Stripe config - STRIPE_SECRET_KEY starts with:', process.env.STRIPE_SECRET_KEY ? process.env.STRIPE_SECRET_KEY.substring(0, 20) + '...' : 'undefined'); +console.log( + '🔍 Stripe config - STRIPE_SECRET_KEY available:', + !!process.env.STRIPE_SECRET_KEY, +); +console.log( + '🔍 Stripe config - STRIPE_SECRET_KEY length:', + process.env.STRIPE_SECRET_KEY ? process.env.STRIPE_SECRET_KEY.length : 0, +); +console.log( + '🔍 Stripe config - STRIPE_SECRET_KEY starts with:', + process.env.STRIPE_SECRET_KEY + ? process.env.STRIPE_SECRET_KEY.substring(0, 20) + '...' + : 'undefined', +); // eslint-disable-next-line no-undef const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); diff --git a/apps/api/utils/telegram.js b/apps/api/utils/telegram.js index 6a9b87a..bc41a5e 100755 --- a/apps/api/utils/telegram.js +++ b/apps/api/utils/telegram.js @@ -11,12 +11,16 @@ const TELEGRAM_MAX_LENGTH = 4000; // Учитываем запас для Markdo * @param {Array} jobs - Список вакансий пользователя */ export const sendTelegramNotification = async (user, jobs) => { - try { - const messages = generateMessages(user, jobs, "🔥 *Новый премиум-пользователь!* 🔥"); - await sendTelegramMessages(messages); - } catch (error) { - console.error('❌ Ошибка отправки в Telegram:', error); - } + try { + const messages = generateMessages( + user, + jobs, + '🔥 *Новый премиум-пользователь!* 🔥', + ); + await sendTelegramMessages(messages); + } catch (error) { + console.error('❌ Ошибка отправки в Telegram:', error); + } }; /** @@ -25,21 +29,27 @@ export const sendTelegramNotification = async (user, jobs) => { * @param {Array} jobs - Список вакансий пользователя */ export const sendUpdatedJobListToTelegram = async (user, jobs) => { - try { - const messages = generateMessages(user, jobs, "⚡ *Обновление у премиум-пользователя!* ⚡"); - - if (jobs.length === 0) { - // 🔴 Уведомляем, если у пользователя больше нет вакансий - messages.push(`⚠️ *Премиум-пользователь больше не имеет ни одной вакансии!* ⚠️\n\n` + - `👤 *Имя:* ${user.firstName || 'Не указано'} ${user.lastName || ''}\n` + - `📧 *Email:* ${user.email}\n` + - `❌ Все вакансии удалены.`); - } - - await sendTelegramMessages(messages); - } catch (error) { - console.error(`❌ [Telegram] Ошибка при отправке уведомления:`, error); - } + try { + const messages = generateMessages( + user, + jobs, + '⚡ *Обновление у премиум-пользователя!* ⚡', + ); + + if (jobs.length === 0) { + // 🔴 Уведомляем, если у пользователя больше нет вакансий + messages.push( + `⚠️ *Премиум-пользователь больше не имеет ни одной вакансии!* ⚠️\n\n` + + `👤 *Имя:* ${user.firstName || 'Не указано'} ${user.lastName || ''}\n` + + `📧 *Email:* ${user.email}\n` + + `❌ Все вакансии удалены.`, + ); + } + + await sendTelegramMessages(messages); + } catch (error) { + console.error(`❌ [Telegram] Ошибка при отправке уведомления:`, error); + } }; /** @@ -48,57 +58,59 @@ export const sendUpdatedJobListToTelegram = async (user, jobs) => { * @param {Object} job - Созданная вакансия */ export const sendNewJobNotificationToTelegram = async (user, job) => { - try { - console.log("📢 Вызов sendNewJobNotificationToTelegram для вакансии:", job); - - if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) { - console.error("❌ Ошибка: TELEGRAM_BOT_TOKEN или TELEGRAM_CHAT_ID не установлены."); - return; - } - - const message = `🆕 *Новая вакансия от премиум-пользователя!* 🆕\n\n` + - `👤 *Имя:* ${user.firstName || 'Не указано'} ${user.lastName || ''}\n` + - `📧 *Email:* ${user.email}\n` + - `⚒️ *Категория:* ${job.category || 'Не указана'}\n` + - `💎 *Статус:* Премиум активирован!\n\n` + - `📌 *Вакансия:*\n` + - `🔹 *${job.title}* \n` + - `📍 *Город:* ${job.city?.name || 'Не указан'}\n` + - `💰 *Зарплата:* ${job.salary}\n` + - `📞 *Телефон:* ${job.phone}\n` + - `📅 *Дата:* ${new Date(job.createdAt).toLocaleDateString()}\n` + - `📝 *Описание:* ${job.description || 'Нет описания'}\n` + - `---`; - - - console.log(`📤 Отправляем сообщение в Telegram:\n${message}`); - - const response = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chat_id: TELEGRAM_CHAT_ID, - text: message, - parse_mode: 'Markdown', - }), - }); - - const data = await response.json(); - console.log("📩 Ответ от Telegram API:", data); - - if (!data.ok) { - console.error("❌ Ошибка Telegram:", data.description); - } else { - console.log(`✅ [Telegram] Уведомление о новой вакансии отправлено!`); - } - - } catch (error) { - console.error(`❌ Ошибка при отправке уведомления в Telegram:`, error); - } + try { + console.log('📢 Вызов sendNewJobNotificationToTelegram для вакансии:', job); + + if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) { + console.error( + '❌ Ошибка: TELEGRAM_BOT_TOKEN или TELEGRAM_CHAT_ID не установлены.', + ); + return; + } + + const message = + `🆕 *Новая вакансия от премиум-пользователя!* 🆕\n\n` + + `👤 *Имя:* ${user.firstName || 'Не указано'} ${user.lastName || ''}\n` + + `📧 *Email:* ${user.email}\n` + + `⚒️ *Категория:* ${job.category || 'Не указана'}\n` + + `💎 *Статус:* Премиум активирован!\n\n` + + `📌 *Вакансия:*\n` + + `🔹 *${job.title}* \n` + + `📍 *Город:* ${job.city?.name || 'Не указан'}\n` + + `💰 *Зарплата:* ${job.salary}\n` + + `📞 *Телефон:* ${job.phone}\n` + + `📅 *Дата:* ${new Date(job.createdAt).toLocaleDateString()}\n` + + `📝 *Описание:* ${job.description || 'Нет описания'}\n` + + `---`; + + console.log(`📤 Отправляем сообщение в Telegram:\n${message}`); + + const response = await fetch( + `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: TELEGRAM_CHAT_ID, + text: message, + parse_mode: 'Markdown', + }), + }, + ); + + const data = await response.json(); + console.log('📩 Ответ от Telegram API:', data); + + if (!data.ok) { + console.error('❌ Ошибка Telegram:', data.description); + } else { + console.log(`✅ [Telegram] Уведомление о новой вакансии отправлено!`); + } + } catch (error) { + console.error(`❌ Ошибка при отправке уведомления в Telegram:`, error); + } }; - - /** * Генерирует список сообщений для отправки в Telegram * @param {Object} user - Объект пользователя @@ -107,41 +119,43 @@ export const sendNewJobNotificationToTelegram = async (user, job) => { * @returns {Array} messages - Разбитый список сообщений */ const generateMessages = (user, jobs, header) => { - let messages = []; - let currentMessage = `${header}\n\n` + - `👤 *Имя:* ${user.firstName || 'Не указано'} ${user.lastName || ''}\n` + - `📧 *Email:* ${user.email}\n` + - `💎 *Статус:* Премиум активирован!\n\n` + - `📌 *Вакансии пользователя:*`; - - if (jobs.length === 0) { - currentMessage += `\n❌ У пользователя пока нет вакансий.`; - messages.push(currentMessage); - } else { - jobs.forEach((job, index) => { - let jobMessage = `\n\n🔹 *${index + 1}. ${job.title}* \n` + - `⚒️ *Категория:* ${job.category?.name || 'Не указана'}\n` + - `📍 *Город:* ${job.city?.name || job.city || 'Не указан'}\n` + - `💰 *Зарплата:* ${job.salary}\n` + - `📞 *Телефон:* ${job.phone}\n` + - `📅 *Дата:* ${new Date(job.createdAt).toLocaleDateString()}\n` + - `📝 *Описание:* ${job.description || 'Нет описания'}\n` + - `---`; - - if (currentMessage.length + jobMessage.length > TELEGRAM_MAX_LENGTH) { - messages.push(currentMessage); - currentMessage = ''; // Очищаем и создаем новый блок - } - - currentMessage += jobMessage; - }); - - if (currentMessage.length > 0) { - messages.push(currentMessage); - } - } - - return messages; + let messages = []; + let currentMessage = + `${header}\n\n` + + `👤 *Имя:* ${user.firstName || 'Не указано'} ${user.lastName || ''}\n` + + `📧 *Email:* ${user.email}\n` + + `💎 *Статус:* Премиум активирован!\n\n` + + `📌 *Вакансии пользователя:*`; + + if (jobs.length === 0) { + currentMessage += `\n❌ У пользователя пока нет вакансий.`; + messages.push(currentMessage); + } else { + jobs.forEach((job, index) => { + let jobMessage = + `\n\n🔹 *${index + 1}. ${job.title}* \n` + + `⚒️ *Категория:* ${job.category?.name || 'Не указана'}\n` + + `📍 *Город:* ${job.city?.name || job.city || 'Не указан'}\n` + + `💰 *Зарплата:* ${job.salary}\n` + + `📞 *Телефон:* ${job.phone}\n` + + `📅 *Дата:* ${new Date(job.createdAt).toLocaleDateString()}\n` + + `📝 *Описание:* ${job.description || 'Нет описания'}\n` + + `---`; + + if (currentMessage.length + jobMessage.length > TELEGRAM_MAX_LENGTH) { + messages.push(currentMessage); + currentMessage = ''; // Очищаем и создаем новый блок + } + + currentMessage += jobMessage; + }); + + if (currentMessage.length > 0) { + messages.push(currentMessage); + } + } + + return messages; }; /** @@ -149,28 +163,31 @@ const generateMessages = (user, jobs, header) => { * @param {Array} messages - Список сообщений для отправки */ const sendTelegramMessages = async (messages) => { - for (const msg of messages) { - console.log(`📤 Отправляем сообщение в Telegram:\n${msg}`); - - try { - const response = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chat_id: TELEGRAM_CHAT_ID, - text: msg, - parse_mode: 'Markdown', - }), - }); - - const data = await response.json(); - console.log("📩 Ответ от Telegram API:", data); - - if (!data.ok) { - console.error("❌ Ошибка Telegram:", data.description); - } - } catch (error) { - console.error("❌ Ошибка сети при отправке:", error); - } - } -}; \ No newline at end of file + for (const msg of messages) { + console.log(`📤 Отправляем сообщение в Telegram:\n${msg}`); + + try { + const response = await fetch( + `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: TELEGRAM_CHAT_ID, + text: msg, + parse_mode: 'Markdown', + }), + }, + ); + + const data = await response.json(); + console.log('📩 Ответ от Telegram API:', data); + + if (!data.ok) { + console.error('❌ Ошибка Telegram:', data.description); + } + } catch (error) { + console.error('❌ Ошибка сети при отправке:', error); + } + } +}; diff --git a/apps/api/utils/toastUtils.js b/apps/api/utils/toastUtils.js index 0a7fec6..075a082 100755 --- a/apps/api/utils/toastUtils.js +++ b/apps/api/utils/toastUtils.js @@ -1,40 +1,40 @@ import { toast } from 'react-hot-toast'; export const showToastError = (error) => { - console.error('Ошибка:', error.response?.data || error.message); - - // Check if upgrade is required - if (error.response?.data?.upgradeRequired) { - toast.error( - (t) => ( -
-
{error.response.data.error}
- -
- ), - { duration: 6000 } - ); - } else if (error.response?.data?.error) { - toast.error(error.response.data.error); - } else { - toast.error('Ошибка при создании объявления. Попробуйте позже.'); - } + console.error('Ошибка:', error.response?.data || error.message); + + // Check if upgrade is required + if (error.response?.data?.upgradeRequired) { + toast.error( + (t) => ( +
+
{error.response.data.error}
+ +
+ ), + { duration: 6000 }, + ); + } else if (error.response?.data?.error) { + toast.error(error.response.data.error); + } else { + toast.error('Ошибка при создании объявления. Попробуйте позже.'); + } }; export const showToastSuccess = (message) => { - toast.success(message); + toast.success(message); }; diff --git a/apps/api/utils/updateJobsWithAITitles.js b/apps/api/utils/updateJobsWithAITitles.js index 52060da..b9a6b91 100644 --- a/apps/api/utils/updateJobsWithAITitles.js +++ b/apps/api/utils/updateJobsWithAITitles.js @@ -1,99 +1,103 @@ -import AIJobTitleService from '../services/aiJobTitleService.js'; import pkg from '@prisma/client'; +import AIJobTitleService from '../services/aiJobTitleService.js'; + const { PrismaClient } = pkg; const prisma = new PrismaClient(); async function updateJobsWithAITitles() { - console.log("🤖 Updating Jobs with AI-Generated Titles\n"); - - try { - // Get all jobs that need title updates - const jobs = await prisma.job.findMany({ - include: { - city: true, - category: true - } - }); - - console.log(`📊 Found ${jobs.length} jobs to process\n`); - - let updatedCount = 0; - let skippedCount = 0; - let errorCount = 0; - - for (const job of jobs) { - try { - console.log(`\n🔍 Processing job ${job.id}: "${job.title}"`); - console.log(` Description: ${job.description.substring(0, 80)}...`); - - const context = { - city: job.city?.name, - salary: job.salary, - requirements: AIJobTitleService.extractRequirements(job.description) - }; - - const titleData = await AIJobTitleService.generateAITitle(job.description, context); - - console.log(` Generated: "${titleData.title}" (${titleData.method}, confidence: ${titleData.confidence.toFixed(2)})`); - - // Only update if the new title is better - if (titleData.confidence > 0.6 && titleData.title !== job.title) { - await prisma.job.update({ - where: { id: job.id }, - data: { title: titleData.title } - }); - - console.log(` ✅ Updated: "${job.title}" → "${titleData.title}"`); - updatedCount++; - } else { - console.log(` ⏭️ Skipped - confidence too low or same title`); - skippedCount++; - } - - // Add delay to avoid overwhelming the system - await new Promise(resolve => setTimeout(resolve, 100)); - - } catch (error) { - console.error(` ❌ Error processing job ${job.id}:`, error.message); - errorCount++; - } - } - - console.log(`\n📈 AI Update Summary:`); - console.log(` Total jobs processed: ${jobs.length}`); - console.log(` Jobs updated: ${updatedCount}`); - console.log(` Jobs skipped: ${skippedCount}`); - console.log(` Errors: ${errorCount}`); - - // Show some examples of updated titles - const updatedJobs = await prisma.job.findMany({ - orderBy: { - createdAt: 'desc' - }, - take: 10 - }); - - console.log(`\n✅ Examples of Current Job Titles:`); - updatedJobs.forEach((job, index) => { - console.log(` ${index + 1}. "${job.title}"`); - console.log(` Description: ${job.description.substring(0, 60)}...`); - }); - - return { - total: jobs.length, - updated: updatedCount, - skipped: skippedCount, - errors: errorCount - }; - - } catch (error) { - console.error('❌ Error updating jobs with AI titles:', error); - throw error; - } finally { - await prisma.$disconnect(); - } + console.log('🤖 Updating Jobs with AI-Generated Titles\n'); + + try { + // Get all jobs that need title updates + const jobs = await prisma.job.findMany({ + include: { + city: true, + category: true, + }, + }); + + console.log(`📊 Found ${jobs.length} jobs to process\n`); + + let updatedCount = 0; + let skippedCount = 0; + let errorCount = 0; + + for (const job of jobs) { + try { + console.log(`\n🔍 Processing job ${job.id}: "${job.title}"`); + console.log(` Description: ${job.description.substring(0, 80)}...`); + + const context = { + city: job.city?.name, + salary: job.salary, + requirements: AIJobTitleService.extractRequirements(job.description), + }; + + const titleData = await AIJobTitleService.generateAITitle( + job.description, + context, + ); + + console.log( + ` Generated: "${titleData.title}" (${titleData.method}, confidence: ${titleData.confidence.toFixed(2)})`, + ); + + // Only update if the new title is better + if (titleData.confidence > 0.6 && titleData.title !== job.title) { + await prisma.job.update({ + where: { id: job.id }, + data: { title: titleData.title }, + }); + + console.log(` ✅ Updated: "${job.title}" → "${titleData.title}"`); + updatedCount++; + } else { + console.log(` ⏭️ Skipped - confidence too low or same title`); + skippedCount++; + } + + // Add delay to avoid overwhelming the system + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + console.error(` ❌ Error processing job ${job.id}:`, error.message); + errorCount++; + } + } + + console.log(`\n📈 AI Update Summary:`); + console.log(` Total jobs processed: ${jobs.length}`); + console.log(` Jobs updated: ${updatedCount}`); + console.log(` Jobs skipped: ${skippedCount}`); + console.log(` Errors: ${errorCount}`); + + // Show some examples of updated titles + const updatedJobs = await prisma.job.findMany({ + orderBy: { + createdAt: 'desc', + }, + take: 10, + }); + + console.log(`\n✅ Examples of Current Job Titles:`); + updatedJobs.forEach((job, index) => { + console.log(` ${index + 1}. "${job.title}"`); + console.log(` Description: ${job.description.substring(0, 60)}...`); + }); + + return { + total: jobs.length, + updated: updatedCount, + skipped: skippedCount, + errors: errorCount, + }; + } catch (error) { + console.error('❌ Error updating jobs with AI titles:', error); + throw error; + } finally { + await prisma.$disconnect(); + } } // Run the update -updateJobsWithAITitles().catch(console.error); \ No newline at end of file +updateJobsWithAITitles().catch(console.error); diff --git a/apps/api/utils/upload.js b/apps/api/utils/upload.js index 2f8d2e9..06ccd12 100644 --- a/apps/api/utils/upload.js +++ b/apps/api/utils/upload.js @@ -7,33 +7,33 @@ const __dirname = path.dirname(__filename); // Configure storage const storage = multer.diskStorage({ - destination: function (req, file, cb) { - cb(null, path.join(__dirname, '../../../public/images/jobs/')); - }, - filename: function (req, file, cb) { - // Generate unique filename with timestamp - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); - cb(null, 'job-' + uniqueSuffix + path.extname(file.originalname)); - } + destination: function (req, file, cb) { + cb(null, path.join(__dirname, '../../../public/images/jobs/')); + }, + filename: function (req, file, cb) { + // Generate unique filename with timestamp + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + cb(null, 'job-' + uniqueSuffix + path.extname(file.originalname)); + }, }); // File filter const fileFilter = (req, file, cb) => { - // Accept only image files - if (file.mimetype.startsWith('image/')) { - cb(null, true); - } else { - cb(new Error('Only image files are allowed!'), false); - } + // Accept only image files + if (file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('Only image files are allowed!'), false); + } }; // Configure multer const upload = multer({ - storage: storage, - fileFilter: fileFilter, - limits: { - fileSize: 5 * 1024 * 1024 // 5MB limit - } + storage: storage, + fileFilter: fileFilter, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit + }, }); -export default upload; \ No newline at end of file +export default upload; diff --git a/apps/client/src/App.jsx b/apps/client/src/App.jsx index 1e868a3..18536df 100644 --- a/apps/client/src/App.jsx +++ b/apps/client/src/App.jsx @@ -1,188 +1,232 @@ -import { useMemo, Suspense, useEffect } from "react"; -import { Toaster } from "react-hot-toast"; -import { ClerkProvider } from "@clerk/clerk-react"; -import { baseTheme } from "@clerk/themes"; -import { ruRU, enUS, heIL, arSA, ukUA } from "@clerk/localizations"; -import { RouterProvider, Outlet, createBrowserRouter } from "react-router-dom"; -import { HelmetProvider, Helmet } from "react-helmet-async"; // 🔹 SEO -import { ImageUploadProvider } from "./contexts/ImageUploadContext.jsx"; -import { LoadingProvider } from "./contexts/LoadingContext.jsx"; -import { IntlayerProvider, useIntlayer, useLocale } from "react-intlayer"; -import { useI18nHTMLAttributes } from "./hooks/useI18nHTMLAttributes"; -import ProgressBar from "./components/ui/ProgressBar.jsx"; -import Home from "./pages/Home.jsx"; -import MyAds from "./pages/MyAds.jsx"; -import CreateNewAd from "./pages/CreateNewAd.jsx"; -import NotFoundPage from "./pages/NotFoundPage.jsx"; -import AccessDenied from "./pages/AccessDenied.jsx"; -import { EditJobForm } from "./components/form/EditJobForm.jsx"; -import UserProfile from "./components/UserProfile.jsx"; -import SupportPage from "./components/SupportPage.jsx"; -import SurveyWidget from "./components/SurveyWidget.jsx"; -import ProtectedRoute from "./components/routes/ProtectedRoute.jsx"; -import Success from "./pages/Success.jsx"; -import Cancel from "./pages/Cancel.jsx"; -import Seekers from "./pages/Seekers.jsx"; -import SeekerDetails from "./pages/SeekerDetails.jsx"; -import PremiumPage from "./components/PremiumPage.jsx"; -import { Navbar } from "./components/Navbar.jsx"; -import { Footer } from "./components/Footer.jsx"; -import "./css/ripple.css"; -import CancelSubscription from "./components/CancelSubscription.jsx"; -import BillingPage from "./components/BillingPage.jsx"; -import NewsletterSubscription from "./pages/NewsletterSubscription.jsx"; +import { ClerkProvider } from '@clerk/clerk-react'; +import { arSA, enUS, heIL, ruRU, ukUA } from '@clerk/localizations'; +import { baseTheme } from '@clerk/themes'; +import { Suspense, useEffect, useMemo } from 'react'; +import { Helmet, HelmetProvider } from 'react-helmet-async'; // 🔹 SEO +import { Toaster } from 'react-hot-toast'; +import { IntlayerProvider, useIntlayer, useLocale } from 'react-intlayer'; +import { createBrowserRouter, Outlet, RouterProvider } from 'react-router-dom'; +import { Footer } from './components/Footer.jsx'; +import { EditJobForm } from './components/form/EditJobForm.jsx'; +import { Navbar } from './components/Navbar.jsx'; +import PremiumPage from './components/PremiumPage.jsx'; +import ProtectedRoute from './components/routes/ProtectedRoute.jsx'; +import SupportPage from './components/SupportPage.jsx'; +import SurveyWidget from './components/SurveyWidget.jsx'; +import UserProfile from './components/UserProfile.jsx'; +import ProgressBar from './components/ui/ProgressBar.jsx'; +import { ImageUploadProvider } from './contexts/ImageUploadContext.jsx'; +import { LoadingProvider } from './contexts/LoadingContext.jsx'; +import { useI18nHTMLAttributes } from './hooks/useI18nHTMLAttributes'; +import AccessDenied from './pages/AccessDenied.jsx'; +import Cancel from './pages/Cancel.jsx'; +import CreateNewAd from './pages/CreateNewAd.jsx'; +import Home from './pages/Home.jsx'; +import MyAds from './pages/MyAds.jsx'; +import NotFoundPage from './pages/NotFoundPage.jsx'; +import SeekerDetails from './pages/SeekerDetails.jsx'; +import Seekers from './pages/Seekers.jsx'; +import Success from './pages/Success.jsx'; +import './css/ripple.css'; +import BillingPage from './components/BillingPage.jsx'; +import CancelSubscription from './components/CancelSubscription.jsx'; +import NewsletterSubscription from './pages/NewsletterSubscription.jsx'; // Google Analytics Configuration const GA_TRACKING_ID = import.meta.env.VITE_GA_TRACKING_ID || 'G-XXXXXXXXXX'; // Initialize Google Analytics const initGA = () => { - if (typeof window !== 'undefined' && window.gtag) { - window.gtag('config', GA_TRACKING_ID, { - page_title: document.title, - page_location: window.location.href, - custom_map: { - dimension1: 'user_type', - dimension2: 'subscription_status' - } - }); - } + if (typeof window !== 'undefined' && window.gtag) { + window.gtag('config', GA_TRACKING_ID, { + page_title: document.title, + page_location: window.location.href, + custom_map: { + dimension1: 'user_type', + dimension2: 'subscription_status', + }, + }); + } }; // Track page views const trackPageView = (url) => { - if (typeof window !== 'undefined' && window.gtag) { - window.gtag('config', GA_TRACKING_ID, { - page_path: url, - page_title: document.title - }); - } + if (typeof window !== 'undefined' && window.gtag) { + window.gtag('config', GA_TRACKING_ID, { + page_path: url, + page_title: document.title, + }); + } }; function Layout() { - // Track page views when route changes - useEffect(() => { - trackPageView(window.location.pathname); - }, []); - - return ( -
- -
- -
-
-
- ); + // Track page views when route changes + useEffect(() => { + trackPageView(window.location.pathname); + }, []); + + return ( +
+ +
+ +
+
+
+ ); } const router = createBrowserRouter([ - { - path: "/", - element: , // Корневой layout с Navbar/Footer - errorElement: , - children: [ - { index: true, element: }, - { path: "my-advertisements", element: }, - { path: "create-new-advertisement", element: }, - { path: "access-denied", element: }, - { path: "edit-job/:id", element: }, - { path: "profile/:clerkUserId", element: }, - { path: "support", element: }, - { path: "survey", element: }, - { path: "success", element: }, - { path: "cancel", element: }, - { path: "seekers", element: }, - { path: "seekers/:id", element: }, - { path: "premium", element: }, - { path: "cancel-subscription", element: }, - { path: "billing", element: }, - { path: "newsletter", element: }, - { path: "*", element: }, - ] - } + { + path: '/', + element: , // Корневой layout с Navbar/Footer + errorElement: , + children: [ + { index: true, element: }, + { path: 'my-advertisements', element: }, + { + path: 'create-new-advertisement', + element: ( + + + + ), + }, + { path: 'access-denied', element: }, + { + path: 'edit-job/:id', + element: ( + + + + ), + }, + { path: 'profile/:clerkUserId', element: }, + { path: 'support', element: }, + { path: 'survey', element: }, + { path: 'success', element: }, + { path: 'cancel', element: }, + { path: 'seekers', element: }, + { path: 'seekers/:id', element: }, + { path: 'premium', element: }, + { path: 'cancel-subscription', element: }, + { path: 'billing', element: }, + { path: 'newsletter', element: }, + { path: '*', element: }, + ], + }, ]); const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; if (!PUBLISHABLE_KEY || !PUBLISHABLE_KEY.startsWith('pk_')) { - // eslint-disable-next-line no-console - console.error('❌ Ошибка: Некорректный или отсутствует VITE_CLERK_PUBLISHABLE_KEY! Проверьте .env и перезапустите dev-сервер.'); + // eslint-disable-next-line no-console + console.error( + '❌ Ошибка: Некорректный или отсутствует VITE_CLERK_PUBLISHABLE_KEY! Проверьте .env и перезапустите dev-сервер.', + ); } // Clerk localization mapping const clerkLocalizationMap = { - 'ru': ruRU, - 'en': enUS, - 'he': heIL, - 'ar': arSA, - 'uk': ukUA, + ru: ruRU, + en: enUS, + he: heIL, + ar: arSA, + uk: ukUA, }; const AppContent = () => { - const { locale } = useLocale(); - - // Apply the hook to update the tag's lang and dir attributes based on the locale. - useI18nHTMLAttributes(); - - // Get the appropriate Clerk localization based on current locale - const clerkLocalization = clerkLocalizationMap[locale] || ruRU; // Default to Russian - - // Initialize Google Analytics on app load - useEffect(() => { - // Load Google Analytics script - const script = document.createElement('script'); - script.async = true; - script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_TRACKING_ID}`; - document.head.appendChild(script); - - // Initialize gtag - window.dataLayer = window.dataLayer || []; - function gtag(){window.dataLayer.push(arguments);} - window.gtag = gtag; - gtag('js', new Date()); - gtag('config', GA_TRACKING_ID); - - // Track initial page view - initGA(); - - return () => { - // Cleanup if needed - if (script.parentNode) { - script.parentNode.removeChild(script); - } - }; - }, []); - - if (!PUBLISHABLE_KEY || !PUBLISHABLE_KEY.startsWith('pk_')) { - return
❌ Ошибка: Некорректный или отсутствует VITE_CLERK_PUBLISHABLE_KEY!
Проверьте .env и перезапустите dev-сервер.
Текущий ключ: {String(PUBLISHABLE_KEY)}
; - } - - return ( - - - - - {/* 🔹 Глобальная SEO-оптимизация */} - - WorkNow – Работа в Израиле | Поиск вакансий - - - - - - - - - {/* Google Analytics */} - - + - - {/* 🔹 Schema.org (WebSite + Organization) */} - - - - - -
-
-
-
- }> - -
- -
-
-
-
- ); + + + {/* 🔹 Schema.org (WebSite + Organization) */} + + + + + +
+
+
+
+ + } + > + +
+ + + + + + ); }; const App = () => { - return ( - - - - ); + return ( + + + + ); }; export default App; diff --git a/apps/client/src/components/BillingPage.jsx b/apps/client/src/components/BillingPage.jsx index 128d750..4924d91 100644 --- a/apps/client/src/components/BillingPage.jsx +++ b/apps/client/src/components/BillingPage.jsx @@ -1,150 +1,176 @@ -import { useUser } from "@clerk/clerk-react"; -import { useClerk } from "@clerk/clerk-react"; -import axios from "axios"; -import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; -import { useIntlayer } from "react-intlayer"; +import { useClerk, useUser } from '@clerk/clerk-react'; +import axios from 'axios'; +import { useEffect, useState } from 'react'; +import { useIntlayer } from 'react-intlayer'; +import { Link } from 'react-router-dom'; const API_URL = import.meta.env.VITE_API_URL; const PAGE_SIZE = 10; const BillingPage = () => { - const content = useIntlayer("billingPage"); - const { user } = useUser(); - const { redirectToSignIn } = useClerk(); - const [history, setHistory] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [pageStack, setPageStack] = useState([]); // для keyset пагинации (массив id) - const [currentPage, setCurrentPage] = useState(0); - const [hasNext, setHasNext] = useState(false); - const [hasPrev, setHasPrev] = useState(false); + const content = useIntlayer('billingPage'); + const { user } = useUser(); + const { redirectToSignIn } = useClerk(); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [pageStack, setPageStack] = useState([]); // для keyset пагинации (массив id) + const [currentPage, setCurrentPage] = useState(0); + const [hasNext, setHasNext] = useState(false); + const [hasPrev, setHasPrev] = useState(false); - const fetchHistory = async (startingAfter = null, page = 0) => { - if (!user) return; - setLoading(true); - setError(""); - try { - let url = `${API_URL}/api/payments/stripe-history?clerkUserId=${user.id}&limit=${PAGE_SIZE}`; - if (startingAfter) url += `&starting_after=${startingAfter}`; - const res = await axios.get(url); - setHistory(res.data.payments || []); - setHasNext((res.data.payments || []).length === PAGE_SIZE); - setHasPrev(page > 0); - } catch { - setError(content.error_loading_history); - setHistory([]); - setHasNext(false); - setHasPrev(page > 0); - } finally { - setLoading(false); - } - }; + const fetchHistory = async (startingAfter = null, page = 0) => { + if (!user) return; + setLoading(true); + setError(''); + try { + let url = `${API_URL}/api/payments/stripe-history?clerkUserId=${user.id}&limit=${PAGE_SIZE}`; + if (startingAfter) url += `&starting_after=${startingAfter}`; + const res = await axios.get(url); + setHistory(res.data.payments || []); + setHasNext((res.data.payments || []).length === PAGE_SIZE); + setHasPrev(page > 0); + } catch { + setError(content.error_loading_history); + setHistory([]); + setHasNext(false); + setHasPrev(page > 0); + } finally { + setLoading(false); + } + }; - useEffect(() => { - setPageStack([]); - setCurrentPage(0); - fetchHistory(null, 0); - // eslint-disable-next-line - }, [user]); + useEffect(() => { + setPageStack([]); + setCurrentPage(0); + fetchHistory(null, 0); + // eslint-disable-next-line + }, [user]); - const handleNext = () => { - if (history.length === 0) return; - const lastId = history[history.length - 1].id; - setPageStack(prev => [...prev, lastId]); - setCurrentPage(prev => prev + 1); - fetchHistory(lastId, currentPage + 1); - }; + const handleNext = () => { + if (history.length === 0) return; + const lastId = history[history.length - 1].id; + setPageStack((prev) => [...prev, lastId]); + setCurrentPage((prev) => prev + 1); + fetchHistory(lastId, currentPage + 1); + }; - const handlePrev = () => { - if (pageStack.length === 0) return; - const newStack = [...pageStack]; - newStack.pop(); - setPageStack(newStack); - setCurrentPage(prev => prev - 1); - fetchHistory(newStack[newStack.length - 1] || null, currentPage - 1); - }; + const handlePrev = () => { + if (pageStack.length === 0) return; + const newStack = [...pageStack]; + newStack.pop(); + setPageStack(newStack); + setCurrentPage((prev) => prev - 1); + fetchHistory(newStack[newStack.length - 1] || null, currentPage - 1); + }; - return ( -
-

{content.billing_title}

-
- - {content.cancel_subscription} - -
-
{content.payment_history}
- {!user ? ( -
- {content.no_transaction_history}{' '} - -
- ) : loading ? ( -
{content.loading}
- ) : error ? ( -
{error}
- ) : history.length === 0 ? ( -
{content.no_payments}
- ) : ( - <> -
- - - - - - - - - - - {history.map((item) => { - let periodStr = '-'; - if (item.period) { - let periodDate = item.period; - if (typeof periodDate === 'string' || typeof periodDate === 'number') { - periodDate = new Date(periodDate); - } - if (periodDate instanceof Date && !isNaN(periodDate)) { - periodStr = periodDate.toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' }); - } - } - return ( - - - - - - - ); - })} - -
{content.month}{content.amount}{content.subscription_type}{content.date}
{periodStr}{item.amount ? `${item.amount}₪` : '-'}{item.type || '-'}{item.date ? new Date(item.date).toLocaleDateString() : '-'}
-
- - - )} -
- ); + return ( +
+

{content.billing_title}

+
+ + {content.cancel_subscription} + +
+
{content.payment_history}
+ {!user ? ( +
+ {content.no_transaction_history}{' '} + +
+ ) : loading ? ( +
{content.loading}
+ ) : error ? ( +
{error}
+ ) : history.length === 0 ? ( +
{content.no_payments}
+ ) : ( + <> +
+ + + + + + + + + + + {history.map((item) => { + let periodStr = '-'; + if (item.period) { + let periodDate = item.period; + if ( + typeof periodDate === 'string' || + typeof periodDate === 'number' + ) { + periodDate = new Date(periodDate); + } + if (periodDate instanceof Date && !isNaN(periodDate)) { + periodStr = periodDate.toLocaleDateString('ru-RU', { + month: 'long', + year: 'numeric', + }); + } + } + return ( + + + + + + + ); + })} + +
{content.month}{content.amount}{content.subscription_type}{content.date}
{periodStr} + {item.amount ? `${item.amount}₪` : '-'} + {item.type || '-'} + {item.date + ? new Date(item.date).toLocaleDateString() + : '-'} +
+
+ + + )} +
+ ); }; -export default BillingPage; \ No newline at end of file +export default BillingPage; diff --git a/apps/client/src/components/CancelSubscription.jsx b/apps/client/src/components/CancelSubscription.jsx index 82a58a1..7ffa367 100644 --- a/apps/client/src/components/CancelSubscription.jsx +++ b/apps/client/src/components/CancelSubscription.jsx @@ -1,122 +1,145 @@ /* eslint-disable no-unused-vars */ -import { useUser } from "@clerk/clerk-react"; -import { useClerk } from "@clerk/clerk-react"; -import axios from "axios"; -import { useState, useEffect } from "react"; -import { useIntlayer } from "react-intlayer"; +import { useClerk, useUser } from '@clerk/clerk-react'; +import axios from 'axios'; +import { useEffect, useState } from 'react'; +import { useIntlayer } from 'react-intlayer'; const API_URL = import.meta.env.VITE_API_URL; const CancelSubscription = () => { - const content = useIntlayer("cancelSubscription"); - const { user } = useUser(); - const { redirectToSignIn } = useClerk(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [isPremium, setIsPremium] = useState(null); - const [isAutoRenewal, setIsAutoRenewal] = useState(null); - const [renewLoading, setRenewLoading] = useState(false); - const [renewError, setRenewError] = useState(""); + const content = useIntlayer('cancelSubscription'); + const { user } = useUser(); + const { redirectToSignIn } = useClerk(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [isPremium, setIsPremium] = useState(null); + const [isAutoRenewal, setIsAutoRenewal] = useState(null); + const [renewLoading, setRenewLoading] = useState(false); + const [renewError, setRenewError] = useState(''); - // Получаем статус премиума и автопродления с сервера - const fetchUserStatus = async () => { - if (!user) return; - try { - const res = await axios.get(`${API_URL}/api/users/${user.id}`); - setIsPremium(res.data.isPremium); - setIsAutoRenewal(res.data.isAutoRenewal); - } catch { - setIsPremium(false); - setIsAutoRenewal(false); - } - }; + // Получаем статус премиума и автопродления с сервера + const fetchUserStatus = async () => { + if (!user) return; + try { + const res = await axios.get(`${API_URL}/api/users/${user.id}`); + setIsPremium(res.data.isPremium); + setIsAutoRenewal(res.data.isAutoRenewal); + } catch { + setIsPremium(false); + setIsAutoRenewal(false); + } + }; - useEffect(() => { - fetchUserStatus(); - // eslint-disable-next-line - }, [user]); + useEffect(() => { + fetchUserStatus(); + // eslint-disable-next-line + }, [user]); - const handleCancel = async () => { - if (!user || !isPremium || !isAutoRenewal) return; - setLoading(true); - setError(""); - try { - await axios.post(`${API_URL}/api/payments/cancel-auto-renewal`, { - clerkUserId: user.id, - }); - await fetchUserStatus(); - } catch (e) { - setError(content.error_cancel_subscription); - } finally { - setLoading(false); - } - }; + const handleCancel = async () => { + if (!user || !isPremium || !isAutoRenewal) return; + setLoading(true); + setError(''); + try { + await axios.post(`${API_URL}/api/payments/cancel-auto-renewal`, { + clerkUserId: user.id, + }); + await fetchUserStatus(); + } catch (e) { + setError(content.error_cancel_subscription); + } finally { + setLoading(false); + } + }; - const handleRenew = async () => { - if (!user) return; - setRenewLoading(true); - setRenewError(""); - try { - await axios.post(`${API_URL}/api/payments/renew-auto-renewal`, { - clerkUserId: user.id, - }); - await fetchUserStatus(); - } catch (e) { - setRenewError(content.error_renew_subscription); - } finally { - setRenewLoading(false); - } - }; + const handleRenew = async () => { + if (!user) return; + setRenewLoading(true); + setRenewError(''); + try { + await axios.post(`${API_URL}/api/payments/renew-auto-renewal`, { + clerkUserId: user.id, + }); + await fetchUserStatus(); + } catch (e) { + setRenewError(content.error_renew_subscription); + } finally { + setRenewLoading(false); + } + }; - return ( -
-

{content.cancel_subscription_title}

- {!user ? ( -
- {content.no_active_subscription}{' '} - -
- ) : ( - <> - {isPremium && isAutoRenewal === false && ( -
{content.auto_renewal_disabled}
- )} - {isPremium && isAutoRenewal === false && ( - - )} - {renewError &&
{renewError}
} - {isPremium && isAutoRenewal && ( - <> -

{content.confirm_cancel_auto_renewal}

- - {error &&
{error}
} - - )} - {!isPremium && isPremium !== null && ( -
{content.no_premium_subscription}
- )} - - )} -
- ); + return ( +
+

{content.cancel_subscription_title}

+ {!user ? ( +
+ {content.no_active_subscription}{' '} + +
+ ) : ( + <> + {isPremium && isAutoRenewal === false && ( +
+ {content.auto_renewal_disabled} +
+ )} + {isPremium && isAutoRenewal === false && ( + + )} + {renewError && ( +
+ {renewError} +
+ )} + {isPremium && isAutoRenewal && ( + <> +

+ {content.confirm_cancel_auto_renewal} +

+ + {error && ( +
+ {error} +
+ )} + + )} + {!isPremium && isPremium !== null && ( +
+ {content.no_premium_subscription} +
+ )} + + )} +
+ ); }; -export default CancelSubscription; \ No newline at end of file +export default CancelSubscription; diff --git a/apps/client/src/components/Footer.jsx b/apps/client/src/components/Footer.jsx index 44417d8..3d90b9e 100755 --- a/apps/client/src/components/Footer.jsx +++ b/apps/client/src/components/Footer.jsx @@ -1,35 +1,35 @@ -import "bootstrap/dist/css/bootstrap.min.css"; -import { Telegram, Facebook } from 'react-bootstrap-icons'; +import 'bootstrap/dist/css/bootstrap.min.css'; +import { Facebook, Telegram } from 'react-bootstrap-icons'; const Footer = () => { - return ( - - ); + return ( + + ); }; export { Footer }; diff --git a/apps/client/src/components/JobCard.jsx b/apps/client/src/components/JobCard.jsx index 9b31849..54a70c4 100755 --- a/apps/client/src/components/JobCard.jsx +++ b/apps/client/src/components/JobCard.jsx @@ -1,182 +1,201 @@ -import PropTypes from "prop-types"; -import { useNavigate } from "react-router-dom"; -import { useIntlayer } from "react-intlayer"; -import { useState } from "react"; -import useFetchCities from '../hooks/useFetchCities'; -import useFetchCategories from '../hooks/useFetchCategories'; +import PropTypes from 'prop-types'; +import { useState } from 'react'; +import { useIntlayer } from 'react-intlayer'; import Skeleton from 'react-loading-skeleton'; +import { useNavigate } from 'react-router-dom'; +import useFetchCategories from '../hooks/useFetchCategories'; +import useFetchCities from '../hooks/useFetchCities'; import 'react-loading-skeleton/dist/skeleton.css'; import { ImageModal } from './ui'; const JobCard = ({ job }) => { - const content = useIntlayer("jobCard"); - const navigate = useNavigate(); - const { cities, loading: citiesLoading } = useFetchCities(); - const { categories, loading: categoriesLoading } = useFetchCategories(); - const [showImageModal, setShowImageModal] = useState(false); - const [imageLoading, setImageLoading] = useState(true); + const content = useIntlayer('jobCard'); + const navigate = useNavigate(); + const { cities, loading: citiesLoading } = useFetchCities(); + const { categories, loading: categoriesLoading } = useFetchCategories(); + const [showImageModal, setShowImageModal] = useState(false); + const [imageLoading, setImageLoading] = useState(true); + + // Получаем название города на нужном языке + let cityLabel = null; + if (job.cityId && Array.isArray(cities)) { + const city = cities.find( + (c) => c.value === job.cityId || c.id === job.cityId, + ); + cityLabel = city?.label || city?.name || null; + } - // Получаем название города на нужном языке - let cityLabel = null; - if (job.cityId && Array.isArray(cities)) { - const city = cities.find(c => c.value === job.cityId || c.id === job.cityId); - cityLabel = city?.label || city?.name || null; - } + // Получаем название категории на нужном языке + let categoryLabel = null; + if (job.categoryId && Array.isArray(categories)) { + const category = categories.find( + (c) => c.value === job.categoryId || c.id === job.categoryId, + ); + categoryLabel = category?.label || category?.name || null; + } - // Получаем название категории на нужном языке - let categoryLabel = null; - if (job.categoryId && Array.isArray(categories)) { - const category = categories.find(c => c.value === job.categoryId || c.id === job.categoryId); - categoryLabel = category?.label || category?.name || null; - } + const handleImageClick = (e) => { + e.stopPropagation(); // Prevent card click when clicking image + setShowImageModal(true); + }; - const handleImageClick = (e) => { - e.stopPropagation(); // Prevent card click when clicking image - setShowImageModal(true); - }; + const handleCloseModal = () => { + setShowImageModal(false); + }; - const handleCloseModal = () => { - setShowImageModal(false); - }; + const handleImageLoad = () => { + setImageLoading(false); + }; - const handleImageLoad = () => { - setImageLoading(false); - }; + const handleImageError = () => { + setImageLoading(false); + }; - const handleImageError = () => { - setImageLoading(false); - }; + return ( + <> +
{ + if (job.user?.clerkUserId) { + navigate(`/profile/${job.user.clerkUserId}`); + } + }} + > + {/* Плашка Премиум */} + {job.user?.isPremium && ( +
+ {content.premiumBadge.value} +
+ )} +
+
{job.title}
+ {(job.categoryId || job.category?.label) && ( +
+ {categoriesLoading ? ( + + ) : ( + + {categoryLabel || + job.category?.label || + content.notSpecified.value} + + )} +
+ )} +

+ {content.salaryPerHourCard.value}{' '} + {job.salary || content.notSpecified.value} +
+ {content.locationCard.value}{' '} + {citiesLoading ? ( + + ) : ( + cityLabel || content.notSpecified.value + )} +

+

+ {job.description || content.descriptionMissing.value} +

+
+ {typeof job.shuttle === 'boolean' && ( +
+ {content.shuttle.value}:{' '} + {job.shuttle ? content.yes.value : content.no.value} +
+ )} + {typeof job.meals === 'boolean' && ( +
+ {content.meals.value}:{' '} + {job.meals ? content.yes.value : content.no.value} +
+ )} + {content.phoneNumberCard.value}{' '} + {job.phone || content.phoneNotSpecified.value} +
- return ( - <> -
{ - if (job.user?.clerkUserId) { - navigate(`/profile/${job.user.clerkUserId}`); - } - }} - > - {/* Плашка Премиум */} - {job.user?.isPremium && ( -
- {content.premiumBadge.value} -
- )} -
-
{job.title}
- {(job.categoryId || job.category?.label) && ( -
- {categoriesLoading ? ( - - ) : ( - - {categoryLabel || job.category?.label || content.notSpecified.value} - - )} -
- )} -

- {content.salaryPerHourCard.value}{" "} - {job.salary || content.notSpecified.value} -
- {content.locationCard.value}{" "} - {citiesLoading ? ( - - ) : ( - cityLabel || content.notSpecified.value - )} -

-

{job.description || content.descriptionMissing.value}

-
- {typeof job.shuttle === 'boolean' && ( -
- {content.shuttle.value}: {job.shuttle ? content.yes.value : content.no.value} -
- )} - {typeof job.meals === 'boolean' && ( -
- {content.meals.value}: {job.meals ? content.yes.value : content.no.value} -
- )} - {content.phoneNumberCard.value} {job.phone || content.phoneNotSpecified.value} -
- - {/* Image displayed under phone number in mini size */} - {job.imageUrl && ( -
- {imageLoading && ( - - )} - {job.title} -
- )} -
-
+ {/* Image displayed under phone number in mini size */} + {job.imageUrl && ( +
+ {imageLoading && ( + + )} + {job.title} +
+ )} +
+
- {/* Image Modal - Only render if there's an image URL */} - {job.imageUrl && ( - {}} - /> - )} - - ); + {/* Image Modal - Only render if there's an image URL */} + {job.imageUrl && ( + {}} + /> + )} + + ); }; // **Валидация пропсов** JobCard.propTypes = { - job: PropTypes.shape({ - id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - cityId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - city: PropTypes.object, - title: PropTypes.string, - salary: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - description: PropTypes.string, - phone: PropTypes.string, - category: PropTypes.object, - user: PropTypes.object, - shuttle: PropTypes.bool, - meals: PropTypes.bool, - imageUrl: PropTypes.string, - }).isRequired, + job: PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + cityId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + city: PropTypes.object, + title: PropTypes.string, + salary: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + description: PropTypes.string, + phone: PropTypes.string, + category: PropTypes.object, + user: PropTypes.object, + shuttle: PropTypes.bool, + meals: PropTypes.bool, + imageUrl: PropTypes.string, + }).isRequired, }; export default JobCard; diff --git a/apps/client/src/components/JobList.jsx b/apps/client/src/components/JobList.jsx index 2d009a5..058a098 100755 --- a/apps/client/src/components/JobList.jsx +++ b/apps/client/src/components/JobList.jsx @@ -1,65 +1,74 @@ +import { useUser } from '@clerk/clerk-react'; import PropTypes from 'prop-types'; -import JobCard from './JobCard'; import Skeleton from 'react-loading-skeleton'; -import { useUser } from '@clerk/clerk-react'; +import JobCard from './JobCard'; const JobList = ({ jobs, loading }) => { - const { user: clerkUser, isLoaded } = useUser(); + const { user: clerkUser, isLoaded } = useUser(); - if (loading) { - return ( - <> - {Array.from({ length: 5 }).map((_, index) => ( -
-
- - - -
-
- ))} - - ); - } + if (loading) { + return ( + <> + {Array.from({ length: 5 }).map((_, index) => ( +
+
+ + + +
+
+ ))} + + ); + } - if (!jobs || jobs.length === 0) { - return

Объявлений не найдено

; - } + if (!jobs || jobs.length === 0) { + return

Объявлений не найдено

; + } - return jobs.map((job) => { - const isOwnJob = isLoaded && clerkUser && job.user?.clerkUserId === clerkUser.id; - return ( - - ); - }); + return jobs.map((job) => { + const isOwnJob = + isLoaded && clerkUser && job.user?.clerkUserId === clerkUser.id; + return ( + + ); + }); }; // **Валидация пропсов** JobList.propTypes = { - jobs: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.number.isRequired, - title: PropTypes.string.isRequired, - salary: PropTypes.string, - city: PropTypes.shape({ - name: PropTypes.string, - }), - description: PropTypes.string, - phone: PropTypes.string, - createdAt: PropTypes.string.isRequired, - user: PropTypes.shape({ - clerkUserId: PropTypes.string, - imageUrl: PropTypes.string, - isPremium: PropTypes.bool, - }), - }) - ).isRequired, - loading: PropTypes.bool.isRequired, + jobs: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + salary: PropTypes.string, + city: PropTypes.shape({ + name: PropTypes.string, + }), + description: PropTypes.string, + phone: PropTypes.string, + createdAt: PropTypes.string.isRequired, + user: PropTypes.shape({ + clerkUserId: PropTypes.string, + imageUrl: PropTypes.string, + isPremium: PropTypes.bool, + }), + }), + ).isRequired, + loading: PropTypes.bool.isRequired, }; export default JobList; diff --git a/apps/client/src/components/JobListing.jsx b/apps/client/src/components/JobListing.jsx index c4ec486..c14de94 100755 --- a/apps/client/src/components/JobListing.jsx +++ b/apps/client/src/components/JobListing.jsx @@ -1,149 +1,168 @@ import { useState } from 'react'; +import { Helmet } from 'react-helmet-async'; +import { useIntlayer } from 'react-intlayer'; import useJobs from '../hooks/useJobs'; +import useFilterStore from '../store/filterStore'; import JobList from './JobList'; import PaginationControl from './PaginationControl'; import CityDropdown from './ui/city-dropwdown'; -import { useIntlayer } from "react-intlayer"; -import { Helmet } from 'react-helmet-async'; import JobFilterModal from './ui/JobFilterModal'; -import useFilterStore from '../store/filterStore'; const JobListing = () => { - const content = useIntlayer("jobListing"); - const commonContent = useIntlayer("common"); - const { filters, setFilters } = useFilterStore(); - - // Use Intlayer content instead of i18next - const defaultCity = content.chooseCityDashboard.value; - const defaultTitle = content.latestJobs.value; - - const [currentPage, setCurrentPage] = useState(1); - const [selectedCity, setSelectedCity] = useState({ value: null, label: defaultCity }); - const [filterOpen, setFilterOpen] = useState(false); - - // Combine filters with city selection - const combinedFilters = { - ...filters, - city: selectedCity.value - }; - - const { jobs, loading, pagination } = useJobs(currentPage, combinedFilters); - - // Use server-side pagination if available, otherwise fall back to client-side - const totalPages = pagination ? pagination.pages : Math.ceil(jobs.length / 10); - const currentJobs = jobs; // Jobs are already paginated from server - - // Handle page change - const handlePageChange = (newPage) => { - setCurrentPage(newPage); - // Scroll to top when page changes - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; - - // Генерация SEO-friendly заголовка - const pageTitle = selectedCity.value - ? `${content.jobsIn.value.replace('{{city}}', selectedCity.label)} - WorkNow` - : `${defaultTitle} | WorkNow`; - - // Генерация динамического описания страницы - const pageDescription = selectedCity.value - ? `${content.findJobsIn.value.replace('{{city}}', selectedCity.label)}. ${content.newVacanciesFromEmployers.value}.` - : `${content.jobSearchPlatform.value} - ${content.findLatestJobs.value}.`; - - // Формирование динамического URL для SEO - const pageUrl = selectedCity.value - ? `https://worknow.co.il/jobs/${selectedCity.label.toLowerCase()}` - : `https://worknow.co.il/jobs`; - - return ( -
- {/* SEO-мета-теги */} - - {pageTitle} - - - - - - - - - {/* Schema.org разметка (JobPosting) */} - - - - {/* Фильтр по городам */} -
-
- -
- -
-
-
- setFilterOpen(false)} - onApply={setFilters} - currentFilters={filters} - /> - - {/* Список вакансий */} - - - {/* Пагинация */} - {jobs.length > 0 && ( - - )} -
- ); + const content = useIntlayer('jobListing'); + const commonContent = useIntlayer('common'); + const { filters, setFilters } = useFilterStore(); + + // Use Intlayer content instead of i18next + const defaultCity = content.chooseCityDashboard.value; + const defaultTitle = content.latestJobs.value; + + const [currentPage, setCurrentPage] = useState(1); + const [selectedCity, setSelectedCity] = useState({ + value: null, + label: defaultCity, + }); + const [filterOpen, setFilterOpen] = useState(false); + + // Combine filters with city selection + const combinedFilters = { + ...filters, + city: selectedCity.value, + }; + + const { jobs, loading, pagination } = useJobs(currentPage, combinedFilters); + + // Use server-side pagination if available, otherwise fall back to client-side + const totalPages = pagination + ? pagination.pages + : Math.ceil(jobs.length / 10); + const currentJobs = jobs; // Jobs are already paginated from server + + // Handle page change + const handlePageChange = (newPage) => { + setCurrentPage(newPage); + // Scroll to top when page changes + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + // Генерация SEO-friendly заголовка + const pageTitle = selectedCity.value + ? `${content.jobsIn.value.replace('{{city}}', selectedCity.label)} - WorkNow` + : `${defaultTitle} | WorkNow`; + + // Генерация динамического описания страницы + const pageDescription = selectedCity.value + ? `${content.findJobsIn.value.replace('{{city}}', selectedCity.label)}. ${content.newVacanciesFromEmployers.value}.` + : `${content.jobSearchPlatform.value} - ${content.findLatestJobs.value}.`; + + // Формирование динамического URL для SEO + const pageUrl = selectedCity.value + ? `https://worknow.co.il/jobs/${selectedCity.label.toLowerCase()}` + : `https://worknow.co.il/jobs`; + + return ( +
+ {/* SEO-мета-теги */} + + {pageTitle} + + + + + + + + + {/* Schema.org разметка (JobPosting) */} + + + + {/* Фильтр по городам */} +
+
+ +
+ +
+
+
+ setFilterOpen(false)} + onApply={setFilters} + currentFilters={filters} + /> + + {/* Список вакансий */} + + + {/* Пагинация */} + {jobs.length > 0 && ( + + )} +
+ ); }; export { JobListing }; diff --git a/apps/client/src/components/Navbar.jsx b/apps/client/src/components/Navbar.jsx index f16c382..fcc668f 100755 --- a/apps/client/src/components/Navbar.jsx +++ b/apps/client/src/components/Navbar.jsx @@ -1,342 +1,394 @@ -import { Link, useLocation } from "react-router-dom"; -import { useState, useEffect } from "react"; -import { useIntlayer, useLocale } from "react-intlayer"; -import { useLanguageManager } from "../hooks/useLanguageManager"; -import PremiumButton from "./ui/premium-button"; import { - SignedIn, - SignedOut, - SignInButton, - UserButton, -} from "@clerk/clerk-react"; -import "bootstrap/dist/js/bootstrap.bundle.min.js"; -import MailDropdown from "./ui/MailDropdown"; + SignedIn, + SignedOut, + SignInButton, + UserButton, +} from '@clerk/clerk-react'; +import { useEffect, useState } from 'react'; +import { useIntlayer, useLocale } from 'react-intlayer'; +import { Link, useLocation } from 'react-router-dom'; +import { useLanguageManager } from '../hooks/useLanguageManager'; +import PremiumButton from './ui/premium-button'; +import 'bootstrap/dist/js/bootstrap.bundle.min.js'; +import MailDropdown from './ui/MailDropdown'; -const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY +const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID; const Navbar = () => { - const content = useIntlayer("navbar"); - const { locale } = useLocale(); - const { changeLanguage, isLoading, clearLanguagePreference } = useLanguageManager(); - const location = useLocation(); + const content = useIntlayer('navbar'); + const { locale } = useLocale(); + const { changeLanguage, isLoading, clearLanguagePreference } = + useLanguageManager(); + const location = useLocation(); - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); - // Close mobile navbar when route changes - useEffect(() => { - setIsExpanded(false); - }, [location.pathname]); + // Close mobile navbar when route changes + useEffect(() => { + setIsExpanded(false); + }, [location.pathname]); - // Expose functions to window for testing - useEffect(() => { - window.resetLanguageDetection = clearLanguagePreference; - window.testLanguageLoading = () => { - console.log('🧪 Language Loading Test'); - console.log('Current locale:', locale); - console.log('Is loading:', isLoading); - console.log('Available languages:', ['ru', 'en', 'he', 'ar', 'uk']); - console.log('Cookie value:', document.cookie); - console.log('LocalStorage value:', localStorage.getItem('worknow-language')); - }; - }, [clearLanguagePreference, locale, isLoading]); + // Expose functions to window for testing + useEffect(() => { + window.resetLanguageDetection = clearLanguagePreference; + window.testLanguageLoading = () => { + console.log('🧪 Language Loading Test'); + console.log('Current locale:', locale); + console.log('Is loading:', isLoading); + console.log('Available languages:', ['ru', 'en', 'he', 'ar', 'uk']); + console.log('Cookie value:', document.cookie); + console.log( + 'LocalStorage value:', + localStorage.getItem('worknow-language'), + ); + }; + }, [clearLanguagePreference, locale, isLoading]); - return ( - <> - {/* Desktop Version */} -
-
-
-
- - Logo -

worknow

- -
-
    -
  • - - {content.vacancies.value} - -
  • - / -
  • - - {content.seekers.value} - -
  • - / -
  • - - {content.jobs.value} - -
  • - {/* Dropdown Support */} - / -
  • - - -
  • -
-
+ return ( + <> + {/* Desktop Version */} +
+
+
+
+ + Logo +

worknow

+ +
+
    +
  • + + {content.vacancies.value} + +
  • + / +
  • + + {content.seekers.value} + +
  • + / +
  • + + {content.jobs.value} + +
  • + {/* Dropdown Support */} + / +
  • + + +
  • +
+
-
- - - -
- -
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
- -
- - - - - {content.signIn.value} - - - - - - -
-
-
+
+ + + +
+ +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+ +
+ + + + + {content.signIn.value} + + + + + + +
+
+
- {/* Mobile Version */} - + + ); }; export { Navbar }; diff --git a/apps/client/src/components/PaginationControl.jsx b/apps/client/src/components/PaginationControl.jsx index 1a27f42..eda0fbe 100755 --- a/apps/client/src/components/PaginationControl.jsx +++ b/apps/client/src/components/PaginationControl.jsx @@ -1,66 +1,66 @@ -import { Pagination } from 'react-bootstrap'; import PropTypes from 'prop-types'; +import { Pagination } from 'react-bootstrap'; const PaginationControl = ({ currentPage, totalPages, onPageChange }) => { - // Generate sliding page numbers (always show 5 pages) - const getPageNumbers = () => { - const pages = []; - const maxVisiblePages = 5; // Always show exactly 5 page numbers - - if (totalPages <= maxVisiblePages) { - // If total pages is small, show all pages - for (let i = 1; i <= totalPages; i++) { - pages.push(i); - } - } else { - // For many pages, show 5 pages around current page - let start = Math.max(1, currentPage - 2); - let end = Math.min(totalPages, start + maxVisiblePages - 1); - - // Adjust start if we're near the end - if (end === totalPages) { - start = Math.max(1, totalPages - maxVisiblePages + 1); - } - - // Show pages around current page - for (let i = start; i <= end; i++) { - pages.push(i); - } - } - - return pages; - }; + // Generate sliding page numbers (always show 5 pages) + const getPageNumbers = () => { + const pages = []; + const maxVisiblePages = 5; // Always show exactly 5 page numbers + + if (totalPages <= maxVisiblePages) { + // If total pages is small, show all pages + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + // For many pages, show 5 pages around current page + let start = Math.max(1, currentPage - 2); + let end = Math.min(totalPages, start + maxVisiblePages - 1); + + // Adjust start if we're near the end + if (end === totalPages) { + start = Math.max(1, totalPages - maxVisiblePages + 1); + } + + // Show pages around current page + for (let i = start; i <= end; i++) { + pages.push(i); + } + } + + return pages; + }; - return ( - - onPageChange(currentPage - 1)} - /> + return ( + + onPageChange(currentPage - 1)} + /> - {getPageNumbers().map((page) => ( - onPageChange(page)} - > - {page} - - ))} + {getPageNumbers().map((page) => ( + onPageChange(page)} + > + {page} + + ))} - onPageChange(currentPage + 1)} - /> - - ); + onPageChange(currentPage + 1)} + /> + + ); }; // PropTypes validation PaginationControl.propTypes = { - currentPage: PropTypes.number.isRequired, - totalPages: PropTypes.number.isRequired, - onPageChange: PropTypes.func.isRequired, + currentPage: PropTypes.number.isRequired, + totalPages: PropTypes.number.isRequired, + onPageChange: PropTypes.func.isRequired, }; export default PaginationControl; diff --git a/apps/client/src/components/PremiumPage.jsx b/apps/client/src/components/PremiumPage.jsx index a5f9339..d70ee80 100644 --- a/apps/client/src/components/PremiumPage.jsx +++ b/apps/client/src/components/PremiumPage.jsx @@ -1,497 +1,666 @@ -import { useState, useEffect } from "react"; -import { useUser, useClerk } from "@clerk/clerk-react"; -import axios from "axios"; -import { useUserSync } from "../hooks/useUserSync.js"; -import { useIntlayer } from "react-intlayer"; -import { useLoadingProgress } from '../hooks/useLoadingProgress'; +import { useClerk, useUser } from '@clerk/clerk-react'; +import axios from 'axios'; +import { useEffect, useState } from 'react'; +import { useIntlayer } from 'react-intlayer'; import { useGoogleAnalytics } from '../hooks/useGoogleAnalytics.js'; +import { useLoadingProgress } from '../hooks/useLoadingProgress'; +import { useUserSync } from '../hooks/useUserSync.js'; const API_URL = import.meta.env.VITE_API_URL; const PremiumPage = () => { - const { user } = useUser(); - const { redirectToSignIn } = useClerk(); - const [loading, setLoading] = useState(false); - const { dbUser, loading: userLoading, error: userError, refreshUser } = useUserSync(); - const { startLoadingWithProgress, completeLoading } = useLoadingProgress(); - const content = useIntlayer("premiumPage"); - const { trackPremiumSubscription, trackButtonClick, trackError } = useGoogleAnalytics(); - - // Text carousel state - const [currentTitleIndex, setCurrentTitleIndex] = useState(0); - const [isTransitioning, setIsTransitioning] = useState(false); - - // Different variations of the pricing title - const titleVariations = [ - content.pricingTitle.value, - content.pricingEffective.value, - content.pricingConvenient.value, - content.pricingTrust.value - ]; + const { user } = useUser(); + const { redirectToSignIn } = useClerk(); + const [loading, setLoading] = useState(false); + const { + dbUser, + loading: userLoading, + error: userError, + refreshUser, + } = useUserSync(); + const { startLoadingWithProgress, completeLoading } = useLoadingProgress(); + const content = useIntlayer('premiumPage'); + const { trackPremiumSubscription, trackButtonClick, trackError } = + useGoogleAnalytics(); + + // Text carousel state + const [currentTitleIndex, setCurrentTitleIndex] = useState(0); + const [isTransitioning, setIsTransitioning] = useState(false); + + // Different variations of the pricing title + const titleVariations = [ + content.pricingTitle.value, + content.pricingEffective.value, + content.pricingConvenient.value, + content.pricingTrust.value, + ]; + + // Text carousel effect - change title every 3 seconds + useEffect(() => { + const interval = setInterval(() => { + setIsTransitioning(true); + + // Wait for fade out, then change title + setTimeout(() => { + setCurrentTitleIndex((prevIndex) => + prevIndex === titleVariations.length - 1 ? 0 : prevIndex + 1, + ); + + // Wait a bit, then fade in + setTimeout(() => { + setIsTransitioning(false); + }, 100); + }, 300); + }, 3000); + + return () => clearInterval(interval); + }, [titleVariations.length]); + + // Create plans with translations + const getPlans = () => [ + { + name: 'Free', + price: 0, + period: '/mo', + features: [ + { + text: content.pricingFreeUpTo5Ads.value, + icon: '📝', + color: 'text-primary', + }, + { + text: content.pricingFreeDailyBoost.value, + icon: '📈', + color: 'text-success', + }, + { + text: content.pricingFreeBasicSupport.value, + icon: '💬', + color: 'text-info', + }, + ], + button: { + text: content.pricingFreeUseFree.value, + variant: 'outline-primary', + action: (navigate) => navigate('/create-new-advertisement'), + }, + }, + { + name: 'Pro', + price: 99, + period: '/mo', + features: [ + { + text: content.pricingProUpTo10Ads.value, + icon: '📝', + color: 'text-primary', + }, + { + text: content.pricingProUnlimitedSeekerData.value, + icon: '👥', + color: 'text-success', + }, + { + text: content.pricingProTopJobs.value, + icon: '⭐', + color: 'text-warning', + }, + { + text: content.pricingProColorHighlighting.value, + icon: '🎨', + color: 'text-info', + }, + { + text: content.pricingProAdvancedFilters.value, + icon: '🔍', + color: 'text-primary', + }, + { + text: content.pricingProPrioritySupport.value, + icon: '🚀', + color: 'text-danger', + }, + ], + button: { + text: content.pricingProBuyPremium.value, + variant: 'primary', + priceId: 'price_1Qt5J0COLiDbHvw1IQNl90uU', + }, + highlight: true, + }, + { + name: 'Deluxe', + price: 200, // Base price for new users + period: '/mo', + features: [ + { + text: content.pricingDeluxeAllFromPro.value, + icon: '✨', + color: 'text-warning', + }, + { + text: content.pricingDeluxePersonalManager.value, + icon: '👨‍💼', + color: 'text-primary', + }, + { + text: content.pricingDeluxeFacebookAutoposting.value, + icon: '⚡', + color: 'text-info', + }, + { + text: content.pricingDeluxeFacebookPromotion.value, + icon: '📢', + color: 'text-success', + }, + { + text: content.pricingDeluxePersonalMailing.value, + icon: '📧', + color: 'text-primary', + }, + { + text: content.pricingDeluxeTelegramPublications.value, + icon: '🔥', + color: 'text-info', + }, + { + text: content.pricingDeluxePersonalCandidateSelection.value, + icon: '🎯', + color: 'text-warning', + }, + { + text: content.pricingDeluxeInterviewOrganization.value, + icon: '🤝', + color: 'text-success', + }, + ], + button: { + text: content.pricingDeluxeBuyDeluxe.value, + variant: 'primary', + priceId: 'price_1RfHjiCOLiDbHvw1repgIbnK', // Price ID for 200 ILS + }, + bestResults: true, + }, + ]; + + // Auto-refresh user data when coming from success page + useEffect(() => { + const fromSuccess = window.location.search.includes('fromSuccess=true'); + if (fromSuccess && user && !userLoading) { + refreshUser(); + // Remove the parameter to prevent infinite refresh + window.history.replaceState({}, '', '/premium'); + } + }, [user, userLoading, refreshUser]); + + // Auto-refresh user data when coming from success page + useEffect(() => { + const fromSuccess = window.location.search.includes('fromSuccess=true'); + if (fromSuccess && user && !userLoading) { + refreshUser(); + // Remove the parameter to prevent infinite refresh + window.history.replaceState({}, '', '/premium'); + } + }, [user, userLoading, refreshUser]); + + const handlePay = async (priceId) => { + if (!user) { + redirectToSignIn(); + return; + } + + // Track premium subscription attempt + const planData = getPlans().find((plan) => plan.button.priceId === priceId); + if (planData) { + trackPremiumSubscription({ + name: planData.name, + price: planData.price, + }); + } + + // Track button click + trackButtonClick({ + button_name: 'premium_subscription', + button_location: 'premium_page', + button_action: 'initiate_payment', + }); + + setLoading(true); + startLoadingWithProgress(2000); // Start progress bar for payment process + + try { + const data = { clerkUserId: user.id }; + if (priceId) data.priceId = priceId; + + const response = await axios.post( + `${API_URL}/api/payments/create-checkout-session`, + data, + ); + completeLoading(); // Complete progress when checkout session is created + window.location.href = response.data.url; + } catch (error) { + completeLoading(); // Complete progress even on error + + // Track error + trackError({ + error_type: 'payment_error', + error_message: error.response?.data?.error || error.message, + error_location: 'premium_page_payment', + }); + + if (error.response?.status === 404) { + alert(content.userNotFound.value); + } else if (error.response?.data?.error) { + alert(`Ошибка оплаты: ${error.response.data.error}`); + } else { + alert(content.paymentError.value); + } + } finally { + setLoading(false); + } + }; - // Text carousel effect - change title every 3 seconds - useEffect(() => { - const interval = setInterval(() => { - setIsTransitioning(true); - - // Wait for fade out, then change title - setTimeout(() => { - setCurrentTitleIndex((prevIndex) => - prevIndex === titleVariations.length - 1 ? 0 : prevIndex + 1 - ); - - // Wait a bit, then fade in - setTimeout(() => { - setIsTransitioning(false); - }, 100); - }, 300); - }, 3000); + return ( +
+ {/* Enhanced Header Section */} +
+

+ {titleVariations[currentTitleIndex]} +

- return () => clearInterval(interval); - }, [titleVariations.length]); +
+

+ {content.pricingDescription.value} +

+
+
- // Create plans with translations - const getPlans = () => [ - { - name: "Free", - price: 0, - period: "/mo", - features: [ - { text: content.pricingFreeUpTo5Ads.value, icon: "📝", color: "text-primary" }, - { text: content.pricingFreeDailyBoost.value, icon: "📈", color: "text-success" }, - { text: content.pricingFreeBasicSupport.value, icon: "💬", color: "text-info" }, - ], - button: { - text: content.pricingFreeUseFree.value, - variant: "outline-primary", - action: (navigate) => navigate("/create-new-advertisement") - } - }, - { - name: "Pro", - price: 99, - period: "/mo", - features: [ - { text: content.pricingProUpTo10Ads.value, icon: "📝", color: "text-primary" }, - { text: content.pricingProUnlimitedSeekerData.value, icon: "👥", color: "text-success" }, - { text: content.pricingProTopJobs.value, icon: "⭐", color: "text-warning" }, - { text: content.pricingProColorHighlighting.value, icon: "🎨", color: "text-info" }, - { text: content.pricingProAdvancedFilters.value, icon: "🔍", color: "text-primary" }, - { text: content.pricingProPrioritySupport.value, icon: "🚀", color: "text-danger" }, - ], - button: { - text: content.pricingProBuyPremium.value, - variant: "primary", - priceId: "price_1Qt5J0COLiDbHvw1IQNl90uU" - }, - highlight: true - }, - { - name: "Deluxe", - price: 200, // Base price for new users - period: "/mo", - features: [ - { text: content.pricingDeluxeAllFromPro.value, icon: "✨", color: "text-warning" }, - { text: content.pricingDeluxePersonalManager.value, icon: "👨‍💼", color: "text-primary" }, - { text: content.pricingDeluxeFacebookAutoposting.value, icon: "⚡", color: "text-info" }, - { text: content.pricingDeluxeFacebookPromotion.value, icon: "📢", color: "text-success" }, - { text: content.pricingDeluxePersonalMailing.value, icon: "📧", color: "text-primary" }, - { text: content.pricingDeluxeTelegramPublications.value, icon: "🔥", color: "text-info" }, - { text: content.pricingDeluxePersonalCandidateSelection.value, icon: "🎯", color: "text-warning" }, - { text: content.pricingDeluxeInterviewOrganization.value, icon: "🤝", color: "text-success" }, - ], - button: { - text: content.pricingDeluxeBuyDeluxe.value, - variant: "primary", - priceId: "price_1RfHjiCOLiDbHvw1repgIbnK" // Price ID for 200 ILS - }, - bestResults: true - } - ]; + {/* Показываем спиннер только если user есть, но dbUser ещё не загружен */} + {user && userLoading ? ( +
+
+ {content.loading.value} +
+
+ ) : userError ? ( +
+
+ Ошибка загрузки данных пользователя: {userError} +
+
+ ) : ( +
+ {getPlans().map((plan) => { + let isActive = false; + let displayPrice = plan.price; + let buttonText = plan.button.text; + let priceId = plan.button.priceId; - // Auto-refresh user data when coming from success page - useEffect(() => { - const fromSuccess = window.location.search.includes('fromSuccess=true'); - if (fromSuccess && user && !userLoading) { - refreshUser(); - // Remove the parameter to prevent infinite refresh - window.history.replaceState({}, '', '/premium'); - } - }, [user, userLoading, refreshUser]); + // Determine plan status based on user subscription + if (plan.name === 'Free') { + // Free plan is always active for logged-in users + isActive = !!user; + } else if (plan.name === 'Pro') { + isActive = dbUser?.isPremium || dbUser?.premiumDeluxe; + } else if (plan.name === 'Deluxe') { + isActive = dbUser?.premiumDeluxe; + } - // Auto-refresh user data when coming from success page - useEffect(() => { - const fromSuccess = window.location.search.includes('fromSuccess=true'); - if (fromSuccess && user && !userLoading) { - refreshUser(); - // Remove the parameter to prevent infinite refresh - window.history.replaceState({}, '', '/premium'); - } - }, [user, userLoading, refreshUser]); + // Deluxe pricing logic + if (plan.name === 'Deluxe') { + if (user && dbUser?.isPremium && !dbUser?.premiumDeluxe) { + // User has Pro but not Deluxe - show upgrade price + displayPrice = 100; + buttonText = content.pricingDeluxeUpgradeToDeluxe.value; + priceId = 'price_1Rfli2COLiDbHvw1xdMaguLf'; // Price ID for 100 ILS upgrade + } else if (!user || !dbUser?.isPremium) { + // New user or no Pro subscription - show full price + displayPrice = 200; + buttonText = plan.button.text; + priceId = plan.button.priceId; + } else { + // User already has Deluxe or other cases - use default + priceId = plan.button.priceId; + } + // If user already has Deluxe, isActive will be true and button will show "Active" + } + return ( +
+
{ + e.currentTarget.style.transform = 'translateY(-8px)'; + e.currentTarget.style.boxShadow = + '0 12px 35px rgba(0,0,0,0.15)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = plan.highlight + ? '0 0.5rem 1rem rgba(0, 0, 0, 0.15)' + : '0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)'; + }} + > +
+ {/* Enhanced Badge Section */} + {plan.highlight && ( +
+ + ⭐ {content.pricingRecommended.value} + +
+ )} + {plan.bestResults && ( +
+ + 🏆 {content.pricingBestResults.value} + +
+ )} - const handlePay = async (priceId) => { - if (!user) { - redirectToSignIn(); - return; - } - - // Track premium subscription attempt - const planData = getPlans().find(plan => plan.button.priceId === priceId); - if (planData) { - trackPremiumSubscription({ - name: planData.name, - price: planData.price - }); - } - - // Track button click - trackButtonClick({ - button_name: 'premium_subscription', - button_location: 'premium_page', - button_action: 'initiate_payment' - }); - - setLoading(true); - startLoadingWithProgress(2000); // Start progress bar for payment process - - try { - const data = { clerkUserId: user.id }; - if (priceId) data.priceId = priceId; - - const response = await axios.post( - `${API_URL}/api/payments/create-checkout-session`, - data - ); - completeLoading(); // Complete progress when checkout session is created - window.location.href = response.data.url; - } catch (error) { - completeLoading(); // Complete progress even on error - - // Track error - trackError({ - error_type: 'payment_error', - error_message: error.response?.data?.error || error.message, - error_location: 'premium_page_payment' - }); - - if (error.response?.status === 404) { - alert(content.userNotFound.value); - } else if (error.response?.data?.error) { - alert(`Ошибка оплаты: ${error.response.data.error}`); - } else { - alert(content.paymentError.value); - } - } finally { - setLoading(false); - } - }; + {/* Enhanced Plan Title */} +
+ {plan.name === 'Free' + ? content.pricingFreeTitle.value + : plan.name} +
- return ( -
- {/* Enhanced Header Section */} -
-

- {titleVariations[currentTitleIndex]} -

- -
-

- {content.pricingDescription.value} -

-
-
+ {/* Enhanced Price Section */} +
+

+ {displayPrice === 0 ? '0' : `${displayPrice}₪`} + + {plan.period} + +

- {/* Показываем спиннер только если user есть, но dbUser ещё не загружен */} - {user && userLoading ? ( -
-
- {content.loading.value} -
-
- ) : userError ? ( -
-
- Ошибка загрузки данных пользователя: {userError} -
-
- ) : ( -
- {getPlans().map((plan) => { - let isActive = false; - let displayPrice = plan.price; - let buttonText = plan.button.text; - let priceId = plan.button.priceId; + {plan.name === 'Deluxe' && + user && + dbUser?.isPremium && + !dbUser?.premiumDeluxe && ( +
+ + 200₪ + + + -50% + +
+ )} +
- // Determine plan status based on user subscription - if (plan.name === "Free") { - // Free plan is always active for logged-in users - isActive = !!user; - } else if (plan.name === "Pro") { - isActive = dbUser?.isPremium || dbUser?.premiumDeluxe; - } else if (plan.name === "Deluxe") { - isActive = dbUser?.premiumDeluxe; - } + {/* Enhanced Features List - Flexbox for consistent height */} +
+
    + {plan.features.map((feature, index) => ( +
  • +
    + + {feature.icon} + +
    + + {feature.text} + +
  • + ))} +
+
- // Deluxe pricing logic - if (plan.name === "Deluxe") { - if (user && dbUser?.isPremium && !dbUser?.premiumDeluxe) { - // User has Pro but not Deluxe - show upgrade price - displayPrice = 100; - buttonText = content.pricingDeluxeUpgradeToDeluxe.value; - priceId = "price_1Rfli2COLiDbHvw1xdMaguLf"; // Price ID for 100 ILS upgrade - } else if (!user || !dbUser?.isPremium) { - // New user or no Pro subscription - show full price - displayPrice = 200; - buttonText = plan.button.text; - priceId = plan.button.priceId; - } else { - // User already has Deluxe or other cases - use default - priceId = plan.button.priceId; - } - // If user already has Deluxe, isActive will be true and button will show "Active" - } - return ( -
-
{ - e.currentTarget.style.transform = 'translateY(-8px)'; - e.currentTarget.style.boxShadow = '0 12px 35px rgba(0,0,0,0.15)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = 'translateY(0)'; - e.currentTarget.style.boxShadow = plan.highlight ? '0 0.5rem 1rem rgba(0, 0, 0, 0.15)' : '0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)'; - }}> -
- {/* Enhanced Badge Section */} - {plan.highlight && ( -
- - ⭐ {content.pricingRecommended.value} - -
- )} - {plan.bestResults && ( -
- - 🏆 {content.pricingBestResults.value} - -
- )} - - {/* Enhanced Plan Title */} -
- {plan.name === "Free" ? content.pricingFreeTitle.value : plan.name} -
- - {/* Enhanced Price Section */} -
-

- {displayPrice === 0 ? "0" : `${displayPrice}₪`} - - {plan.period} - -

- - {plan.name === "Deluxe" && user && dbUser?.isPremium && !dbUser?.premiumDeluxe && ( -
- - 200₪ - - - -50% - -
- )} -
- - {/* Enhanced Features List - Flexbox for consistent height */} -
-
    - {plan.features.map((feature, index) => ( -
  • -
    - {feature.icon} -
    - - {feature.text} - -
  • - ))} -
-
- - {/* Enhanced Button Section */} -
- {plan.price === 0 ? ( - !user ? ( - - ) : ( - - ) - ) : ( - - )} -
-
-
-
- ); - })} -
- )} -
- ); + {/* Enhanced Button Section */} +
+ {plan.price === 0 ? ( + !user ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+
+
+ ); + })} +
+ )} +
+ ); }; export default PremiumPage; diff --git a/apps/client/src/components/SupportPage.jsx b/apps/client/src/components/SupportPage.jsx index a7fb9f2..a984f7a 100755 --- a/apps/client/src/components/SupportPage.jsx +++ b/apps/client/src/components/SupportPage.jsx @@ -1,391 +1,419 @@ -import { useIntlayer } from "react-intlayer"; -import { Mail, Phone, MessageCircle, Clock, HelpCircle, Users, Shield, Zap } from "lucide-react"; -import { useState, useEffect, useRef } from "react"; +import { + Clock, + HelpCircle, + Mail, + MessageCircle, + Phone, + Shield, + Users, + Zap, +} from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { useIntlayer } from 'react-intlayer'; const SupportPage = () => { - const content = useIntlayer("supportPage"); - const [isModalOpen, setIsModalOpen] = useState(false); - const [selectedMethod, setSelectedMethod] = useState(null); - const [touchStart, setTouchStart] = useState(null); - const [touchEnd, setTouchEnd] = useState(null); - const modalRef = useRef(); + const content = useIntlayer('supportPage'); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedMethod, setSelectedMethod] = useState(null); + const [touchStart, setTouchStart] = useState(null); + const [touchEnd, setTouchEnd] = useState(null); + const modalRef = useRef(); - // Determine if mobile - const isMobile = window.innerWidth <= 768; - const minSwipeDistance = 50; + // Determine if mobile + const isMobile = window.innerWidth <= 768; + const minSwipeDistance = 50; - const supportMethods = [ - { - icon: Mail, - title: content.support_email_title, - description: content.support_email_description, - contact: "worknow.notifications@gmail.com", - action: "mailto:worknow.notifications@gmail.com", - color: "bg-blue-500", - hoverColor: "hover:bg-blue-600" - }, - { - icon: MessageCircle, - title: content.support_live_chat_title, - description: content.support_live_chat_description, - contact: content.support_available_24_7, - action: "#", - color: "bg-green-500", - hoverColor: "hover:bg-green-600", - disabled: true - }, - { - icon: Phone, - title: content.support_phone_title, - description: content.support_phone_description, - contact: "+972-053-3033332", - action: "tel:+972-053-3033332", - color: "bg-purple-500", - hoverColor: "hover:bg-purple-600" - } - ]; + const supportMethods = [ + { + icon: Mail, + title: content.support_email_title, + description: content.support_email_description, + contact: 'worknow.notifications@gmail.com', + action: 'mailto:worknow.notifications@gmail.com', + color: 'bg-blue-500', + hoverColor: 'hover:bg-blue-600', + }, + { + icon: MessageCircle, + title: content.support_live_chat_title, + description: content.support_live_chat_description, + contact: content.support_available_24_7, + action: '#', + color: 'bg-green-500', + hoverColor: 'hover:bg-green-600', + disabled: true, + }, + { + icon: Phone, + title: content.support_phone_title, + description: content.support_phone_description, + contact: '+972-053-3033332', + action: 'tel:+972-053-3033332', + color: 'bg-purple-500', + hoverColor: 'hover:bg-purple-600', + }, + ]; - const faqItems = [ - { - question: content.support_faq_create_job_question, - answer: content.support_faq_create_job_answer - }, - { - question: content.support_faq_premium_question, - answer: content.support_faq_premium_answer - }, - { - question: content.support_faq_edit_job_question, - answer: content.support_faq_edit_job_answer - }, - { - question: content.support_faq_contact_seekers_question, - answer: content.support_faq_contact_seekers_answer - }, - { - question: content.support_faq_job_limits_question, - answer: content.support_faq_job_limits_answer - }, - { - question: content.support_faq_payment_question, - answer: content.support_faq_payment_answer - }, - { - question: content.support_faq_categories_question, - answer: content.support_faq_categories_answer - }, - { - question: content.support_faq_cities_question, - answer: content.support_faq_cities_answer - } - ]; + const faqItems = [ + { + question: content.support_faq_create_job_question, + answer: content.support_faq_create_job_answer, + }, + { + question: content.support_faq_premium_question, + answer: content.support_faq_premium_answer, + }, + { + question: content.support_faq_edit_job_question, + answer: content.support_faq_edit_job_answer, + }, + { + question: content.support_faq_contact_seekers_question, + answer: content.support_faq_contact_seekers_answer, + }, + { + question: content.support_faq_job_limits_question, + answer: content.support_faq_job_limits_answer, + }, + { + question: content.support_faq_payment_question, + answer: content.support_faq_payment_answer, + }, + { + question: content.support_faq_categories_question, + answer: content.support_faq_categories_answer, + }, + { + question: content.support_faq_cities_question, + answer: content.support_faq_cities_answer, + }, + ]; - const features = [ - { - icon: Shield, - title: content.support_secure_platform_title, - description: content.support_secure_platform_description - }, - { - icon: Zap, - title: content.support_fast_reliable_title, - description: content.support_fast_reliable_description - }, - { - icon: Users, - title: content.support_community_driven_title, - description: content.support_community_driven_description - } - ]; + const features = [ + { + icon: Shield, + title: content.support_secure_platform_title, + description: content.support_secure_platform_description, + }, + { + icon: Zap, + title: content.support_fast_reliable_title, + description: content.support_fast_reliable_description, + }, + { + icon: Users, + title: content.support_community_driven_title, + description: content.support_community_driven_description, + }, + ]; - // Touch handlers for mobile modal - const onTouchStart = (e) => { - setTouchEnd(null); - setTouchStart(e.targetTouches[0].clientY); - }; - - const onTouchMove = (e) => { - setTouchEnd(e.targetTouches[0].clientY); - }; - - const onTouchEnd = () => { - if (!touchStart || !touchEnd) return; - const distance = touchStart - touchEnd; - const isUpSwipe = distance > minSwipeDistance; - - if (isUpSwipe) { - closeModal(); - } - }; + // Touch handlers for mobile modal + const onTouchStart = (e) => { + setTouchEnd(null); + setTouchStart(e.targetTouches[0].clientY); + }; - const openModal = (method) => { - setSelectedMethod(method); - setIsModalOpen(true); - document.body.style.overflow = 'hidden'; - if (isMobile) { - document.body.style.position = 'fixed'; - document.body.style.width = '100%'; - } - }; + const onTouchMove = (e) => { + setTouchEnd(e.targetTouches[0].clientY); + }; - const closeModal = () => { - setIsModalOpen(false); - setSelectedMethod(null); - document.body.style.overflow = ''; - if (isMobile) { - document.body.style.position = ''; - document.body.style.width = ''; - } - }; + const onTouchEnd = () => { + if (!touchStart || !touchEnd) return; + const distance = touchStart - touchEnd; + const isUpSwipe = distance > minSwipeDistance; - const handleContactClick = (method) => { - if (isMobile) { - openModal(method); - } else { - // Desktop behavior - direct action - if (!method.disabled) { - window.open(method.action, '_blank'); - } - } - }; + if (isUpSwipe) { + closeModal(); + } + }; - useEffect(() => { - return () => { - document.body.style.overflow = ''; - if (isMobile) { - document.body.style.position = ''; - document.body.style.width = ''; - } - }; - }, [isMobile]); + const openModal = (method) => { + setSelectedMethod(method); + setIsModalOpen(true); + document.body.style.overflow = 'hidden'; + if (isMobile) { + document.body.style.position = 'fixed'; + document.body.style.width = '100%'; + } + }; - const modalStyle = { - position: 'fixed', - top: 0, - left: 0, - width: '100vw', - height: '100vh', - background: 'rgba(0,0,0,0.3)', - zIndex: 1000, - display: 'flex', - alignItems: 'center', - justifyContent: 'center' - }; + const closeModal = () => { + setIsModalOpen(false); + setSelectedMethod(null); + document.body.style.overflow = ''; + if (isMobile) { + document.body.style.position = ''; + document.body.style.width = ''; + } + }; - const contentStyle = { - background: '#fff', - borderRadius: 0, - height: '100vh', - width: '100vw', - padding: '16px 16px', - display: 'flex', - flexDirection: 'column', - boxShadow: 'none', - border: 'none', - position: 'absolute', - top: 0, - left: 0 - }; + const handleContactClick = (method) => { + if (isMobile) { + openModal(method); + } else { + // Desktop behavior - direct action + if (!method.disabled) { + window.open(method.action, '_blank'); + } + } + }; - return ( -
- {/* Hero Section */} -
-
-
-
- -
-

- {content.technical_support} -

-

- {content.support_hero_description} -

-
-
-
+ useEffect(() => { + return () => { + document.body.style.overflow = ''; + if (isMobile) { + document.body.style.position = ''; + document.body.style.width = ''; + } + }; + }, [isMobile]); - {/* Support Methods */} -
-
-

- {content.support_get_in_touch} -

-

- {content.support_get_in_touch_description} -

-
+ const modalStyle = { + position: 'fixed', + top: 0, + left: 0, + width: '100vw', + height: '100vh', + background: 'rgba(0,0,0,0.3)', + zIndex: 1000, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }; -
- {supportMethods.map((method, index) => ( -
-
-
- -
-

- {method.title} -

-

- {method.description} -

-
-

- {method.contact} -

- {!method.disabled ? ( - - ) : ( - - {content.support_coming_soon} - - )} -
-
-
- ))} -
+ const contentStyle = { + background: '#fff', + borderRadius: 0, + height: '100vh', + width: '100vw', + padding: '16px 16px', + display: 'flex', + flexDirection: 'column', + boxShadow: 'none', + border: 'none', + position: 'absolute', + top: 0, + left: 0, + }; - {/* Features Section */} -
-
-

- {content.support_why_choose_title} -

-

- {content.support_why_choose_description} -

-
- -
- {features.map((feature, index) => ( -
-
- -
-

- {feature.title} -

-

- {feature.description} -

-
- ))} -
-
+ return ( +
+ {/* Hero Section */} +
+
+
+
+ +
+

+ {content.technical_support} +

+

+ {content.support_hero_description} +

+
+
+
- {/* FAQ Section */} -
-
-

- {content.support_faq_title} -

-

- {content.support_faq_description} -

-
- -
- {faqItems.map((item, index) => ( -
-

- {item.question} -

-

- {item.answer} -

-
- ))} -
-
+ {/* Support Methods */} +
+
+

+ {content.support_get_in_touch} +

+

+ {content.support_get_in_touch_description} +

+
- {/* Contact Info */} -
-
-
- - {content.support_hours_title} -
-

- {content.support_hours_weekdays} -

-

- {content.support_hours_weekend} -

-
-
-
+
+ {supportMethods.map((method, index) => ( +
+
+
+ +
+

+ {method.title} +

+

{method.description}

+
+

{method.contact}

+ {!method.disabled ? ( + + ) : ( + + {content.support_coming_soon} + + )} +
+
+
+ ))} +
- {/* Mobile Contact Modal */} - {isModalOpen && selectedMethod && ( -
-
-
-
{content.support_modal_title}
- -
- -
-
- -
- -

- {selectedMethod.title} -

- -

- {selectedMethod.description} -

- -
-

{content.support_modal_contact_info}

-

- {selectedMethod.contact} -

-
- - {!selectedMethod.disabled ? ( - - {content.support_contact_now} - - ) : ( - - {content.support_coming_soon} - - )} -
-
-
- )} -
- ); + {/* Features Section */} +
+
+

+ {content.support_why_choose_title} +

+

+ {content.support_why_choose_description} +

+
+ +
+ {features.map((feature, index) => ( +
+
+ +
+

+ {feature.title} +

+

{feature.description}

+
+ ))} +
+
+ + {/* FAQ Section */} +
+
+

+ {content.support_faq_title} +

+

+ {content.support_faq_description} +

+
+ +
+ {faqItems.map((item, index) => ( +
+

+ {item.question} +

+

{item.answer}

+
+ ))} +
+
+ + {/* Contact Info */} +
+
+
+ + + {content.support_hours_title} + +
+

+ {content.support_hours_weekdays} +

+

{content.support_hours_weekend}

+
+
+
+ + {/* Mobile Contact Modal */} + {isModalOpen && selectedMethod && ( +
+
+
+
+ {content.support_modal_title} +
+ +
+ +
+
+ +
+ +

+ {selectedMethod.title} +

+ +

+ {selectedMethod.description} +

+ +
+

+ {content.support_modal_contact_info} +

+

+ {selectedMethod.contact} +

+
+ + {!selectedMethod.disabled ? ( + + {content.support_contact_now} + + ) : ( + + {content.support_coming_soon} + + )} +
+
+
+ )} +
+ ); }; export default SupportPage; diff --git a/apps/client/src/components/SurveyWidget.jsx b/apps/client/src/components/SurveyWidget.jsx index 1208323..0228561 100755 --- a/apps/client/src/components/SurveyWidget.jsx +++ b/apps/client/src/components/SurveyWidget.jsx @@ -1,34 +1,34 @@ -import { useEffect } from "react"; +import { useEffect } from 'react'; const SurveyWidget = () => { - useEffect(() => { - const scriptId = "smcx-sdk"; + useEffect(() => { + const scriptId = 'smcx-sdk'; - // Проверяем, не загружен ли уже скрипт - if (!document.getElementById(scriptId)) { - const script = document.createElement("script"); - script.type = "text/javascript"; - script.async = true; - script.id = scriptId; - script.src = - "https://widget.surveymonkey.com/collect/website/js/tRaiETqnLgj758hTBazgdyU2pqpYZCI3tEdSFY4EGCkJzpvUWeYwN5mmOILR_2BapO.js"; - document.body.appendChild(script); - } - }, []); + // Проверяем, не загружен ли уже скрипт + if (!document.getElementById(scriptId)) { + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.async = true; + script.id = scriptId; + script.src = + 'https://widget.surveymonkey.com/collect/website/js/tRaiETqnLgj758hTBazgdyU2pqpYZCI3tEdSFY4EGCkJzpvUWeYwN5mmOILR_2BapO.js'; + document.body.appendChild(script); + } + }, []); - return ( -
-
-

- Участвуйте в нашем опросе! -

-

- Ваше мнение важно для нас. Пройдите короткий опрос. -

-
-
-
- ); + return ( +
+
+

+ Участвуйте в нашем опросе! +

+

+ Ваше мнение важно для нас. Пройдите короткий опрос. +

+
+
+
+ ); }; export default SurveyWidget; diff --git a/apps/client/src/components/UserHeader.jsx b/apps/client/src/components/UserHeader.jsx index 03ee6f4..b03c5b3 100644 --- a/apps/client/src/components/UserHeader.jsx +++ b/apps/client/src/components/UserHeader.jsx @@ -1,31 +1,34 @@ /* eslint-disable no-unused-vars */ + +import PropTypes from 'prop-types'; import React from 'react'; -import PropTypes from 'prop-types' - const UserHeader = ({ user }) => ( -
- User Avatar -
-

{user.firstName ? `${user.firstName} ${user.lastName || ""}` : "Анонимный пользователь"}

-

{user.email || "Email не указан"}

-
-
+const UserHeader = ({ user }) => ( +
+ User Avatar +
+

+ {user.firstName + ? `${user.firstName} ${user.lastName || ''}` + : 'Анонимный пользователь'} +

+

{user.email || 'Email не указан'}

+
+
); - - UserHeader.propTypes = { - user: PropTypes.shape({ - imageUrl: PropTypes.string, - firstName: PropTypes.string, - lastName: PropTypes.string, - email: PropTypes.string, - }).isRequired, + user: PropTypes.shape({ + imageUrl: PropTypes.string, + firstName: PropTypes.string, + lastName: PropTypes.string, + email: PropTypes.string, + }).isRequired, }; -export default UserHeader; \ No newline at end of file +export default UserHeader; diff --git a/apps/client/src/components/UserJobs.jsx b/apps/client/src/components/UserJobs.jsx index eb9f07c..ab96188 100755 --- a/apps/client/src/components/UserJobs.jsx +++ b/apps/client/src/components/UserJobs.jsx @@ -1,539 +1,610 @@ -import { useEffect, useState } from "react"; -import axios from "axios"; -import { useUser, useAuth } from "@clerk/clerk-react"; -import { Modal, Button } from "react-bootstrap"; -import { toast } from "react-hot-toast"; -import { useNavigate } from "react-router-dom"; -import { Trash, PencilSquare, SortUp } from "react-bootstrap-icons"; -import Skeleton from "react-loading-skeleton"; -import { useIntlayer, useLocale } from "react-intlayer"; -import { format } from "date-fns"; -import { ru, enUS, he, ar } from "date-fns/locale"; -import "react-loading-skeleton/dist/skeleton.css"; +import { useAuth, useUser } from '@clerk/clerk-react'; +import axios from 'axios'; +import { format } from 'date-fns'; +import { ar, enUS, he, ru } from 'date-fns/locale'; +import { useEffect, useState } from 'react'; +import { Button, Modal } from 'react-bootstrap'; +import { PencilSquare, SortUp, Trash } from 'react-bootstrap-icons'; +import { toast } from 'react-hot-toast'; +import { useIntlayer, useLocale } from 'react-intlayer'; +import Skeleton from 'react-loading-skeleton'; +import { useNavigate } from 'react-router-dom'; +import 'react-loading-skeleton/dist/skeleton.css'; import { useLoadingProgress } from '../hooks/useLoadingProgress'; import { useTranslationHelpers } from '../utils/translationHelpers'; -import { ImageModal } from './ui'; import PaginationControl from './PaginationControl'; +import { ImageModal } from './ui'; const API_URL = import.meta.env.VITE_API_URL; // Берем API из .env const UserJobs = () => { - const content = useIntlayer("userJobs"); - const { locale } = useLocale(); - const { user } = useUser(); - const { getToken } = useAuth(); - const navigate = useNavigate(); - const { startLoadingWithProgress, completeLoading } = useLoadingProgress(); - const { getCityLabel } = useTranslationHelpers(); - - const [jobs, setJobs] = useState([]); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [loading, setLoading] = useState(true); - - const [showModal, setShowModal] = useState(false); - const [jobToDelete, setJobToDelete] = useState(null); - const [showImageModal, setShowImageModal] = useState(false); - const [selectedImageUrl, setSelectedImageUrl] = useState(""); - const [selectedImageTitle, setSelectedImageTitle] = useState(""); - const [imageLoadingStates, setImageLoadingStates] = useState({}); - const [failedImages, setFailedImages] = useState(new Set()); - - // Helper function to get the appropriate date-fns locale - const getDateLocale = () => { - switch (locale) { - case 'en': - return enUS; - case 'he': - return he; - case 'ar': - return ar; - default: - return ru; - } - }; - - // Helper function to format date based on language - const formatDate = (date) => { - const locale = getDateLocale(); - return format(new Date(date), "dd MMMM yyyy", { locale }); - }; - - // Helper function to check if a job is already boosted - const isJobBoosted = (job) => { - if (!job.boostedAt) return false; - - const now = new Date(); - const boostedAt = new Date(job.boostedAt); - const timeSinceBoost = now - boostedAt; - const ONE_DAY = 24 * 60 * 60 * 1000; - - return timeSinceBoost < ONE_DAY; - }; - - // Helper function to get remaining time until next boost - const getTimeUntilNextBoost = (job) => { - if (!job.boostedAt) return null; - - const now = new Date(); - const boostedAt = new Date(job.boostedAt); - const timeSinceBoost = now - boostedAt; - const ONE_DAY = 24 * 60 * 60 * 1000; - - if (timeSinceBoost >= ONE_DAY) return null; - - const timeLeft = ONE_DAY - timeSinceBoost; - const hoursLeft = Math.floor(timeLeft / (1000 * 60 * 60)); - const minutesLeft = Math.floor((timeLeft % (1000 * 60 * 60)) / (1000 * 60)); - - return { hours: hoursLeft, minutes: minutesLeft }; - }; - - const fetchUserJobs = async () => { - if (!user) return; - - setLoading(true); - startLoadingWithProgress(1500); // Start loading progress - - try { - const response = await axios.get( - `${API_URL}/api/users/user-jobs/${user.id}?page=${currentPage}&limit=5&lang=${locale}` - ); - - // Jobs data received from server - setJobs(response.data.jobs); - setTotalPages(response.data.totalPages); - - // Initialize loading states for all images - const initialLoadingStates = {}; - response.data.jobs.forEach(job => { - if (job.imageUrl) { - initialLoadingStates[job.id] = true; - } - }); - // Initializing loading states - setImageLoadingStates(initialLoadingStates); - setFailedImages(new Set()); // Reset failed images for new jobs - completeLoading(); // Complete loading when done - } catch (error) { - console.error( - "❌ Ошибка загрузки объявлений пользователя:", - error.response?.data || error.message - ); - toast.error(content.loadJobsError.value); - completeLoading(); // Complete loading even on error - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchUserJobs(); - }, [user, currentPage, locale]); // Loading functions are stable now - - const handleDelete = async () => { - if (!jobToDelete) return; - - startLoadingWithProgress(2000); // Start loading progress for deletion - - try { - const token = await getToken(); - await axios.delete(`${API_URL}/api/jobs/${jobToDelete}`, { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - completeLoading(); // Complete loading when done - toast.success(content.jobDeletedSuccess.value); - setJobs((prevJobs) => prevJobs.filter((job) => job.id !== jobToDelete)); - } catch (error) { - console.error("Ошибка удаления объявления:", error); - completeLoading(); // Complete loading even on error - toast.error(content.jobDeletedError.value); - } finally { - setShowModal(false); - setJobToDelete(null); - } - }; - - const openDeleteModal = (jobId) => { - setJobToDelete(jobId); - setShowModal(true); - }; - - const handleEdit = (jobId) => { - navigate(`/edit-job/${jobId}`); - }; - - const handleBoost = async (jobId) => { - startLoadingWithProgress(1500); // Start loading progress for boost - - try { - const token = await getToken(); - await axios.post(`${API_URL}/api/jobs/${jobId}/boost`, {}, { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - completeLoading(); // Complete loading when done - toast.success(content.jobBoostedSuccess.value); - fetchUserJobs(); - } catch (error) { - completeLoading(); // Complete loading even on error - toast.error(error.response?.data?.error || content.jobBoostedError.value); - } - }; - - const handlePageChange = (pageNumber) => { - setCurrentPage(pageNumber); - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; - - const handleImageClick = (e, imageUrl, title) => { - e.stopPropagation(); // Prevent card click when clicking image - setSelectedImageUrl(imageUrl); - setSelectedImageTitle(title); - setShowImageModal(true); - }; - - const handleCloseImageModal = () => { - setShowImageModal(false); - setSelectedImageUrl(""); - setSelectedImageTitle(""); - }; - - const handleImageLoad = (jobId) => { - // Image load event fired - setImageLoadingStates(prev => { - const newState = { - ...prev, - [jobId]: false - }; - // Loading state updated - return newState; - }); - }; - - const handleImageError = (jobId, e) => { - // Image error event fired - setImageLoadingStates(prev => { - const newState = { - ...prev, - [jobId]: false - }; - // Error state updated - return newState; - }); - - // Mark this image as failed - setFailedImages(prev => new Set([...prev, jobId])); - - // Check if it's a CORS error - if (e.target.src && e.target.src.includes('s3.eu-north-1.amazonaws.com')) { - console.warn('⚠️ UserJobs - CORS error detected for S3 image:', jobId); - } - - console.error('❌ UserJobs - Mini image failed to load for job:', jobId, e); - }; - - // Add timeout to prevent infinite loading - useEffect(() => { - const timeouts = {}; - - // Set up timeouts for all images that are loading - Object.keys(imageLoadingStates).forEach(jobId => { - if (imageLoadingStates[jobId] === true) { - timeouts[jobId] = setTimeout(() => { - setImageLoadingStates(prev => { - if (prev[jobId] === true) { - console.warn('⚠️ UserJobs - Image loading timeout for job:', jobId); - return { - ...prev, - [jobId]: false - }; - } - return prev; - }); - }, 3000); // 3 second timeout for CORS issues - } - }); - - // Cleanup timeouts - return () => { - Object.values(timeouts).forEach(timeout => clearTimeout(timeout)); - }; - }, [imageLoadingStates]); - - if (!user) { - return

{content.signInToView.value}

; - } - - return ( - <> -
-

- {loading ? : content.myAdsTitle.value} -

- - {loading ? ( -
- {Array.from({ length: 5 }).map((_, index) => ( -
-
- - - -
-
- ))} -
- ) : jobs.length === 0 ? ( -
-

{content.youDontHaveAds.value}

-
- No ads illustration -
-
- ) : ( -
- {jobs.map((job) => { - const isBoosted = isJobBoosted(job); - const timeUntilNextBoost = getTimeUntilNextBoost(job); - - return ( -
- {/* Плашка Премиум */} - {job.user?.isPremium && ( -
- {content.premiumBadge.value} -
- )} -
-
{job.title}
- {job.category?.label && ( -
- {job.category.label} -
- )} - {!job.category?.label && ( -
- {content.notSpecified.value} -
- )} -

- {content.salaryPerHourCard.value} {job.salary} -
- {content.locationCard.value}{" "} - {job.city?.name ? getCityLabel(job.city.name) : content.notSpecified.value} -

-

{job.description}

-
- {typeof job.shuttle === 'boolean' && ( -

- {content.shuttle.value}: {job.shuttle ? content.yes.value : content.no.value} -

- )} - {typeof job.meals === 'boolean' && ( -

- {content.meals.value}: {job.meals ? content.yes.value : content.no.value} -

- )} -

- {content.phoneNumberCard.value} {job.phone} -

-
- - {/* Image displayed under phone number in mini size */} - {job.imageUrl && ( -
- {/* Image rendering for job */} - {imageLoadingStates[job.id] && ( - - )} - - {failedImages.has(job.id) ? ( -
- {content.imageUnavailable.value} -
- ) : ( - {job.title} handleImageClick(e, job.imageUrl, job.title)} - onLoad={() => handleImageLoad(job.id)} - onError={(e) => { - handleImageError(job.id, e); - // Hide the broken image - e.target.style.display = 'none'; - }} - /> - )} -
- )} - -
- - - {content.createdAt.value + ": "} - - {formatDate(job.createdAt)} - -
-
-
-
- {isBoosted ? ( -
- - {content.nextBoostAfter.value} - -
- {timeUntilNextBoost ? `${timeUntilNextBoost.hours}${content.hoursShort.value} ${timeUntilNextBoost.minutes}${content.minutesShort.value}` : content.boostReady.value} -
-
- ) : ( - handleBoost(job.id)} - title={content.boostTitle.value} - style={{ cursor: 'pointer' }} - /> - )} -
- handleEdit(job.id)} - /> - openDeleteModal(job.id)} - /> -
-
- ); - })} - {totalPages > 1 && ( - - )} -
- )} - - {/* Delete Confirmation Modal */} - setShowModal(false)} centered> - - {content.confirmDelete.value} - - {content.confirmDeleteText.value} - - - - - - - {/* Image Modal - Only render if there's an image URL */} - {selectedImageUrl && ( - console.error('❌ UserJobs - Modal image failed to load:', selectedImageUrl, e)} - /> - )} -
- - ); + const content = useIntlayer('userJobs'); + const { locale } = useLocale(); + const { user } = useUser(); + const { getToken } = useAuth(); + const navigate = useNavigate(); + const { startLoadingWithProgress, completeLoading } = useLoadingProgress(); + const { getCityLabel } = useTranslationHelpers(); + + const [jobs, setJobs] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(true); + + const [showModal, setShowModal] = useState(false); + const [jobToDelete, setJobToDelete] = useState(null); + const [showImageModal, setShowImageModal] = useState(false); + const [selectedImageUrl, setSelectedImageUrl] = useState(''); + const [selectedImageTitle, setSelectedImageTitle] = useState(''); + const [imageLoadingStates, setImageLoadingStates] = useState({}); + const [failedImages, setFailedImages] = useState(new Set()); + + // Helper function to get the appropriate date-fns locale + const getDateLocale = () => { + switch (locale) { + case 'en': + return enUS; + case 'he': + return he; + case 'ar': + return ar; + default: + return ru; + } + }; + + // Helper function to format date based on language + const formatDate = (date) => { + const locale = getDateLocale(); + return format(new Date(date), 'dd MMMM yyyy', { locale }); + }; + + // Helper function to check if a job is already boosted + const isJobBoosted = (job) => { + if (!job.boostedAt) return false; + + const now = new Date(); + const boostedAt = new Date(job.boostedAt); + const timeSinceBoost = now - boostedAt; + const ONE_DAY = 24 * 60 * 60 * 1000; + + return timeSinceBoost < ONE_DAY; + }; + + // Helper function to get remaining time until next boost + const getTimeUntilNextBoost = (job) => { + if (!job.boostedAt) return null; + + const now = new Date(); + const boostedAt = new Date(job.boostedAt); + const timeSinceBoost = now - boostedAt; + const ONE_DAY = 24 * 60 * 60 * 1000; + + if (timeSinceBoost >= ONE_DAY) return null; + + const timeLeft = ONE_DAY - timeSinceBoost; + const hoursLeft = Math.floor(timeLeft / (1000 * 60 * 60)); + const minutesLeft = Math.floor((timeLeft % (1000 * 60 * 60)) / (1000 * 60)); + + return { hours: hoursLeft, minutes: minutesLeft }; + }; + + const fetchUserJobs = async () => { + if (!user) return; + + setLoading(true); + startLoadingWithProgress(1500); // Start loading progress + + try { + const response = await axios.get( + `${API_URL}/api/users/user-jobs/${user.id}?page=${currentPage}&limit=5&lang=${locale}`, + ); + + // Jobs data received from server + setJobs(response.data.jobs); + setTotalPages(response.data.totalPages); + + // Initialize loading states for all images + const initialLoadingStates = {}; + response.data.jobs.forEach((job) => { + if (job.imageUrl) { + initialLoadingStates[job.id] = true; + } + }); + // Initializing loading states + setImageLoadingStates(initialLoadingStates); + setFailedImages(new Set()); // Reset failed images for new jobs + completeLoading(); // Complete loading when done + } catch (error) { + console.error( + '❌ Ошибка загрузки объявлений пользователя:', + error.response?.data || error.message, + ); + toast.error(content.loadJobsError.value); + completeLoading(); // Complete loading even on error + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchUserJobs(); + }, [user, currentPage, locale]); // Loading functions are stable now + + const handleDelete = async () => { + if (!jobToDelete) return; + + startLoadingWithProgress(2000); // Start loading progress for deletion + + try { + const token = await getToken(); + await axios.delete(`${API_URL}/api/jobs/${jobToDelete}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + completeLoading(); // Complete loading when done + toast.success(content.jobDeletedSuccess.value); + setJobs((prevJobs) => prevJobs.filter((job) => job.id !== jobToDelete)); + } catch (error) { + console.error('Ошибка удаления объявления:', error); + completeLoading(); // Complete loading even on error + toast.error(content.jobDeletedError.value); + } finally { + setShowModal(false); + setJobToDelete(null); + } + }; + + const openDeleteModal = (jobId) => { + setJobToDelete(jobId); + setShowModal(true); + }; + + const handleEdit = (jobId) => { + navigate(`/edit-job/${jobId}`); + }; + + const handleBoost = async (jobId) => { + startLoadingWithProgress(1500); // Start loading progress for boost + + try { + const token = await getToken(); + await axios.post( + `${API_URL}/api/jobs/${jobId}/boost`, + {}, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + completeLoading(); // Complete loading when done + toast.success(content.jobBoostedSuccess.value); + fetchUserJobs(); + } catch (error) { + completeLoading(); // Complete loading even on error + toast.error(error.response?.data?.error || content.jobBoostedError.value); + } + }; + + const handlePageChange = (pageNumber) => { + setCurrentPage(pageNumber); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const handleImageClick = (e, imageUrl, title) => { + e.stopPropagation(); // Prevent card click when clicking image + setSelectedImageUrl(imageUrl); + setSelectedImageTitle(title); + setShowImageModal(true); + }; + + const handleCloseImageModal = () => { + setShowImageModal(false); + setSelectedImageUrl(''); + setSelectedImageTitle(''); + }; + + const handleImageLoad = (jobId) => { + // Image load event fired + setImageLoadingStates((prev) => { + const newState = { + ...prev, + [jobId]: false, + }; + // Loading state updated + return newState; + }); + }; + + const handleImageError = (jobId, e) => { + // Image error event fired + setImageLoadingStates((prev) => { + const newState = { + ...prev, + [jobId]: false, + }; + // Error state updated + return newState; + }); + + // Mark this image as failed + setFailedImages((prev) => new Set([...prev, jobId])); + + // Check if it's a CORS error + if (e.target.src && e.target.src.includes('s3.eu-north-1.amazonaws.com')) { + console.warn('⚠️ UserJobs - CORS error detected for S3 image:', jobId); + } + + console.error('❌ UserJobs - Mini image failed to load for job:', jobId, e); + }; + + // Add timeout to prevent infinite loading + useEffect(() => { + const timeouts = {}; + + // Set up timeouts for all images that are loading + Object.keys(imageLoadingStates).forEach((jobId) => { + if (imageLoadingStates[jobId] === true) { + timeouts[jobId] = setTimeout(() => { + setImageLoadingStates((prev) => { + if (prev[jobId] === true) { + console.warn( + '⚠️ UserJobs - Image loading timeout for job:', + jobId, + ); + return { + ...prev, + [jobId]: false, + }; + } + return prev; + }); + }, 3000); // 3 second timeout for CORS issues + } + }); + + // Cleanup timeouts + return () => { + Object.values(timeouts).forEach((timeout) => clearTimeout(timeout)); + }; + }, [imageLoadingStates]); + + if (!user) { + return

{content.signInToView.value}

; + } + + return ( + <> +
+

+ {loading ? ( + + ) : ( + content.myAdsTitle.value + )} +

+ + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+
+ + + +
+
+ ))} +
+ ) : jobs.length === 0 ? ( +
+

{content.youDontHaveAds.value}

+
+ No ads illustration +
+
+ ) : ( +
+ {jobs.map((job) => { + const isBoosted = isJobBoosted(job); + const timeUntilNextBoost = getTimeUntilNextBoost(job); + + return ( +
+ {/* Плашка Премиум */} + {job.user?.isPremium && ( +
+ {' '} + {content.premiumBadge.value} +
+ )} +
+
{job.title}
+ {job.category?.label && ( +
+ + {job.category.label} + +
+ )} + {!job.category?.label && ( +
+ + {content.notSpecified.value} + +
+ )} +

+ {content.salaryPerHourCard.value}{' '} + {job.salary} +
+ {content.locationCard.value}{' '} + {job.city?.name + ? getCityLabel(job.city.name) + : content.notSpecified.value} +

+

{job.description}

+
+ {typeof job.shuttle === 'boolean' && ( +

+ {content.shuttle.value}:{' '} + {job.shuttle ? content.yes.value : content.no.value} +

+ )} + {typeof job.meals === 'boolean' && ( +

+ {content.meals.value}:{' '} + {job.meals ? content.yes.value : content.no.value} +

+ )} +

+ {content.phoneNumberCard.value}{' '} + {job.phone} +

+
+ + {/* Image displayed under phone number in mini size */} + {job.imageUrl && ( +
+ {/* Image rendering for job */} + {imageLoadingStates[job.id] && ( + + )} + + {failedImages.has(job.id) ? ( +
+ {content.imageUnavailable.value} +
+ ) : ( + {job.title} + handleImageClick(e, job.imageUrl, job.title) + } + onLoad={() => handleImageLoad(job.id)} + onError={(e) => { + handleImageError(job.id, e); + // Hide the broken image + e.target.style.display = 'none'; + }} + /> + )} +
+ )} + +
+ + + {content.createdAt.value + ': '} + + {formatDate(job.createdAt)} + +
+
+
+
+ {isBoosted ? ( +
+ + {content.nextBoostAfter.value} + +
+ {timeUntilNextBoost + ? `${timeUntilNextBoost.hours}${content.hoursShort.value} ${timeUntilNextBoost.minutes}${content.minutesShort.value}` + : content.boostReady.value} +
+
+ ) : ( + handleBoost(job.id)} + title={content.boostTitle.value} + style={{ cursor: 'pointer' }} + /> + )} +
+ handleEdit(job.id)} + /> + openDeleteModal(job.id)} + /> +
+
+ ); + })} + {totalPages > 1 && ( + + )} +
+ )} + + {/* Delete Confirmation Modal */} + setShowModal(false)} centered> + + {content.confirmDelete.value} + + {content.confirmDeleteText.value} + + + + + + + {/* Image Modal - Only render if there's an image URL */} + {selectedImageUrl && ( + + console.error( + '❌ UserJobs - Modal image failed to load:', + selectedImageUrl, + e, + ) + } + /> + )} +
+ + ); }; -export default UserJobs; \ No newline at end of file +export default UserJobs; diff --git a/apps/client/src/components/UserProfile.jsx b/apps/client/src/components/UserProfile.jsx index 222d273..fba7508 100755 --- a/apps/client/src/components/UserProfile.jsx +++ b/apps/client/src/components/UserProfile.jsx @@ -1,237 +1,238 @@ -import { useEffect, useState } from "react"; -import PropTypes from "prop-types"; -import { useParams } from "react-router-dom"; -import axios from "axios"; -import Skeleton from "react-loading-skeleton"; -import { Helmet } from "react-helmet-async"; -import { useIntlayer } from "react-intlayer"; -import "bootstrap/dist/css/bootstrap.min.css"; -import "react-loading-skeleton/dist/skeleton.css"; +import axios from 'axios'; +import PropTypes from 'prop-types'; +import { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet-async'; +import { useIntlayer } from 'react-intlayer'; +import Skeleton from 'react-loading-skeleton'; +import { useParams } from 'react-router-dom'; +import 'bootstrap/dist/css/bootstrap.min.css'; +import 'react-loading-skeleton/dist/skeleton.css'; import { useUser } from '@clerk/clerk-react'; import { useLoadingProgress } from '../hooks/useLoadingProgress'; -import JobCard from "./JobCard"; -import PaginationControl from "./PaginationControl"; +import JobCard from './JobCard'; +import PaginationControl from './PaginationControl'; const API_URL = import.meta.env.VITE_API_URL; const UserProfile = () => { - const content = useIntlayer("userProfile"); - const { user: clerkUser, isLoaded } = useUser(); - const [user, setUser] = useState(null); - const [jobs, setJobs] = useState([]); - const [loading, setLoading] = useState(true); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const jobsPerPage = 5; - const { clerkUserId } = useParams(); - const { startLoadingWithProgress, completeLoading, stopLoadingImmediately } = useLoadingProgress(); - - const fetchJobs = async (page) => { - try { - const response = await axios.get( - `${API_URL}/api/users/user-jobs/${clerkUserId}?page=${page}&limit=${jobsPerPage}` - ); - // Jobs data received from server - setJobs(response.data.jobs); - setTotalPages(response.data.totalPages); - } catch (error) { - console.error("Ошибка загрузки объявлений:", error); - } - }; - - const fetchProfileData = async () => { - try { - startLoadingWithProgress(2000); // Start progress bar for 2 seconds - - const timestamp = new Date().getTime(); - const userResponse = await axios.get( - `${API_URL}/api/users/${clerkUserId}?t=${timestamp}` - ); - - if (!userResponse.data || !userResponse.data.firstName) { - console.warn("⚠️ Пользователь не найден или данные профиля пустые!"); - setUser(null); - } else { - setUser(userResponse.data); - } - - await fetchJobs(currentPage); - completeLoading(); // Complete the progress bar - } catch (error) { - console.error("❌ Ошибка загрузки данных профиля:", error); - setUser(null); - stopLoadingImmediately(); // Stop progress bar on error - } finally { - setLoading(false); - } - }; - - // Добавляем эффект для обновления при возвращении фокуса и смене аватарки - useEffect(() => { - const handleFocus = () => { - fetchProfileData(); - }; - const handleAvatarChanged = () => { - fetchProfileData(); - }; - - window.addEventListener("focus", handleFocus); - window.addEventListener("avatarChanged", handleAvatarChanged); - return () => { - window.removeEventListener("focus", handleFocus); - window.removeEventListener("avatarChanged", handleAvatarChanged); - }; - }, []); - - // Основной эффект для загрузки данных - useEffect(() => { - fetchProfileData(); - }, [clerkUserId, currentPage]); - - const handlePageChange = (pageNumber) => { - if (pageNumber !== currentPage) { - setCurrentPage(pageNumber); - } - }; - - // Определяем, просматривает ли пользователь свой профиль - const isOwnProfile = isLoaded && clerkUser && clerkUser.id === clerkUserId; - - // Если это свой профиль — используем данные из Clerk - const profileData = isOwnProfile - ? { - name: `${clerkUser.firstName || ''} ${clerkUser.lastName || ''}`.trim(), - email: clerkUser.primaryEmailAddress?.emailAddress || clerkUser.emailAddresses?.[0]?.emailAddress || '', - imageUrl: clerkUser.imageUrl, - } - : user; - - const pageTitle = profileData - ? `${profileData.name || ''} | ${content.user_profile_title} - WorkNow` - : `${content.user_not_found} | WorkNow`; - - const pageDescription = profileData - ? `${content.profile_description}: ${profileData.name}. ${content.user_jobs}: ${jobs.length}.` - : content.user_profile_not_found_description; - - const profileImage = profileData?.imageUrl || "/images/default-avatar.png"; - const profileUrl = `https://worknow.co.il/user/${clerkUserId}`; - - // Создаем SEO-разметку отдельно, чтобы избежать мутации данных - const jobPostingSchema = jobs.map((job) => ({ - "@type": "JobPosting", - title: job.title, - description: job.description, - hiringOrganization: { - "@type": "Organization", - name: "WorkNow", - sameAs: "https://worknow.co.il", - }, - jobLocation: { - "@type": "Place", - address: { - "@type": "PostalAddress", - addressLocality: job.city?.name || "Не указано", - addressCountry: "IL", - }, - }, - "jobCategory": job.category?.name || "Не указано" - })); - - return ( - <> - {/* 🔹 SEO-оптимизация */} - - {pageTitle} - - - - - - - - - {/* 🔹 Schema.org разметка (Person + JobPosting) */} - - - -
- {loading ? ( - - ) : !profileData ? ( -

{content.user_not_found}

- ) : ( - <> -

{content.user_jobs}

- {jobs.length === 0 ? ( -

{content.user_no_jobs}

- ) : ( - <> - {jobs.map((job) => ( - - ))} - - - )} - - )} -
- - ); + const content = useIntlayer('userProfile'); + const { user: clerkUser, isLoaded } = useUser(); + const [user, setUser] = useState(null); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const jobsPerPage = 5; + const { clerkUserId } = useParams(); + const { startLoadingWithProgress, completeLoading, stopLoadingImmediately } = + useLoadingProgress(); + + const fetchJobs = async (page) => { + try { + const response = await axios.get( + `${API_URL}/api/users/user-jobs/${clerkUserId}?page=${page}&limit=${jobsPerPage}`, + ); + // Jobs data received from server + setJobs(response.data.jobs); + setTotalPages(response.data.totalPages); + } catch (error) { + console.error('Ошибка загрузки объявлений:', error); + } + }; + + const fetchProfileData = async () => { + try { + startLoadingWithProgress(2000); // Start progress bar for 2 seconds + + const timestamp = new Date().getTime(); + const userResponse = await axios.get( + `${API_URL}/api/users/${clerkUserId}?t=${timestamp}`, + ); + + if (!userResponse.data || !userResponse.data.firstName) { + console.warn('⚠️ Пользователь не найден или данные профиля пустые!'); + setUser(null); + } else { + setUser(userResponse.data); + } + + await fetchJobs(currentPage); + completeLoading(); // Complete the progress bar + } catch (error) { + console.error('❌ Ошибка загрузки данных профиля:', error); + setUser(null); + stopLoadingImmediately(); // Stop progress bar on error + } finally { + setLoading(false); + } + }; + + // Добавляем эффект для обновления при возвращении фокуса и смене аватарки + useEffect(() => { + const handleFocus = () => { + fetchProfileData(); + }; + const handleAvatarChanged = () => { + fetchProfileData(); + }; + + window.addEventListener('focus', handleFocus); + window.addEventListener('avatarChanged', handleAvatarChanged); + return () => { + window.removeEventListener('focus', handleFocus); + window.removeEventListener('avatarChanged', handleAvatarChanged); + }; + }, []); + + // Основной эффект для загрузки данных + useEffect(() => { + fetchProfileData(); + }, [clerkUserId, currentPage]); + + const handlePageChange = (pageNumber) => { + if (pageNumber !== currentPage) { + setCurrentPage(pageNumber); + } + }; + + // Определяем, просматривает ли пользователь свой профиль + const isOwnProfile = isLoaded && clerkUser && clerkUser.id === clerkUserId; + + // Если это свой профиль — используем данные из Clerk + const profileData = isOwnProfile + ? { + name: `${clerkUser.firstName || ''} ${clerkUser.lastName || ''}`.trim(), + email: + clerkUser.primaryEmailAddress?.emailAddress || + clerkUser.emailAddresses?.[0]?.emailAddress || + '', + imageUrl: clerkUser.imageUrl, + } + : user; + + const pageTitle = profileData + ? `${profileData.name || ''} | ${content.user_profile_title} - WorkNow` + : `${content.user_not_found} | WorkNow`; + + const pageDescription = profileData + ? `${content.profile_description}: ${profileData.name}. ${content.user_jobs}: ${jobs.length}.` + : content.user_profile_not_found_description; + + const profileImage = profileData?.imageUrl || '/images/default-avatar.png'; + const profileUrl = `https://worknow.co.il/user/${clerkUserId}`; + + // Создаем SEO-разметку отдельно, чтобы избежать мутации данных + const jobPostingSchema = jobs.map((job) => ({ + '@type': 'JobPosting', + title: job.title, + description: job.description, + hiringOrganization: { + '@type': 'Organization', + name: 'WorkNow', + sameAs: 'https://worknow.co.il', + }, + jobLocation: { + '@type': 'Place', + address: { + '@type': 'PostalAddress', + addressLocality: job.city?.name || 'Не указано', + addressCountry: 'IL', + }, + }, + jobCategory: job.category?.name || 'Не указано', + })); + + return ( + <> + {/* 🔹 SEO-оптимизация */} + + {pageTitle} + + + + + + + + + {/* 🔹 Schema.org разметка (Person + JobPosting) */} + + + +
+ {loading ? ( + + ) : !profileData ? ( +

{content.user_not_found}

+ ) : ( + <> +

{content.user_jobs}

+ {jobs.length === 0 ? ( +

{content.user_no_jobs}

+ ) : ( + <> + {jobs.map((job) => ( + + ))} + + + )} + + )} +
+ + ); }; // Компонент заглушки (скелетон) const SkeletonLoader = ({ jobsPerPage }) => ( - <> -

- -

- {Array.from({ length: jobsPerPage }).map((_, index) => ( -
-
- - - - - - -
-
- ))} - + <> +

+ +

+ {Array.from({ length: jobsPerPage }).map((_, index) => ( +
+
+ + + + + + +
+
+ ))} + ); SkeletonLoader.propTypes = { - jobsPerPage: PropTypes.number.isRequired, + jobsPerPage: PropTypes.number.isRequired, }; export default UserProfile; diff --git a/apps/client/src/components/form/AddSeekerModal.jsx b/apps/client/src/components/form/AddSeekerModal.jsx index ca0a952..8147133 100644 --- a/apps/client/src/components/form/AddSeekerModal.jsx +++ b/apps/client/src/components/form/AddSeekerModal.jsx @@ -1,307 +1,448 @@ -import { useState } from "react"; +import PropTypes from 'prop-types'; +import { useState } from 'react'; import { useIntlayer } from 'react-intlayer'; -import useFetchCities from '../../hooks/useFetchCities'; import useFetchCategories from '../../hooks/useFetchCategories'; -import PropTypes from 'prop-types'; +import useFetchCities from '../../hooks/useFetchCities'; export default function AddSeekerModal({ show, onClose, onSubmit }) { - const content = useIntlayer("addSeekerModal"); - const [step, setStep] = useState(1); - const [form, setForm] = useState({ - name: '', - contact: '', - city: '', - description: '', - gender: '', - isDemanded: false, - facebook: '', - languages: [], - nativeLanguage: '', - category: '', - employment: '', - documents: '', - note: '', - announcement: '', - documentType: '', - }); - const [error, setError] = useState(null); - - const languageOptions = [ - { value: 'русский', label: content.languageRussian.value }, - { value: 'арабский', label: content.languageArabic.value }, - { value: 'английский', label: content.languageEnglish.value }, - { value: 'иврит', label: content.languageHebrew.value }, - { value: 'украинский', label: content.languageUkrainian.value }, - ]; + const content = useIntlayer('addSeekerModal'); + const [step, setStep] = useState(1); + const [form, setForm] = useState({ + name: '', + contact: '', + city: '', + description: '', + gender: '', + isDemanded: false, + facebook: '', + languages: [], + nativeLanguage: '', + category: '', + employment: '', + documents: '', + note: '', + announcement: '', + documentType: '', + }); + const [error, setError] = useState(null); - const { cities, loading: loadingCities } = useFetchCities(); - const { categories, loading: loadingCategories } = useFetchCategories(); + const languageOptions = [ + { value: 'русский', label: content.languageRussian.value }, + { value: 'арабский', label: content.languageArabic.value }, + { value: 'английский', label: content.languageEnglish.value }, + { value: 'иврит', label: content.languageHebrew.value }, + { value: 'украинский', label: content.languageUkrainian.value }, + ]; - const employmentOptions = [ - { value: 'полная', label: content.employmentFull.value }, - { value: 'частичная', label: content.employmentPartial.value }, - ]; + const { cities, loading: loadingCities } = useFetchCities(); + const { categories, loading: loadingCategories } = useFetchCategories(); - const documentTypeOptions = [ - { value: 'Виза Б1', label: content.documentVisaB1.value }, - { value: 'Виза Б2', label: content.documentVisaB2.value }, - { value: 'Теудат Зеут', label: content.documentTeudatZehut.value }, - { value: 'Рабочая виза', label: content.documentWorkVisa.value }, - { value: 'Другое', label: content.documentOther.value }, - ]; + const employmentOptions = [ + { value: 'полная', label: content.employmentFull.value }, + { value: 'частичная', label: content.employmentPartial.value }, + ]; - if (!show) return null; + const documentTypeOptions = [ + { value: 'Виза Б1', label: content.documentVisaB1.value }, + { value: 'Виза Б2', label: content.documentVisaB2.value }, + { value: 'Теудат Зеут', label: content.documentTeudatZehut.value }, + { value: 'Рабочая виза', label: content.documentWorkVisa.value }, + { value: 'Другое', label: content.documentOther.value }, + ]; - const handleNext = (e) => { - e.preventDefault(); - if (!form.name || !form.contact || !form.city || !form.description || !form.gender) { - setError(content.fillAllFieldsError.value); - return; - } - setError(null); - setStep(2); - }; + if (!show) return null; - const handleBack = () => { - setStep(1); - }; + const handleNext = (e) => { + e.preventDefault(); + if ( + !form.name || + !form.contact || + !form.city || + !form.description || + !form.gender + ) { + setError(content.fillAllFieldsError.value); + return; + } + setError(null); + setStep(2); + }; - const handleChange = (e) => { - const { name, value, type, checked } = e.target; - if (name === 'languages') { - setForm(f => { - let langs = f.languages || []; - if (checked) { - langs = [...langs, value]; - } else { - langs = langs.filter(l => l !== value); - } - // если убрали родной язык из списка языков — сбросить nativeLanguage - const nativeLanguage = langs.includes(f.nativeLanguage) ? f.nativeLanguage : ''; - return { ...f, languages: langs, nativeLanguage }; - }); - } else if (name === 'nativeLanguage') { - setForm(f => ({ ...f, nativeLanguage: value })); - } else if (type === 'radio') { - setForm(f => ({ ...f, [name]: value })); - } else if (type === 'checkbox' && name !== 'languages') { - setForm(f => ({ ...f, [name]: checked })); - } else { - setForm(f => ({ ...f, [name]: value })); - } - }; + const handleBack = () => { + setStep(1); + }; - const handleSubmit = (e) => { - e.preventDefault(); - if (!form.nativeLanguage || form.languages.length === 0) { - setError(content.languageSelectionError.value); - return; - } - onSubmit(form); - }; + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + if (name === 'languages') { + setForm((f) => { + let langs = f.languages || []; + if (checked) { + langs = [...langs, value]; + } else { + langs = langs.filter((l) => l !== value); + } + // если убрали родной язык из списка языков — сбросить nativeLanguage + const nativeLanguage = langs.includes(f.nativeLanguage) + ? f.nativeLanguage + : ''; + return { ...f, languages: langs, nativeLanguage }; + }); + } else if (name === 'nativeLanguage') { + setForm((f) => ({ ...f, nativeLanguage: value })); + } else if (type === 'radio') { + setForm((f) => ({ ...f, [name]: value })); + } else if (type === 'checkbox' && name !== 'languages') { + setForm((f) => ({ ...f, [name]: checked })); + } else { + setForm((f) => ({ ...f, [name]: value })); + } + }; - return ( -
-
-
-
-

- {content.addSeeker.value} -

- -
-
- {step === 1 && ( -
-
- -
-
- -
-
- -
-
- -
-
-