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