hMarket Trae ilk versiyon
This commit is contained in:
371
backend/src/config/firebase.js
Normal file
371
backend/src/config/firebase.js
Normal file
@@ -0,0 +1,371 @@
|
||||
const admin = require('firebase-admin');
|
||||
|
||||
let firebaseApp = null;
|
||||
|
||||
/**
|
||||
* Firebase Admin SDK'yı başlat
|
||||
*/
|
||||
const initializeFirebase = () => {
|
||||
try {
|
||||
// Eğer zaten başlatılmışsa, mevcut instance'ı döndür
|
||||
if (firebaseApp) {
|
||||
return firebaseApp;
|
||||
}
|
||||
|
||||
// Environment variables'dan Firebase config'i al
|
||||
const firebaseConfig = {
|
||||
type: process.env.FIREBASE_TYPE,
|
||||
project_id: process.env.FIREBASE_PROJECT_ID,
|
||||
private_key_id: process.env.FIREBASE_PRIVATE_KEY_ID,
|
||||
private_key: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
client_email: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
client_id: process.env.FIREBASE_CLIENT_ID,
|
||||
auth_uri: process.env.FIREBASE_AUTH_URI,
|
||||
token_uri: process.env.FIREBASE_TOKEN_URI,
|
||||
auth_provider_x509_cert_url: process.env.FIREBASE_AUTH_PROVIDER_X509_CERT_URL,
|
||||
client_x509_cert_url: process.env.FIREBASE_CLIENT_X509_CERT_URL
|
||||
};
|
||||
|
||||
// Gerekli environment variables'ların varlığını kontrol et
|
||||
const requiredFields = ['project_id', 'private_key', 'client_email'];
|
||||
const missingFields = requiredFields.filter(field => !firebaseConfig[field]);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
console.warn(`⚠️ Firebase konfigürasyonu eksik: ${missingFields.join(', ')}`);
|
||||
console.warn('Push notification özelliği devre dışı bırakıldı.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Firebase Admin SDK'yı başlat
|
||||
firebaseApp = admin.initializeApp({
|
||||
credential: admin.credential.cert(firebaseConfig),
|
||||
projectId: firebaseConfig.project_id
|
||||
});
|
||||
|
||||
console.log('✅ Firebase Admin SDK başarıyla başlatıldı');
|
||||
return firebaseApp;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Firebase başlatma hatası:', error.message);
|
||||
console.warn('Push notification özelliği devre dışı bırakıldı.');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Firebase Messaging instance'ını al
|
||||
*/
|
||||
const getMessaging = () => {
|
||||
if (!firebaseApp) {
|
||||
console.warn('Firebase başlatılmamış. Push notification gönderilemez.');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return admin.messaging();
|
||||
} catch (error) {
|
||||
console.error('Firebase Messaging alınamadı:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tek bir cihaza push notification gönder
|
||||
* @param {string} token - Device token
|
||||
* @param {object} notification - Notification objesi
|
||||
* @param {object} data - Ek veri
|
||||
* @returns {Promise<object>} Gönderim sonucu
|
||||
*/
|
||||
const sendToDevice = async (token, notification, data = {}) => {
|
||||
const messaging = getMessaging();
|
||||
if (!messaging) {
|
||||
return { success: false, error: 'Firebase Messaging kullanılamıyor' };
|
||||
}
|
||||
|
||||
try {
|
||||
const message = {
|
||||
token: token,
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
...(notification.imageUrl && { imageUrl: notification.imageUrl })
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
android: {
|
||||
notification: {
|
||||
icon: 'ic_notification',
|
||||
color: '#FF5722',
|
||||
sound: 'default',
|
||||
priority: 'high'
|
||||
}
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: 'default',
|
||||
badge: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const response = await messaging.send(message);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: response,
|
||||
token: token
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Push notification gönderme hatası:', error);
|
||||
|
||||
// Token geçersizse, bu bilgiyi döndür
|
||||
if (error.code === 'messaging/registration-token-not-registered' ||
|
||||
error.code === 'messaging/invalid-registration-token') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid token',
|
||||
invalidToken: true,
|
||||
token: token
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
token: token
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Birden fazla cihaza push notification gönder
|
||||
* @param {string[]} tokens - Device token'ları
|
||||
* @param {object} notification - Notification objesi
|
||||
* @param {object} data - Ek veri
|
||||
* @returns {Promise<object>} Gönderim sonucu
|
||||
*/
|
||||
const sendToMultipleDevices = async (tokens, notification, data = {}) => {
|
||||
const messaging = getMessaging();
|
||||
if (!messaging) {
|
||||
return { success: false, error: 'Firebase Messaging kullanılamıyor' };
|
||||
}
|
||||
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return { success: false, error: 'Token listesi boş' };
|
||||
}
|
||||
|
||||
try {
|
||||
const message = {
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
...(notification.imageUrl && { imageUrl: notification.imageUrl })
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
android: {
|
||||
notification: {
|
||||
icon: 'ic_notification',
|
||||
color: '#FF5722',
|
||||
sound: 'default',
|
||||
priority: 'high'
|
||||
}
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: 'default',
|
||||
badge: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
tokens: tokens
|
||||
};
|
||||
|
||||
const response = await messaging.sendMulticast(message);
|
||||
|
||||
// Başarısız token'ları topla
|
||||
const failedTokens = [];
|
||||
const invalidTokens = [];
|
||||
|
||||
response.responses.forEach((resp, idx) => {
|
||||
if (!resp.success) {
|
||||
const token = tokens[idx];
|
||||
failedTokens.push({ token, error: resp.error?.message });
|
||||
|
||||
if (resp.error?.code === 'messaging/registration-token-not-registered' ||
|
||||
resp.error?.code === 'messaging/invalid-registration-token') {
|
||||
invalidTokens.push(token);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.successCount > 0,
|
||||
successCount: response.successCount,
|
||||
failureCount: response.failureCount,
|
||||
failedTokens: failedTokens,
|
||||
invalidTokens: invalidTokens
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Toplu push notification gönderme hatası:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Topic'e push notification gönder
|
||||
* @param {string} topic - Topic adı
|
||||
* @param {object} notification - Notification objesi
|
||||
* @param {object} data - Ek veri
|
||||
* @returns {Promise<object>} Gönderim sonucu
|
||||
*/
|
||||
const sendToTopic = async (topic, notification, data = {}) => {
|
||||
const messaging = getMessaging();
|
||||
if (!messaging) {
|
||||
return { success: false, error: 'Firebase Messaging kullanılamıyor' };
|
||||
}
|
||||
|
||||
try {
|
||||
const message = {
|
||||
topic: topic,
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
...(notification.imageUrl && { imageUrl: notification.imageUrl })
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
android: {
|
||||
notification: {
|
||||
icon: 'ic_notification',
|
||||
color: '#FF5722',
|
||||
sound: 'default',
|
||||
priority: 'high'
|
||||
}
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: 'default',
|
||||
badge: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const response = await messaging.send(message);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: response,
|
||||
topic: topic
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Topic push notification gönderme hatası:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
topic: topic
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cihazı topic'e abone et
|
||||
* @param {string[]} tokens - Device token'ları
|
||||
* @param {string} topic - Topic adı
|
||||
* @returns {Promise<object>} Abonelik sonucu
|
||||
*/
|
||||
const subscribeToTopic = async (tokens, topic) => {
|
||||
const messaging = getMessaging();
|
||||
if (!messaging) {
|
||||
return { success: false, error: 'Firebase Messaging kullanılamıyor' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await messaging.subscribeToTopic(tokens, topic);
|
||||
|
||||
return {
|
||||
success: response.successCount > 0,
|
||||
successCount: response.successCount,
|
||||
failureCount: response.failureCount,
|
||||
errors: response.errors
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Topic abonelik hatası:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cihazın topic aboneliğini iptal et
|
||||
* @param {string[]} tokens - Device token'ları
|
||||
* @param {string} topic - Topic adı
|
||||
* @returns {Promise<object>} Abonelik iptal sonucu
|
||||
*/
|
||||
const unsubscribeFromTopic = async (tokens, topic) => {
|
||||
const messaging = getMessaging();
|
||||
if (!messaging) {
|
||||
return { success: false, error: 'Firebase Messaging kullanılamıyor' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await messaging.unsubscribeFromTopic(tokens, topic);
|
||||
|
||||
return {
|
||||
success: response.successCount > 0,
|
||||
successCount: response.successCount,
|
||||
failureCount: response.failureCount,
|
||||
errors: response.errors
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Topic abonelik iptal hatası:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Firebase durumunu kontrol et
|
||||
* @returns {object} Firebase durum bilgisi
|
||||
*/
|
||||
const getStatus = () => {
|
||||
return {
|
||||
initialized: !!firebaseApp,
|
||||
messaging: !!getMessaging(),
|
||||
projectId: firebaseApp?.options?.projectId || null
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
initializeFirebase,
|
||||
getMessaging,
|
||||
sendToDevice,
|
||||
sendToMultipleDevices,
|
||||
sendToTopic,
|
||||
subscribeToTopic,
|
||||
unsubscribeFromTopic,
|
||||
getStatus
|
||||
};
|
||||
98
backend/src/config/passport.js
Normal file
98
backend/src/config/passport.js
Normal file
@@ -0,0 +1,98 @@
|
||||
const passport = require('passport');
|
||||
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Kullanıcıyı session'a serialize et
|
||||
passport.serializeUser((user, done) => {
|
||||
done(null, user.id);
|
||||
});
|
||||
|
||||
// Session'dan kullanıcıyı deserialize et
|
||||
passport.deserializeUser(async (id, done) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true,
|
||||
authProvider: true,
|
||||
isActive: true,
|
||||
isAdmin: true
|
||||
}
|
||||
});
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
});
|
||||
|
||||
// Google OAuth Strategy
|
||||
passport.use(new GoogleStrategy({
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: process.env.GOOGLE_CALLBACK_URL || "/api/auth/google/callback"
|
||||
}, async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
// Önce Google ID ile kullanıcı ara
|
||||
let user = await prisma.user.findUnique({
|
||||
where: { googleId: profile.id }
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// Kullanıcı zaten var, son giriş tarihini güncelle
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() }
|
||||
});
|
||||
return done(null, user);
|
||||
}
|
||||
|
||||
// Email ile kullanıcı ara (mevcut hesap varsa bağla)
|
||||
user = await prisma.user.findUnique({
|
||||
where: { email: profile.emails[0].value }
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// Mevcut hesabı Google ile bağla
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
googleId: profile.id,
|
||||
authProvider: 'google',
|
||||
avatar: profile.photos[0]?.value || user.avatar,
|
||||
lastLoginAt: new Date()
|
||||
}
|
||||
});
|
||||
return done(null, user);
|
||||
}
|
||||
|
||||
// Yeni kullanıcı oluştur
|
||||
const username = profile.emails[0].value.split('@')[0] + '_' + Math.random().toString(36).substr(2, 4);
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email: profile.emails[0].value,
|
||||
username: username,
|
||||
firstName: profile.name.givenName || '',
|
||||
lastName: profile.name.familyName || '',
|
||||
googleId: profile.id,
|
||||
authProvider: 'google',
|
||||
avatar: profile.photos[0]?.value,
|
||||
lastLoginAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
return done(null, user);
|
||||
} catch (error) {
|
||||
console.error('Google OAuth Error:', error);
|
||||
return done(error, null);
|
||||
}
|
||||
}));
|
||||
|
||||
module.exports = passport;
|
||||
226
backend/src/middleware/auth.js
Normal file
226
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,226 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* JWT token doğrulama middleware'i
|
||||
*/
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Erişim token\'ı bulunamadı. Lütfen giriş yapın.'
|
||||
});
|
||||
}
|
||||
|
||||
// Token'ı doğrula
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Kullanıcıyı veritabanından getir
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: decoded.userId,
|
||||
isActive: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true,
|
||||
isAdmin: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Geçersiz token. Kullanıcı bulunamadı.'
|
||||
});
|
||||
}
|
||||
|
||||
// Kullanıcı bilgilerini request'e ekle
|
||||
req.user = user;
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Geçersiz token formatı.'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token süresi dolmuş. Lütfen tekrar giriş yapın.'
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Auth middleware hatası:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Kimlik doğrulama sırasında bir hata oluştu.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin yetkisi kontrolü middleware'i
|
||||
*/
|
||||
const requireAdmin = (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Kimlik doğrulama gerekli.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!req.user.isAdmin) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Bu işlem için admin yetkisi gerekli.'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Liste üyeliği kontrolü middleware'i
|
||||
*/
|
||||
const checkListMembership = async (req, res, next) => {
|
||||
try {
|
||||
const listId = req.params.listId || req.body.listId;
|
||||
const userId = req.user.id;
|
||||
|
||||
if (!listId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Liste ID\'si gerekli.'
|
||||
});
|
||||
}
|
||||
|
||||
// Liste sahibi mi kontrol et
|
||||
const list = await prisma.shoppingList.findFirst({
|
||||
where: {
|
||||
id: listId,
|
||||
ownerId: userId,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (list) {
|
||||
req.userRole = 'owner';
|
||||
req.list = list;
|
||||
return next();
|
||||
}
|
||||
|
||||
// Liste üyesi mi kontrol et
|
||||
const membership = await prisma.listMember.findFirst({
|
||||
where: {
|
||||
listId: listId,
|
||||
userId: userId
|
||||
},
|
||||
include: {
|
||||
list: {
|
||||
where: {
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!membership || !membership.list) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Bu listeye erişim yetkiniz yok.'
|
||||
});
|
||||
}
|
||||
|
||||
req.userRole = membership.role;
|
||||
req.list = membership.list;
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Liste üyelik kontrolü hatası:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Liste erişim kontrolü sırasında bir hata oluştu.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Liste düzenleme yetkisi kontrolü
|
||||
*/
|
||||
const requireListEditPermission = (req, res, next) => {
|
||||
const allowedRoles = ['owner', 'admin', 'editor'];
|
||||
|
||||
if (!allowedRoles.includes(req.userRole)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Bu işlem için yeterli yetkiniz yok.'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Opsiyonel kimlik doğrulama (token varsa doğrula, yoksa devam et)
|
||||
*/
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return next(); // Token yoksa devam et
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: decoded.userId,
|
||||
isActive: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true,
|
||||
isAdmin: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
req.user = user;
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
// Token geçersizse de devam et
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
checkListMembership,
|
||||
requireListEditPermission,
|
||||
optionalAuth
|
||||
};
|
||||
125
backend/src/middleware/errorHandler.js
Normal file
125
backend/src/middleware/errorHandler.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 404 - Sayfa bulunamadı middleware'i
|
||||
*/
|
||||
const notFound = (req, res, next) => {
|
||||
const error = new Error(`Bulunamadı - ${req.originalUrl}`);
|
||||
res.status(404);
|
||||
next(error);
|
||||
};
|
||||
|
||||
/**
|
||||
* Genel hata yakalama middleware'i
|
||||
*/
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
let statusCode = res.statusCode === 200 ? 500 : res.statusCode;
|
||||
let message = err.message;
|
||||
|
||||
// Prisma hataları
|
||||
if (err.code === 'P2002') {
|
||||
statusCode = 400;
|
||||
message = 'Bu veri zaten mevcut. Benzersiz alan ihlali.';
|
||||
}
|
||||
|
||||
if (err.code === 'P2025') {
|
||||
statusCode = 404;
|
||||
message = 'İstenen kayıt bulunamadı.';
|
||||
}
|
||||
|
||||
if (err.code === 'P2003') {
|
||||
statusCode = 400;
|
||||
message = 'İlişkili veri bulunamadı. Yabancı anahtar ihlali.';
|
||||
}
|
||||
|
||||
// Validation hataları
|
||||
if (err.name === 'ValidationError') {
|
||||
statusCode = 400;
|
||||
message = Object.values(err.errors).map(val => val.message).join(', ');
|
||||
}
|
||||
|
||||
// JWT hataları
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
statusCode = 401;
|
||||
message = 'Geçersiz token.';
|
||||
}
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
statusCode = 401;
|
||||
message = 'Token süresi dolmuş.';
|
||||
}
|
||||
|
||||
// Multer hataları (dosya yükleme)
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
statusCode = 400;
|
||||
message = 'Dosya boyutu çok büyük.';
|
||||
}
|
||||
|
||||
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
statusCode = 400;
|
||||
message = 'Beklenmeyen dosya alanı.';
|
||||
}
|
||||
|
||||
// Hata logla (production'da daha detaylı loglama yapılabilir)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('🚨 Hata:', {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
console.error('🚨 Hata:', {
|
||||
message: err.message,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Hata yanıtı
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
message: message,
|
||||
statusCode: statusCode
|
||||
};
|
||||
|
||||
// Development modunda stack trace ekle
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
errorResponse.stack = err.stack;
|
||||
errorResponse.details = {
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
res.status(statusCode).json(errorResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Async fonksiyonlar için hata yakalama wrapper'ı
|
||||
*/
|
||||
const asyncHandler = (fn) => (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation hata formatter'ı
|
||||
*/
|
||||
const formatValidationErrors = (errors) => {
|
||||
return errors.array().map(error => ({
|
||||
field: error.param,
|
||||
message: error.msg,
|
||||
value: error.value
|
||||
}));
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
notFound,
|
||||
errorHandler,
|
||||
asyncHandler,
|
||||
formatValidationErrors
|
||||
};
|
||||
598
backend/src/routes/admin.js
Normal file
598
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,598 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult, param, query } = require('express-validator');
|
||||
const { asyncHandler, formatValidationErrors } = require('../middleware/errorHandler');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Admin dashboard istatistikleri
|
||||
* GET /api/admin/dashboard
|
||||
*/
|
||||
router.get('/dashboard', [
|
||||
authenticateToken,
|
||||
requireAdmin
|
||||
], asyncHandler(async (req, res) => {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const startOfWeek = new Date(now.setDate(now.getDate() - now.getDay()));
|
||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
const [
|
||||
// Kullanıcı istatistikleri
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
newUsersToday,
|
||||
newUsersThisWeek,
|
||||
newUsersThisMonth,
|
||||
|
||||
// Liste istatistikleri
|
||||
totalLists,
|
||||
activeLists,
|
||||
sharedLists,
|
||||
newListsToday,
|
||||
newListsThisWeek,
|
||||
|
||||
// Ürün istatistikleri
|
||||
totalProducts,
|
||||
activeProducts,
|
||||
newProductsToday,
|
||||
newProductsThisWeek,
|
||||
|
||||
// Kategori istatistikleri
|
||||
totalCategories,
|
||||
activeCategories,
|
||||
|
||||
// Bildirim istatistikleri
|
||||
totalNotifications,
|
||||
unreadNotifications,
|
||||
notificationsToday,
|
||||
|
||||
// Aktivite istatistikleri
|
||||
activitiesToday,
|
||||
activitiesThisWeek
|
||||
] = await Promise.all([
|
||||
// Kullanıcılar
|
||||
req.prisma.user.count(),
|
||||
req.prisma.user.count({ where: { isActive: true } }),
|
||||
req.prisma.user.count({ where: { createdAt: { gte: startOfDay } } }),
|
||||
req.prisma.user.count({ where: { createdAt: { gte: startOfWeek } } }),
|
||||
req.prisma.user.count({ where: { createdAt: { gte: startOfMonth } } }),
|
||||
|
||||
// Listeler
|
||||
req.prisma.shoppingList.count(),
|
||||
req.prisma.shoppingList.count({ where: { isActive: true } }),
|
||||
req.prisma.shoppingList.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
members: {
|
||||
some: {
|
||||
role: 'member'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
req.prisma.shoppingList.count({ where: { createdAt: { gte: startOfDay } } }),
|
||||
req.prisma.shoppingList.count({ where: { createdAt: { gte: startOfWeek } } }),
|
||||
|
||||
// Ürünler
|
||||
req.prisma.product.count(),
|
||||
req.prisma.product.count({ where: { isActive: true } }),
|
||||
req.prisma.product.count({ where: { createdAt: { gte: startOfDay } } }),
|
||||
req.prisma.product.count({ where: { createdAt: { gte: startOfWeek } } }),
|
||||
|
||||
// Kategoriler
|
||||
req.prisma.category.count(),
|
||||
req.prisma.category.count({ where: { isActive: true } }),
|
||||
|
||||
// Bildirimler
|
||||
req.prisma.notification.count(),
|
||||
req.prisma.notification.count({ where: { isRead: false } }),
|
||||
req.prisma.notification.count({ where: { createdAt: { gte: startOfDay } } }),
|
||||
|
||||
// Aktiviteler
|
||||
req.prisma.activity.count({ where: { createdAt: { gte: startOfDay } } }),
|
||||
req.prisma.activity.count({ where: { createdAt: { gte: startOfWeek } } })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
users: {
|
||||
total: totalUsers,
|
||||
active: activeUsers,
|
||||
inactive: totalUsers - activeUsers,
|
||||
newToday: newUsersToday,
|
||||
newThisWeek: newUsersThisWeek,
|
||||
newThisMonth: newUsersThisMonth
|
||||
},
|
||||
lists: {
|
||||
total: totalLists,
|
||||
active: activeLists,
|
||||
inactive: totalLists - activeLists,
|
||||
shared: sharedLists,
|
||||
newToday: newListsToday,
|
||||
newThisWeek: newListsThisWeek
|
||||
},
|
||||
products: {
|
||||
total: totalProducts,
|
||||
active: activeProducts,
|
||||
inactive: totalProducts - activeProducts,
|
||||
newToday: newProductsToday,
|
||||
newThisWeek: newProductsThisWeek
|
||||
},
|
||||
categories: {
|
||||
total: totalCategories,
|
||||
active: activeCategories,
|
||||
inactive: totalCategories - activeCategories
|
||||
},
|
||||
notifications: {
|
||||
total: totalNotifications,
|
||||
unread: unreadNotifications,
|
||||
read: totalNotifications - unreadNotifications,
|
||||
today: notificationsToday
|
||||
},
|
||||
activities: {
|
||||
today: activitiesToday,
|
||||
thisWeek: activitiesThisWeek
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Son aktiviteleri getir
|
||||
* GET /api/admin/activities
|
||||
*/
|
||||
router.get('/activities', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
query('page').optional().isInt({ min: 1 }).withMessage('Sayfa numarası pozitif bir sayı olmalı'),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }).withMessage('Limit 1-100 arasında olmalı'),
|
||||
query('type').optional().isIn(['list_created', 'list_updated', 'list_deleted', 'item_added', 'item_updated', 'item_deleted', 'item_purchased', 'member_added', 'member_removed', 'user_registered', 'user_login']).withMessage('Geçerli bir aktivite türü seçin'),
|
||||
query('userId').optional().isUUID().withMessage('Geçerli bir kullanıcı ID\'si girin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
limit = 50,
|
||||
type,
|
||||
userId
|
||||
} = req.query;
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Filtreleme koşulları
|
||||
const whereCondition = {};
|
||||
|
||||
if (type) {
|
||||
whereCondition.type = type;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
whereCondition.userId = userId;
|
||||
}
|
||||
|
||||
const [activities, totalCount] = await Promise.all([
|
||||
req.prisma.activity.findMany({
|
||||
where: whereCondition,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
},
|
||||
list: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
skip: parseInt(skip),
|
||||
take: parseInt(limit)
|
||||
}),
|
||||
req.prisma.activity.count({ where: whereCondition })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
activities,
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
totalCount,
|
||||
hasNext: skip + parseInt(limit) < totalCount,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Sistem ayarlarını getir
|
||||
* GET /api/admin/settings
|
||||
*/
|
||||
router.get('/settings', [
|
||||
authenticateToken,
|
||||
requireAdmin
|
||||
], asyncHandler(async (req, res) => {
|
||||
const settings = await req.prisma.setting.findMany({
|
||||
where: {
|
||||
userId: null // Sistem ayarları
|
||||
},
|
||||
orderBy: {
|
||||
key: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
// Ayarları obje formatına çevir
|
||||
const settingsObject = {};
|
||||
settings.forEach(setting => {
|
||||
settingsObject[setting.key] = setting.value;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { settings: settingsObject }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Sistem ayarlarını güncelle
|
||||
* PUT /api/admin/settings
|
||||
*/
|
||||
router.put('/settings', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
body('settings').isObject().withMessage('Ayarlar obje formatında olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { settings } = req.body;
|
||||
|
||||
// Geçerli sistem ayar anahtarları
|
||||
const validKeys = [
|
||||
'app_name',
|
||||
'app_version',
|
||||
'maintenance_mode',
|
||||
'registration_enabled',
|
||||
'max_lists_per_user',
|
||||
'max_items_per_list',
|
||||
'max_file_size',
|
||||
'allowed_file_types',
|
||||
'email_notifications_enabled',
|
||||
'push_notifications_enabled',
|
||||
'backup_enabled',
|
||||
'backup_frequency',
|
||||
'log_level',
|
||||
'session_timeout',
|
||||
'password_min_length',
|
||||
'password_require_special_chars'
|
||||
];
|
||||
|
||||
// Geçersiz anahtarları filtrele
|
||||
const validSettings = {};
|
||||
Object.keys(settings).forEach(key => {
|
||||
if (validKeys.includes(key)) {
|
||||
validSettings[key] = settings[key].toString();
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(validSettings).length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Geçerli ayar bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Ayarları güncelle veya oluştur
|
||||
const updatePromises = Object.entries(validSettings).map(([key, value]) =>
|
||||
req.prisma.setting.upsert({
|
||||
where: {
|
||||
userId_key: {
|
||||
userId: null,
|
||||
key
|
||||
}
|
||||
},
|
||||
update: { value },
|
||||
create: {
|
||||
userId: null,
|
||||
key,
|
||||
value
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Sistem ayarları güncellendi',
|
||||
data: { settings: validSettings }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Sistem durumu kontrolü
|
||||
* GET /api/admin/system-status
|
||||
*/
|
||||
router.get('/system-status', [
|
||||
authenticateToken,
|
||||
requireAdmin
|
||||
], asyncHandler(async (req, res) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Veritabanı bağlantısını test et
|
||||
await req.prisma.$queryRaw`SELECT 1`;
|
||||
const dbResponseTime = Date.now() - startTime;
|
||||
|
||||
// Sistem bilgileri
|
||||
const systemInfo = {
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
uptime: process.uptime(),
|
||||
memoryUsage: process.memoryUsage(),
|
||||
cpuUsage: process.cpuUsage()
|
||||
};
|
||||
|
||||
// Veritabanı istatistikleri
|
||||
const [
|
||||
userCount,
|
||||
listCount,
|
||||
productCount,
|
||||
notificationCount
|
||||
] = await Promise.all([
|
||||
req.prisma.user.count(),
|
||||
req.prisma.shoppingList.count(),
|
||||
req.prisma.product.count(),
|
||||
req.prisma.notification.count()
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
database: {
|
||||
status: 'connected',
|
||||
responseTime: `${dbResponseTime}ms`
|
||||
},
|
||||
system: systemInfo,
|
||||
statistics: {
|
||||
users: userCount,
|
||||
lists: listCount,
|
||||
products: productCount,
|
||||
notifications: notificationCount
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Sistem durumu kontrolünde hata oluştu',
|
||||
data: {
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* Veritabanı yedekleme (Placeholder)
|
||||
* POST /api/admin/backup
|
||||
*/
|
||||
router.post('/backup', [
|
||||
authenticateToken,
|
||||
requireAdmin
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Bu endpoint gerçek bir yedekleme işlemi yapmaz
|
||||
// Üretim ortamında uygun yedekleme araçları kullanılmalıdır
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Yedekleme işlemi başlatıldı',
|
||||
data: {
|
||||
backupId: `backup_${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
note: 'Bu bir placeholder endpoint\'tir. Gerçek yedekleme işlemi için uygun araçlar kullanılmalıdır.'
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Sistem loglarını getir (Placeholder)
|
||||
* GET /api/admin/logs
|
||||
*/
|
||||
router.get('/logs', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
query('level').optional().isIn(['error', 'warn', 'info', 'debug']).withMessage('Geçerli bir log seviyesi seçin'),
|
||||
query('limit').optional().isInt({ min: 1, max: 1000 }).withMessage('Limit 1-1000 arasında olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { level = 'info', limit = 100 } = req.query;
|
||||
|
||||
// Bu endpoint gerçek log dosyalarını okumaz
|
||||
// Üretim ortamında uygun log yönetim sistemi kullanılmalıdır
|
||||
|
||||
const mockLogs = [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: 'Server started successfully',
|
||||
source: 'server.js'
|
||||
},
|
||||
{
|
||||
timestamp: new Date(Date.now() - 60000).toISOString(),
|
||||
level: 'info',
|
||||
message: 'Database connection established',
|
||||
source: 'database.js'
|
||||
},
|
||||
{
|
||||
timestamp: new Date(Date.now() - 120000).toISOString(),
|
||||
level: 'warn',
|
||||
message: 'High memory usage detected',
|
||||
source: 'monitor.js'
|
||||
}
|
||||
];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
logs: mockLogs.slice(0, parseInt(limit)),
|
||||
note: 'Bu mock log verileridir. Gerçek log sistemi için uygun araçlar kullanılmalıdır.'
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kullanıcı aktivite raporu
|
||||
* GET /api/admin/reports/user-activity
|
||||
*/
|
||||
router.get('/reports/user-activity', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
query('startDate').optional().isISO8601().withMessage('Geçerli bir başlangıç tarihi girin'),
|
||||
query('endDate').optional().isISO8601().withMessage('Geçerli bir bitiş tarihi girin'),
|
||||
query('userId').optional().isUUID().withMessage('Geçerli bir kullanıcı ID\'si girin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), // Son 30 gün
|
||||
endDate = new Date().toISOString(),
|
||||
userId
|
||||
} = req.query;
|
||||
|
||||
const whereCondition = {
|
||||
createdAt: {
|
||||
gte: new Date(startDate),
|
||||
lte: new Date(endDate)
|
||||
}
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
whereCondition.userId = userId;
|
||||
}
|
||||
|
||||
const [
|
||||
activities,
|
||||
activityCounts,
|
||||
userStats
|
||||
] = await Promise.all([
|
||||
req.prisma.activity.findMany({
|
||||
where: whereCondition,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: 100
|
||||
}),
|
||||
req.prisma.activity.groupBy({
|
||||
by: ['type'],
|
||||
where: whereCondition,
|
||||
_count: {
|
||||
id: true
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: 'desc'
|
||||
}
|
||||
}
|
||||
}),
|
||||
userId ? req.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
ownedLists: { where: { isActive: true } },
|
||||
sharedLists: true,
|
||||
activities: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: new Date(startDate),
|
||||
lte: new Date(endDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}) : null
|
||||
]);
|
||||
|
||||
const activityTypeStats = {};
|
||||
activityCounts.forEach(item => {
|
||||
activityTypeStats[item.type] = item._count.id;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
period: {
|
||||
startDate,
|
||||
endDate
|
||||
},
|
||||
user: userStats,
|
||||
activities: activities,
|
||||
statistics: {
|
||||
totalActivities: activities.length,
|
||||
byType: activityTypeStats
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
420
backend/src/routes/auth.js
Normal file
420
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,420 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { asyncHandler, formatValidationErrors } = require('../middleware/errorHandler');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const passport = require('../config/passport');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* JWT token oluştur
|
||||
*/
|
||||
const generateToken = (userId) => {
|
||||
return jwt.sign(
|
||||
{ userId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Kullanıcı kaydı
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
router.post('/register', [
|
||||
body('email')
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('Geçerli bir e-posta adresi girin'),
|
||||
body('firstName')
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Ad 2-50 karakter arasında olmalı'),
|
||||
body('lastName')
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Soyad 2-50 karakter arasında olmalı'),
|
||||
body('password')
|
||||
.isLength({ min: 6 })
|
||||
.withMessage('Şifre en az 6 karakter olmalı'),
|
||||
body('confirmPassword')
|
||||
.custom((value, { req }) => {
|
||||
if (value !== req.body.password) {
|
||||
throw new Error('Şifreler eşleşmiyor');
|
||||
}
|
||||
return true;
|
||||
})
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { email, firstName, lastName, password } = req.body;
|
||||
|
||||
// E-posta benzersizlik kontrolü
|
||||
const existingUser = await req.prisma.user.findFirst({
|
||||
where: {
|
||||
email: email
|
||||
}
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'E-posta zaten kullanımda'
|
||||
});
|
||||
}
|
||||
|
||||
// Email'den username oluştur (@ işaretinden önceki kısım)
|
||||
const username = email.split('@')[0];
|
||||
|
||||
// Username benzersizlik kontrolü
|
||||
const existingUsername = await req.prisma.user.findFirst({
|
||||
where: {
|
||||
username: username
|
||||
}
|
||||
});
|
||||
|
||||
if (existingUsername) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Bu kullanıcı adı zaten kullanımda'
|
||||
});
|
||||
}
|
||||
|
||||
// Şifreyi hashle
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Kullanıcıyı oluştur
|
||||
const user = await req.prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
firstName,
|
||||
lastName,
|
||||
password: hashedPassword
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true,
|
||||
isAdmin: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
// JWT token oluştur
|
||||
const token = generateToken(user.id);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Kullanıcı başarıyla oluşturuldu',
|
||||
data: {
|
||||
user,
|
||||
token
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kullanıcı girişi
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
router.post('/login', [
|
||||
body('login')
|
||||
.notEmpty()
|
||||
.withMessage('E-posta veya kullanıcı adı gerekli'),
|
||||
body('password')
|
||||
.notEmpty()
|
||||
.withMessage('Şifre gerekli')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { login, password } = req.body;
|
||||
|
||||
// Kullanıcıyı bul (e-posta veya kullanıcı adı ile)
|
||||
const user = await req.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: login },
|
||||
{ username: login }
|
||||
],
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Geçersiz giriş bilgileri'
|
||||
});
|
||||
}
|
||||
|
||||
// Şifreyi kontrol et
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Geçersiz giriş bilgileri'
|
||||
});
|
||||
}
|
||||
|
||||
// Son giriş tarihini güncelle
|
||||
await req.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() }
|
||||
});
|
||||
|
||||
// JWT token oluştur
|
||||
const token = generateToken(user.id);
|
||||
|
||||
// Şifreyi yanıttan çıkar
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Giriş başarılı',
|
||||
data: {
|
||||
user: userWithoutPassword,
|
||||
token
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Profil bilgilerini getir
|
||||
* GET /api/auth/profile
|
||||
*/
|
||||
router.get('/profile', authenticateToken, asyncHandler(async (req, res) => {
|
||||
const user = await req.prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true,
|
||||
isAdmin: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
ownedLists: true,
|
||||
sharedLists: true,
|
||||
notifications: {
|
||||
where: { isRead: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { user }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Profil güncelleme
|
||||
* PUT /api/auth/profile
|
||||
*/
|
||||
router.put('/profile', authenticateToken, [
|
||||
body('firstName')
|
||||
.optional()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Ad 2-50 karakter arasında olmalı'),
|
||||
body('lastName')
|
||||
.optional()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Soyad 2-50 karakter arasında olmalı'),
|
||||
body('username')
|
||||
.optional()
|
||||
.isLength({ min: 3, max: 20 })
|
||||
.matches(/^[a-zA-Z0-9_]+$/)
|
||||
.withMessage('Kullanıcı adı 3-20 karakter olmalı ve sadece harf, rakam ve alt çizgi içermeli')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { firstName, lastName, username } = req.body;
|
||||
const updateData = {};
|
||||
|
||||
if (firstName) updateData.firstName = firstName;
|
||||
if (lastName) updateData.lastName = lastName;
|
||||
if (username) {
|
||||
// Kullanıcı adı benzersizlik kontrolü
|
||||
const existingUser = await req.prisma.user.findFirst({
|
||||
where: {
|
||||
username: username,
|
||||
id: { not: req.user.id }
|
||||
}
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Bu kullanıcı adı zaten kullanımda'
|
||||
});
|
||||
}
|
||||
|
||||
updateData.username = username;
|
||||
}
|
||||
|
||||
const updatedUser = await req.prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true,
|
||||
isAdmin: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Profil başarıyla güncellendi',
|
||||
data: { user: updatedUser }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Şifre değiştirme
|
||||
* PUT /api/auth/change-password
|
||||
*/
|
||||
router.put('/change-password', authenticateToken, [
|
||||
body('currentPassword')
|
||||
.notEmpty()
|
||||
.withMessage('Mevcut şifre gerekli'),
|
||||
body('newPassword')
|
||||
.isLength({ min: 6 })
|
||||
.withMessage('Yeni şifre en az 6 karakter olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
// Mevcut kullanıcıyı şifresi ile birlikte getir
|
||||
const user = await req.prisma.user.findUnique({
|
||||
where: { id: req.user.id }
|
||||
});
|
||||
|
||||
// Mevcut şifreyi kontrol et
|
||||
const isCurrentPasswordValid = await bcrypt.compare(currentPassword, user.password);
|
||||
if (!isCurrentPasswordValid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Mevcut şifre yanlış'
|
||||
});
|
||||
}
|
||||
|
||||
// Yeni şifreyi hashle
|
||||
const hashedNewPassword = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Şifreyi güncelle
|
||||
await req.prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { password: hashedNewPassword }
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Şifre başarıyla değiştirildi'
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Token doğrulama
|
||||
* POST /api/auth/verify-token
|
||||
*/
|
||||
router.post('/verify-token', authenticateToken, (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Token geçerli',
|
||||
data: { user: req.user }
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Google OAuth başlat
|
||||
* GET /api/auth/google
|
||||
*/
|
||||
router.get('/google',
|
||||
passport.authenticate('google', {
|
||||
scope: ['profile', 'email']
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Google OAuth callback
|
||||
* GET /api/auth/google/callback
|
||||
*/
|
||||
router.get('/google/callback',
|
||||
passport.authenticate('google', {
|
||||
failureRedirect: `${process.env.FRONTEND_URL}/login?error=oauth_failed`,
|
||||
session: false
|
||||
}),
|
||||
async (req, res) => {
|
||||
try {
|
||||
// Token oluştur
|
||||
const token = generateToken(req.user.id);
|
||||
|
||||
// Frontend'e token ile redirect et
|
||||
res.redirect(`${process.env.FRONTEND_URL}/login?token=${token}&success=true`);
|
||||
} catch (error) {
|
||||
console.error('Google callback error:', error);
|
||||
res.redirect(`${process.env.FRONTEND_URL}/login?error=callback_failed`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Çıkış yap
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
router.post('/logout', authenticateToken, (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Başarıyla çıkış yapıldı'
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
602
backend/src/routes/categories.js
Normal file
602
backend/src/routes/categories.js
Normal file
@@ -0,0 +1,602 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult, param, query } = require('express-validator');
|
||||
const { asyncHandler, formatValidationErrors } = require('../middleware/errorHandler');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Tüm kategorileri listele
|
||||
* GET /api/categories
|
||||
*/
|
||||
router.get('/', [
|
||||
authenticateToken,
|
||||
query('includeInactive').optional().isBoolean().withMessage('includeInactive boolean değer olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { includeInactive = false } = req.query;
|
||||
|
||||
const whereCondition = {};
|
||||
|
||||
// Admin değilse sadece aktif kategorileri göster
|
||||
if (!req.user.isAdmin || !includeInactive) {
|
||||
whereCondition.isActive = true;
|
||||
}
|
||||
|
||||
const categories = await req.prisma.category.findMany({
|
||||
where: whereCondition,
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
products: {
|
||||
where: { isActive: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
// Ürün sayısını ekle
|
||||
const categoriesWithCount = categories.map(category => ({
|
||||
...category,
|
||||
productCount: category._count.products,
|
||||
_count: undefined
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { categories: categoriesWithCount }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kategori detayı getir
|
||||
* GET /api/categories/:categoryId
|
||||
*/
|
||||
router.get('/:categoryId', [
|
||||
authenticateToken,
|
||||
param('categoryId').isUUID().withMessage('Geçerli bir kategori ID\'si girin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { categoryId } = req.params;
|
||||
|
||||
const whereCondition = { id: categoryId };
|
||||
|
||||
// Admin değilse sadece aktif kategorileri göster
|
||||
if (!req.user.isAdmin) {
|
||||
whereCondition.isActive = true;
|
||||
}
|
||||
|
||||
const category = await req.prisma.category.findUnique({
|
||||
where: whereCondition,
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
products: {
|
||||
where: { isActive: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kategori bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
category: {
|
||||
...category,
|
||||
productCount: category._count.products,
|
||||
_count: undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kategoriye ait ürünleri listele
|
||||
* GET /api/categories/:categoryId/products
|
||||
*/
|
||||
router.get('/:categoryId/products', [
|
||||
authenticateToken,
|
||||
param('categoryId').isUUID().withMessage('Geçerli bir kategori ID\'si girin'),
|
||||
query('page').optional().isInt({ min: 1 }).withMessage('Sayfa numarası pozitif bir sayı olmalı'),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }).withMessage('Limit 1-100 arasında olmalı'),
|
||||
query('search').optional().isLength({ min: 2 }).withMessage('Arama terimi en az 2 karakter olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { categoryId } = req.params;
|
||||
const { page = 1, limit = 20, search } = req.query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Kategori kontrolü
|
||||
const categoryWhereCondition = { id: categoryId };
|
||||
if (!req.user.isAdmin) {
|
||||
categoryWhereCondition.isActive = true;
|
||||
}
|
||||
|
||||
const category = await req.prisma.category.findUnique({
|
||||
where: categoryWhereCondition
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kategori bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Ürün filtreleme koşulları
|
||||
const whereCondition = {
|
||||
categoryId,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
if (search) {
|
||||
whereCondition.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ barcode: { contains: search, mode: 'insensitive' } }
|
||||
];
|
||||
}
|
||||
|
||||
const [products, totalCount] = await Promise.all([
|
||||
req.prisma.product.findMany({
|
||||
where: whereCondition,
|
||||
include: {
|
||||
priceHistory: {
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: 1,
|
||||
select: {
|
||||
price: true,
|
||||
createdAt: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
listItems: {
|
||||
where: {
|
||||
list: {
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
skip: parseInt(skip),
|
||||
take: parseInt(limit)
|
||||
}),
|
||||
req.prisma.product.count({ where: whereCondition })
|
||||
]);
|
||||
|
||||
// Son fiyatları ekle
|
||||
const productsWithPrice = products.map(product => ({
|
||||
...product,
|
||||
currentPrice: product.priceHistory[0]?.price || null,
|
||||
lastPriceUpdate: product.priceHistory[0]?.createdAt || null,
|
||||
usageCount: product._count.listItems,
|
||||
priceHistory: undefined,
|
||||
_count: undefined
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
category: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
color: category.color
|
||||
},
|
||||
products: productsWithPrice,
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
totalCount,
|
||||
hasNext: skip + parseInt(limit) < totalCount,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Yeni kategori oluştur (Admin)
|
||||
* POST /api/categories
|
||||
*/
|
||||
router.post('/', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
body('name')
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Kategori adı 2-50 karakter arasında olmalı'),
|
||||
body('color')
|
||||
.matches(/^#[0-9A-F]{6}$/i)
|
||||
.withMessage('Geçerli bir hex renk kodu girin (örn: #FF5722)'),
|
||||
body('description')
|
||||
.optional({ checkFalsy: true })
|
||||
.trim()
|
||||
.isLength({ max: 200 })
|
||||
.withMessage('Açıklama en fazla 200 karakter olmalı')
|
||||
, body('icon')
|
||||
.optional({ checkFalsy: true })
|
||||
.isString()
|
||||
.isLength({ max: 50 })
|
||||
.withMessage('Icon en fazla 50 karakter olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { name, color, description, icon } = req.body;
|
||||
|
||||
// Kategori adı benzersizlik kontrolü
|
||||
const existingCategory = await req.prisma.category.findFirst({
|
||||
where: {
|
||||
name: name,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (existingCategory) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Bu isimde bir kategori zaten mevcut'
|
||||
});
|
||||
}
|
||||
|
||||
const category = await req.prisma.category.create({
|
||||
data: {
|
||||
name,
|
||||
color,
|
||||
description: description || null,
|
||||
icon: icon || null
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Kategori başarıyla oluşturuldu',
|
||||
data: {
|
||||
category: {
|
||||
...category,
|
||||
productCount: 0
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kategori güncelle (Admin)
|
||||
* PUT /api/categories/:categoryId
|
||||
*/
|
||||
router.put('/:categoryId', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
// Prisma'da ID'ler CUID formatında String; bu yüzden UUID doğrulaması kaldırıldı
|
||||
param('categoryId').isString().withMessage('Geçerli bir kategori ID\'si girin'),
|
||||
body('name')
|
||||
.optional({ checkFalsy: true })
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Kategori adı 2-50 karakter arasında olmalı'),
|
||||
body('color')
|
||||
.optional({ checkFalsy: true })
|
||||
.matches(/^#[0-9A-F]{6}$/i)
|
||||
.withMessage('Geçerli bir hex renk kodu girin (örn: #FF5722)'),
|
||||
body('description')
|
||||
.optional({ checkFalsy: true })
|
||||
.trim()
|
||||
.isLength({ max: 200 })
|
||||
.withMessage('Açıklama en fazla 200 karakter olmalı'),
|
||||
body('icon')
|
||||
.optional({ checkFalsy: true })
|
||||
.isString()
|
||||
.isLength({ max: 50 })
|
||||
.withMessage('Icon en fazla 50 karakter olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { categoryId } = req.params;
|
||||
const { name, color, description, icon } = req.body;
|
||||
|
||||
// Kategori kontrolü
|
||||
const existingCategory = await req.prisma.category.findUnique({
|
||||
where: { id: categoryId }
|
||||
});
|
||||
|
||||
if (!existingCategory) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kategori bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Kategori adı benzersizlik kontrolü
|
||||
if (name && name !== existingCategory.name) {
|
||||
const duplicateCategory = await req.prisma.category.findFirst({
|
||||
where: {
|
||||
name: {
|
||||
equals: name,
|
||||
mode: 'insensitive'
|
||||
},
|
||||
isActive: true,
|
||||
id: { not: categoryId }
|
||||
}
|
||||
});
|
||||
|
||||
if (duplicateCategory) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Bu isimde bir kategori zaten mevcut'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
if (name) updateData.name = name;
|
||||
if (color) updateData.color = color;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (icon !== undefined) updateData.icon = icon || null;
|
||||
|
||||
const updatedCategory = await req.prisma.category.update({
|
||||
where: { id: categoryId },
|
||||
data: updateData,
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
products: {
|
||||
where: { isActive: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Kategori başarıyla güncellendi',
|
||||
data: {
|
||||
category: {
|
||||
...updatedCategory,
|
||||
productCount: updatedCategory._count.products,
|
||||
_count: undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kategori durumunu güncelle (Admin)
|
||||
* PUT /api/categories/:categoryId/status
|
||||
*/
|
||||
router.put('/:categoryId/status', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
// CUID string ID desteği
|
||||
param('categoryId').isString().withMessage('Geçerli bir kategori ID\'si girin'),
|
||||
body('isActive').isBoolean().withMessage('Durum boolean değer olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { categoryId } = req.params;
|
||||
const { isActive } = req.body;
|
||||
|
||||
const category = await req.prisma.category.findUnique({
|
||||
where: { id: categoryId },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
products: {
|
||||
where: { isActive: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kategori bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Eğer kategori deaktive ediliyorsa ve ürünleri varsa uyarı ver
|
||||
if (!isActive && category._count.products > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Bu kategoride ${category._count.products} aktif ürün bulunuyor. Önce ürünleri başka kategorilere taşıyın veya silin.`
|
||||
});
|
||||
}
|
||||
|
||||
const updatedCategory = await req.prisma.category.update({
|
||||
where: { id: categoryId },
|
||||
data: { isActive },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
products: {
|
||||
where: { isActive: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Kategori ${isActive ? 'aktif' : 'pasif'} hale getirildi`,
|
||||
data: {
|
||||
category: {
|
||||
...updatedCategory,
|
||||
productCount: updatedCategory._count.products,
|
||||
_count: undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kategori sil (Admin)
|
||||
* DELETE /api/categories/:categoryId
|
||||
*/
|
||||
router.delete('/:categoryId', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
param('categoryId').isUUID().withMessage('Geçerli bir kategori ID\'si girin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { categoryId } = req.params;
|
||||
|
||||
const category = await req.prisma.category.findUnique({
|
||||
where: { id: categoryId },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
products: {
|
||||
where: { isActive: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kategori bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Eğer kategoride ürünler varsa silinmesine izin verme
|
||||
if (category._count.products > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Bu kategoride ${category._count.products} aktif ürün bulunuyor. Önce ürünleri başka kategorilere taşıyın veya silin.`
|
||||
});
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
await req.prisma.category.update({
|
||||
where: { id: categoryId },
|
||||
data: { isActive: false }
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Kategori başarıyla silindi'
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kategori istatistikleri (Admin)
|
||||
* GET /api/categories/stats/overview
|
||||
*/
|
||||
router.get('/stats/overview', [
|
||||
authenticateToken,
|
||||
requireAdmin
|
||||
], asyncHandler(async (req, res) => {
|
||||
const [
|
||||
totalCategories,
|
||||
activeCategories,
|
||||
categoriesWithProducts,
|
||||
mostUsedCategories
|
||||
] = await Promise.all([
|
||||
req.prisma.category.count(),
|
||||
req.prisma.category.count({ where: { isActive: true } }),
|
||||
req.prisma.category.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
products: {
|
||||
some: {
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
req.prisma.category.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
products: {
|
||||
where: { isActive: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
products: {
|
||||
_count: 'desc'
|
||||
}
|
||||
},
|
||||
take: 5
|
||||
})
|
||||
]);
|
||||
|
||||
const mostUsedCategoriesFormatted = mostUsedCategories.map(category => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
color: category.color,
|
||||
productCount: category._count.products
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
categories: {
|
||||
total: totalCategories,
|
||||
active: activeCategories,
|
||||
inactive: totalCategories - activeCategories,
|
||||
withProducts: categoriesWithProducts,
|
||||
empty: activeCategories - categoriesWithProducts
|
||||
},
|
||||
mostUsed: mostUsedCategoriesFormatted
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
154
backend/src/routes/dashboard.js
Normal file
154
backend/src/routes/dashboard.js
Normal file
@@ -0,0 +1,154 @@
|
||||
const express = require('express');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Dashboard istatistikleri
|
||||
* GET /api/dashboard/stats
|
||||
*/
|
||||
router.get('/stats', [
|
||||
authenticateToken
|
||||
], asyncHandler(async (req, res) => {
|
||||
const userId = req.user.id;
|
||||
|
||||
// Kullanıcının listelerini al
|
||||
const userLists = await prisma.shoppingList.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId: userId
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
isActive: true
|
||||
},
|
||||
include: {
|
||||
items: true
|
||||
}
|
||||
});
|
||||
|
||||
// İstatistikleri hesapla
|
||||
const totalLists = userLists.length;
|
||||
const totalItems = userLists.reduce((sum, list) => sum + list.items.length, 0);
|
||||
const completedItems = userLists.reduce((sum, list) =>
|
||||
sum + list.items.filter(item => item.isPurchased).length, 0
|
||||
);
|
||||
const pendingItems = totalItems - completedItems;
|
||||
|
||||
// Bu ayki harcama
|
||||
const currentMonth = new Date();
|
||||
currentMonth.setDate(1);
|
||||
currentMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const monthlySpending = await prisma.listItem.aggregate({
|
||||
where: {
|
||||
isPurchased: true,
|
||||
purchasedAt: {
|
||||
gte: currentMonth
|
||||
},
|
||||
list: {
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId: userId
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
isActive: true
|
||||
}
|
||||
},
|
||||
_sum: {
|
||||
price: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalLists,
|
||||
totalItems,
|
||||
completedItems,
|
||||
pendingItems,
|
||||
monthlySpending: monthlySpending._sum.price || 0,
|
||||
completionRate: totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Son aktiviteler
|
||||
* GET /api/dashboard/activity
|
||||
*/
|
||||
router.get('/activity', [
|
||||
authenticateToken
|
||||
], asyncHandler(async (req, res) => {
|
||||
const userId = req.user.id;
|
||||
const { limit = 10 } = req.query;
|
||||
|
||||
// Son eklenen ürünler
|
||||
const recentItems = await prisma.listItem.findMany({
|
||||
where: {
|
||||
list: {
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId: userId
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
isActive: true
|
||||
}
|
||||
},
|
||||
include: {
|
||||
list: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
product: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: parseInt(limit)
|
||||
});
|
||||
|
||||
// Aktiviteleri formatla
|
||||
const activities = recentItems.map(item => ({
|
||||
id: item.id,
|
||||
type: item.isPurchased ? 'item_purchased' : 'item_added',
|
||||
message: item.isPurchased
|
||||
? `${item.product?.name || item.name} satın alındı`
|
||||
: `${item.product?.name || item.name} listeye eklendi`,
|
||||
listName: item.list.name,
|
||||
listId: item.list.id,
|
||||
createdAt: item.isPurchased ? item.purchasedAt : item.createdAt
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { activities }
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
653
backend/src/routes/items.js
Normal file
653
backend/src/routes/items.js
Normal file
@@ -0,0 +1,653 @@
|
||||
const express = require('express');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { authenticateToken, checkListMembership, requireListEditPermission } = require('../middleware/auth');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
const {
|
||||
validateListItemCreation,
|
||||
validateListItemUpdate,
|
||||
validateUUIDParam,
|
||||
validateCuidParam,
|
||||
validateCuid,
|
||||
validatePagination
|
||||
} = require('../utils/validators');
|
||||
const { validationResult, param } = require('express-validator');
|
||||
const { successResponse, errorResponse, calculatePagination, createPaginationMeta } = require('../utils/helpers');
|
||||
const notificationService = require('../services/notificationService');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Liste öğelerini getir
|
||||
* GET /api/items/:listId
|
||||
*/
|
||||
router.get('/:listId',
|
||||
authenticateToken,
|
||||
param('listId').custom(validateCuid).withMessage('Geçerli bir liste ID\'si girin'),
|
||||
validatePagination,
|
||||
checkListMembership,
|
||||
asyncHandler(async (req, res) => {
|
||||
console.log('🔍 Items API called with params:', req.params);
|
||||
console.log('🔍 Items API called with query:', req.query);
|
||||
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.log('❌ Validation errors:', errors.array());
|
||||
return res.status(400).json(errorResponse('Doğrulama hatası', errors.array()));
|
||||
}
|
||||
|
||||
const { listId } = req.params;
|
||||
const { page = 1, limit = 50, category, purchased, search } = req.query;
|
||||
const { skip, take } = calculatePagination(page, limit);
|
||||
|
||||
// Filtreleme koşulları
|
||||
const where = {
|
||||
listId
|
||||
};
|
||||
|
||||
if (category) {
|
||||
where.product = {
|
||||
categoryId: category
|
||||
};
|
||||
}
|
||||
|
||||
if (purchased !== undefined) {
|
||||
where.isPurchased = purchased === 'true';
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ notes: { contains: search, mode: 'insensitive' } },
|
||||
{ product: { name: { contains: search, mode: 'insensitive' } } }
|
||||
];
|
||||
}
|
||||
|
||||
console.log('🔍 Database where conditions:', JSON.stringify(where, null, 2));
|
||||
console.log('🔍 Pagination - skip:', skip, 'take:', take);
|
||||
|
||||
// Toplam sayı ve öğeleri getir
|
||||
const [total, items] = await Promise.all([
|
||||
prisma.listItem.count({ where }),
|
||||
prisma.listItem.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
category: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ isPurchased: 'asc' },
|
||||
{ createdAt: 'desc' }
|
||||
]
|
||||
})
|
||||
]);
|
||||
|
||||
const meta = createPaginationMeta(total, parseInt(page), parseInt(limit));
|
||||
|
||||
// Priority değerlerini string'e çevir
|
||||
const itemsWithStringPriority = items.map(item => ({
|
||||
...item,
|
||||
priority: item.priority === 0 ? 'LOW' : item.priority === 1 ? 'MEDIUM' : 'HIGH'
|
||||
}));
|
||||
|
||||
res.json(successResponse('Liste öğeleri başarıyla getirildi', itemsWithStringPriority, meta));
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Liste öğesi detayını getir
|
||||
* GET /api/items/:listId/:itemId
|
||||
*/
|
||||
router.get('/:listId/:itemId',
|
||||
authenticateToken,
|
||||
param('listId').custom(validateCuid).withMessage('Geçerli bir liste ID\'si girin'),
|
||||
param('itemId').custom(validateCuid).withMessage('Geçerli bir öğe ID\'si girin'),
|
||||
checkListMembership,
|
||||
asyncHandler(async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json(errorResponse('Doğrulama hatası', errors.array()));
|
||||
}
|
||||
|
||||
const { listId, itemId } = req.params;
|
||||
|
||||
const item = await prisma.listItem.findFirst({
|
||||
where: {
|
||||
id: itemId,
|
||||
listId
|
||||
},
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
category: true,
|
||||
priceHistory: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
addedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json(errorResponse('Liste öğesi bulunamadı'));
|
||||
}
|
||||
|
||||
// Priority değerini string'e çevir
|
||||
const itemWithStringPriority = {
|
||||
...item,
|
||||
priority: item.priority === 0 ? 'LOW' : item.priority === 1 ? 'MEDIUM' : 'HIGH'
|
||||
};
|
||||
|
||||
res.json(successResponse('Liste öğesi detayı başarıyla getirildi', itemWithStringPriority));
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Listeye öğe ekle
|
||||
* POST /api/items/:listId
|
||||
*/
|
||||
router.post('/:listId',
|
||||
authenticateToken,
|
||||
param('listId').custom(validateCuid).withMessage('Geçerli bir liste ID\'si girin'),
|
||||
validateListItemCreation,
|
||||
checkListMembership,
|
||||
requireListEditPermission,
|
||||
asyncHandler(async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json(errorResponse('Doğrulama hatası', errors.array()));
|
||||
}
|
||||
|
||||
const { listId } = req.params;
|
||||
const { name, quantity = 1, unit, notes, productId, estimatedPrice } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Eğer productId verilmişse, ürünün var olduğunu kontrol et
|
||||
let product = null;
|
||||
if (productId) {
|
||||
product = await prisma.product.findUnique({
|
||||
where: { id: productId }
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json(errorResponse('Ürün bulunamadı'));
|
||||
}
|
||||
}
|
||||
|
||||
// Aynı öğenin listede zaten var olup olmadığını kontrol et
|
||||
const existingItem = await prisma.listItem.findFirst({
|
||||
where: {
|
||||
listId,
|
||||
OR: [
|
||||
{ productId: productId || undefined },
|
||||
{ customName: productId ? undefined : name }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (existingItem) {
|
||||
return res.status(409).json(errorResponse('Bu öğe zaten listede mevcut'));
|
||||
}
|
||||
|
||||
// Yeni öğe oluştur
|
||||
const newItem = await prisma.listItem.create({
|
||||
data: {
|
||||
customName: productId ? product.name : name,
|
||||
quantity,
|
||||
unit: unit || "adet",
|
||||
note: notes,
|
||||
price: estimatedPrice,
|
||||
listId,
|
||||
productId
|
||||
},
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
category: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ürün kullanım sayısını artır (Product modelinde usageCount alanı yok, bu özellik kaldırıldı)
|
||||
|
||||
// Liste güncelleme tarihini güncelle
|
||||
await prisma.shoppingList.update({
|
||||
where: { id: listId },
|
||||
data: { updatedAt: new Date() }
|
||||
});
|
||||
|
||||
// Aktivite kaydı oluştur
|
||||
await prisma.activity.create({
|
||||
data: {
|
||||
action: 'item_added',
|
||||
details: {
|
||||
itemId: newItem.id,
|
||||
itemName: newItem.customName || newItem.product?.name || 'Öğe',
|
||||
userName: `${req.user.firstName} ${req.user.lastName}`
|
||||
},
|
||||
userId,
|
||||
listId
|
||||
}
|
||||
});
|
||||
|
||||
// Liste üyelerine bildirim gönder (geçici olarak devre dışı - notifyListMembers fonksiyonu mevcut değil)
|
||||
// await notificationService.notifyListMembers(
|
||||
// listId,
|
||||
// userId,
|
||||
// 'ITEM_ADDED',
|
||||
// `${req.user.firstName} ${req.user.lastName} listeye "${newItem.customName || newItem.product?.name || 'Öğe'}" öğesini ekledi`,
|
||||
// { itemId: newItem.id, itemName: newItem.customName || newItem.product?.name }
|
||||
// );
|
||||
|
||||
// Socket.IO ile gerçek zamanlı güncelleme
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`list_${listId}`).emit('itemAdded', {
|
||||
item: newItem,
|
||||
addedBy: req.user
|
||||
});
|
||||
}
|
||||
|
||||
// Priority değerini string'e çevir
|
||||
const newItemWithStringPriority = {
|
||||
...newItem,
|
||||
priority: newItem.priority === 0 ? 'LOW' : newItem.priority === 1 ? 'MEDIUM' : 'HIGH'
|
||||
};
|
||||
|
||||
res.status(201).json(successResponse('Öğe başarıyla eklendi', newItemWithStringPriority));
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Liste öğesini güncelle
|
||||
* PUT /api/items/:listId/:itemId
|
||||
*/
|
||||
router.put('/:listId/:itemId',
|
||||
authenticateToken,
|
||||
param('listId').custom(validateCuid).withMessage('Geçerli bir liste ID\'si girin'),
|
||||
param('itemId').custom(validateCuid).withMessage('Geçerli bir öğe ID\'si girin'),
|
||||
validateListItemUpdate,
|
||||
checkListMembership,
|
||||
// Sadece isPurchased güncellemesi değilse edit yetkisi gerekli
|
||||
(req, res, next) => {
|
||||
const { name, quantity, unit, notes, price, priority } = req.body;
|
||||
const isOnlyPurchaseUpdate = !name && !quantity && !unit && !notes && !price && !priority;
|
||||
|
||||
if (isOnlyPurchaseUpdate) {
|
||||
// Sadece isPurchased güncellemesi - tüm üyeler yapabilir
|
||||
return next();
|
||||
} else {
|
||||
// Diğer alanlar güncelleniyor - edit yetkisi gerekli
|
||||
const allowedRoles = ['owner', 'admin'];
|
||||
if (!allowedRoles.includes(req.userRole)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Bu işlem için yeterli yetkiniz yok.'
|
||||
});
|
||||
}
|
||||
return next();
|
||||
}
|
||||
},
|
||||
asyncHandler(async (req, res) => {
|
||||
console.log('🔍 PUT /api/items/:listId/:itemId başladı');
|
||||
console.log('📝 Request params:', req.params);
|
||||
console.log('📝 Request body:', req.body);
|
||||
console.log('👤 User ID:', req.user.id);
|
||||
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.log('❌ Validation errors:', errors.array());
|
||||
return res.status(400).json(errorResponse('Doğrulama hatası', errors.array()));
|
||||
}
|
||||
|
||||
const { listId, itemId } = req.params;
|
||||
const { name, quantity, unit, notes, isPurchased, price, priority } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Öğenin var olduğunu kontrol et
|
||||
console.log('🔍 Öğe aranıyor:', { itemId, listId });
|
||||
const existingItem = await prisma.listItem.findFirst({
|
||||
where: {
|
||||
id: itemId,
|
||||
listId
|
||||
},
|
||||
include: {
|
||||
product: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingItem) {
|
||||
console.log('❌ Öğe bulunamadı');
|
||||
return res.status(404).json(errorResponse('Liste öğesi bulunamadı'));
|
||||
}
|
||||
|
||||
console.log('✅ Öğe bulundu:', existingItem.name);
|
||||
|
||||
// Güncelleme verilerini hazırla
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.customName = name;
|
||||
if (quantity !== undefined) updateData.quantity = quantity;
|
||||
if (unit !== undefined) updateData.unit = unit;
|
||||
if (notes !== undefined) updateData.note = notes;
|
||||
if (price !== undefined) updateData.price = price;
|
||||
if (priority !== undefined) {
|
||||
// Priority string'i sayıya çevir
|
||||
const priorityMap = { 'LOW': 0, 'MEDIUM': 1, 'HIGH': 2 };
|
||||
updateData.priority = priorityMap[priority] !== undefined ? priorityMap[priority] : 1;
|
||||
}
|
||||
|
||||
// Satın alma durumu değişikliği
|
||||
if (isPurchased !== undefined && isPurchased !== existingItem.isPurchased) {
|
||||
updateData.isPurchased = isPurchased;
|
||||
if (isPurchased) {
|
||||
updateData.purchasedAt = new Date();
|
||||
updateData.purchasedBy = userId;
|
||||
} else {
|
||||
updateData.purchasedAt = null;
|
||||
updateData.purchasedBy = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Öğeyi güncelle
|
||||
const updatedItem = await prisma.listItem.update({
|
||||
where: { id: itemId },
|
||||
data: updateData,
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
category: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Fiyat geçmişi ekle (eğer fiyat girilmişse ve ürün varsa)
|
||||
if (price && existingItem.productId && isPurchased) {
|
||||
await prisma.priceHistory.create({
|
||||
data: {
|
||||
price: price,
|
||||
productId: existingItem.productId,
|
||||
userId,
|
||||
location: 'Market' // Varsayılan konum
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Liste güncelleme tarihini güncelle
|
||||
await prisma.shoppingList.update({
|
||||
where: { id: listId },
|
||||
data: { updatedAt: new Date() }
|
||||
});
|
||||
|
||||
// Aktivite kaydı oluştur
|
||||
let activityDescription = `${req.user.firstName} ${req.user.lastName} "${updatedItem.name}" öğesini güncelledi`;
|
||||
if (isPurchased !== undefined) {
|
||||
activityDescription = isPurchased
|
||||
? `${req.user.firstName} ${req.user.lastName} "${updatedItem.name}" öğesini satın aldı`
|
||||
: `${req.user.firstName} ${req.user.lastName} "${updatedItem.name}" öğesinin satın alma durumunu iptal etti`;
|
||||
}
|
||||
|
||||
await prisma.activity.create({
|
||||
data: {
|
||||
action: isPurchased !== undefined ? (isPurchased ? 'ITEM_PURCHASED' : 'ITEM_UNPURCHASED') : 'ITEM_UPDATED',
|
||||
details: {
|
||||
description: activityDescription,
|
||||
itemId: updatedItem.id,
|
||||
itemName: updatedItem.name
|
||||
},
|
||||
userId,
|
||||
listId
|
||||
}
|
||||
});
|
||||
|
||||
// Liste üyelerine bildirim gönder (sadece satın alma durumu değişikliğinde)
|
||||
if (isPurchased !== undefined) {
|
||||
await notificationService.notifyListMembers(
|
||||
listId,
|
||||
userId,
|
||||
{
|
||||
type: isPurchased ? 'ITEM_PURCHASED' : 'ITEM_UNPURCHASED',
|
||||
message: activityDescription,
|
||||
data: { itemId: updatedItem.id, itemName: updatedItem.name }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Socket.IO ile gerçek zamanlı güncelleme
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`list_${listId}`).emit('itemUpdated', {
|
||||
item: updatedItem,
|
||||
updatedBy: req.user
|
||||
});
|
||||
}
|
||||
|
||||
// Priority değerini string'e çevir
|
||||
const updatedItemWithStringPriority = {
|
||||
...updatedItem,
|
||||
priority: updatedItem.priority === 0 ? 'LOW' : updatedItem.priority === 1 ? 'MEDIUM' : 'HIGH'
|
||||
};
|
||||
|
||||
res.json(successResponse('Öğe başarıyla güncellendi', updatedItemWithStringPriority));
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Liste öğesini sil
|
||||
* DELETE /api/items/:listId/:itemId
|
||||
*/
|
||||
router.delete('/:listId/:itemId',
|
||||
authenticateToken,
|
||||
param('listId').custom(validateCuid).withMessage('Geçerli bir liste ID\'si girin'),
|
||||
param('itemId').custom(validateCuid).withMessage('Geçerli bir öğe ID\'si girin'),
|
||||
checkListMembership,
|
||||
requireListEditPermission,
|
||||
asyncHandler(async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json(errorResponse('Doğrulama hatası', errors.array()));
|
||||
}
|
||||
|
||||
const { listId, itemId } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Öğenin var olduğunu kontrol et
|
||||
const existingItem = await prisma.listItem.findFirst({
|
||||
where: {
|
||||
id: itemId,
|
||||
listId
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingItem) {
|
||||
return res.status(404).json(errorResponse('Liste öğesi bulunamadı'));
|
||||
}
|
||||
|
||||
// Öğeyi sil
|
||||
await prisma.listItem.delete({
|
||||
where: { id: itemId }
|
||||
});
|
||||
|
||||
// Liste güncelleme tarihini güncelle
|
||||
await prisma.shoppingList.update({
|
||||
where: { id: listId },
|
||||
data: { updatedAt: new Date() }
|
||||
});
|
||||
|
||||
// Aktivite kaydı oluştur (Prisma şemasına uygun)
|
||||
const itemName = existingItem.customName || existingItem.product?.name || 'Öğe';
|
||||
await prisma.activity.create({
|
||||
data: {
|
||||
action: 'ITEM_REMOVED',
|
||||
details: {
|
||||
description: `${req.user.firstName} ${req.user.lastName} "${itemName}" öğesini listeden kaldırdı`,
|
||||
itemId: existingItem.id,
|
||||
itemName: itemName
|
||||
},
|
||||
userId,
|
||||
listId
|
||||
}
|
||||
});
|
||||
|
||||
// Liste üyelerine bildirim gönder
|
||||
await notificationService.notifyListMembers(
|
||||
listId,
|
||||
userId,
|
||||
{
|
||||
type: 'ITEM_REMOVED',
|
||||
message: `${req.user.firstName} ${req.user.lastName} "${itemName}" öğesini listeden kaldırdı`,
|
||||
data: { itemId: existingItem.id, itemName: itemName }
|
||||
}
|
||||
);
|
||||
|
||||
// Socket.IO ile gerçek zamanlı güncelleme
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`list_${listId}`).emit('itemRemoved', {
|
||||
itemId: existingItem.id,
|
||||
itemName: existingItem.name,
|
||||
removedBy: req.user
|
||||
});
|
||||
}
|
||||
|
||||
res.json(successResponse('Öğe başarıyla silindi'));
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Birden fazla öğeyi toplu güncelle
|
||||
* PATCH /api/items/:listId/bulk
|
||||
*/
|
||||
router.patch('/:listId/bulk',
|
||||
authenticateToken,
|
||||
validateCuidParam('listId'),
|
||||
requireListEditPermission,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { listId } = req.params;
|
||||
const { items, action } = req.body; // items: [itemId1, itemId2], action: 'purchase' | 'unpurchase' | 'delete'
|
||||
const userId = req.user.id;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json(errorResponse('Geçerli öğe listesi gerekli'));
|
||||
}
|
||||
|
||||
if (!['purchase', 'unpurchase', 'delete'].includes(action)) {
|
||||
return res.status(400).json(errorResponse('Geçerli bir işlem seçin'));
|
||||
}
|
||||
|
||||
// Öğelerin var olduğunu kontrol et
|
||||
const existingItems = await prisma.listItem.findMany({
|
||||
where: {
|
||||
id: { in: items },
|
||||
listId
|
||||
}
|
||||
});
|
||||
|
||||
if (existingItems.length !== items.length) {
|
||||
return res.status(404).json(errorResponse('Bazı öğeler bulunamadı'));
|
||||
}
|
||||
|
||||
let updateData = {};
|
||||
let activityType = '';
|
||||
let activityDescription = '';
|
||||
|
||||
switch (action) {
|
||||
case 'purchase':
|
||||
updateData = {
|
||||
isPurchased: true,
|
||||
purchasedAt: new Date(),
|
||||
purchasedById: userId
|
||||
};
|
||||
activityType = 'ITEMS_PURCHASED';
|
||||
activityDescription = `${req.user.firstName} ${req.user.lastName} ${existingItems.length} öğeyi satın aldı`;
|
||||
break;
|
||||
case 'unpurchase':
|
||||
updateData = {
|
||||
isPurchased: false,
|
||||
purchasedAt: null,
|
||||
purchasedBy: null
|
||||
};
|
||||
activityType = 'ITEMS_UNPURCHASED';
|
||||
activityDescription = `${req.user.firstName} ${req.user.lastName} ${existingItems.length} öğenin satın alma durumunu iptal etti`;
|
||||
break;
|
||||
case 'delete':
|
||||
activityType = 'ITEMS_REMOVED';
|
||||
activityDescription = `${req.user.firstName} ${req.user.lastName} ${existingItems.length} öğeyi listeden kaldırdı`;
|
||||
break;
|
||||
}
|
||||
|
||||
// Toplu güncelleme veya silme
|
||||
if (action === 'delete') {
|
||||
await prisma.listItem.deleteMany({
|
||||
where: {
|
||||
id: { in: items },
|
||||
listId
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await prisma.listItem.updateMany({
|
||||
where: {
|
||||
id: { in: items },
|
||||
listId
|
||||
},
|
||||
data: updateData
|
||||
});
|
||||
}
|
||||
|
||||
// Liste güncelleme tarihini güncelle
|
||||
await prisma.shoppingList.update({
|
||||
where: { id: listId },
|
||||
data: { updatedAt: new Date() }
|
||||
});
|
||||
|
||||
// Aktivite kaydı oluştur
|
||||
await prisma.activity.create({
|
||||
data: {
|
||||
type: activityType,
|
||||
description: activityDescription,
|
||||
userId,
|
||||
listId
|
||||
}
|
||||
});
|
||||
|
||||
// Liste üyelerine bildirim gönder
|
||||
await notificationService.notifyListMembers(
|
||||
listId,
|
||||
userId,
|
||||
{
|
||||
type: activityType,
|
||||
message: activityDescription,
|
||||
data: { itemCount: existingItems.length }
|
||||
}
|
||||
);
|
||||
|
||||
// Socket.IO ile gerçek zamanlı güncelleme
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`list_${listId}`).emit('itemsBulkUpdated', {
|
||||
items: items,
|
||||
action: action,
|
||||
updatedBy: req.user
|
||||
});
|
||||
}
|
||||
|
||||
res.json(successResponse(`${existingItems.length} öğe başarıyla ${action === 'purchase' ? 'satın alındı' : action === 'unpurchase' ? 'satın alma iptal edildi' : 'silindi'}`));
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
704
backend/src/routes/lists.js
Normal file
704
backend/src/routes/lists.js
Normal file
@@ -0,0 +1,704 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult, param } = require('express-validator');
|
||||
const { asyncHandler, formatValidationErrors } = require('../middleware/errorHandler');
|
||||
const {
|
||||
authenticateToken,
|
||||
checkListMembership,
|
||||
requireListEditPermission
|
||||
} = require('../middleware/auth');
|
||||
const {
|
||||
validateListCreation,
|
||||
validateListUpdate,
|
||||
validateListItemCreation,
|
||||
validateListItemUpdate,
|
||||
validateUUIDParam,
|
||||
validateCuidParam,
|
||||
validateAddListMember,
|
||||
validatePagination
|
||||
} = require('../utils/validators');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Kullanıcının listelerini getir
|
||||
* GET /api/lists
|
||||
*/
|
||||
router.get('/', authenticateToken, asyncHandler(async (req, res) => {
|
||||
const { page = 1, limit = 10, search } = req.query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Arama koşulları
|
||||
const whereCondition = {
|
||||
OR: [
|
||||
{ ownerId: req.user.id },
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId: req.user.id
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
isActive: true
|
||||
};
|
||||
|
||||
if (search) {
|
||||
whereCondition.name = {
|
||||
contains: search,
|
||||
mode: 'insensitive'
|
||||
};
|
||||
}
|
||||
|
||||
const [lists, totalCount] = await Promise.all([
|
||||
req.prisma.shoppingList.findMany({
|
||||
where: whereCondition,
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
isPurchased: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
items: true,
|
||||
members: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: parseInt(skip),
|
||||
take: parseInt(limit)
|
||||
}),
|
||||
req.prisma.shoppingList.count({ where: whereCondition })
|
||||
]);
|
||||
|
||||
// Her liste için kullanıcının rolünü belirle ve tamamlanan ürün sayısını hesapla
|
||||
const listsWithUserRole = lists.map(list => {
|
||||
let userRole = 'viewer';
|
||||
|
||||
if (list.ownerId === req.user.id) {
|
||||
userRole = 'owner';
|
||||
} else {
|
||||
const membership = list.members.find(member => member.userId === req.user.id);
|
||||
if (membership) {
|
||||
userRole = membership.role;
|
||||
}
|
||||
}
|
||||
|
||||
// Tamamlanan ürün sayısını hesapla
|
||||
const completedItems = list.items.filter(item => item.isPurchased).length;
|
||||
|
||||
// Items'ı response'dan çıkar (sadece count için kullandık)
|
||||
const { items, ...listWithoutItems } = list;
|
||||
|
||||
return {
|
||||
...listWithoutItems,
|
||||
userRole,
|
||||
_count: {
|
||||
...list._count,
|
||||
completedItems
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
lists: listsWithUserRole,
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
totalCount,
|
||||
hasNext: skip + parseInt(limit) < totalCount,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Yeni liste oluştur
|
||||
* POST /api/lists
|
||||
*/
|
||||
router.post('/', authenticateToken, [
|
||||
body('name')
|
||||
.isLength({ min: 1, max: 100 })
|
||||
.withMessage('Liste adı 1-100 karakter arasında olmalı'),
|
||||
body('description')
|
||||
.optional()
|
||||
.isLength({ max: 500 })
|
||||
.withMessage('Açıklama en fazla 500 karakter olabilir'),
|
||||
body('color')
|
||||
.optional()
|
||||
.matches(/^#[0-9A-F]{6}$/i)
|
||||
.withMessage('Geçerli bir hex renk kodu girin'),
|
||||
body('isShared')
|
||||
.optional()
|
||||
.isBoolean()
|
||||
.withMessage('Paylaşım durumu boolean olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { name, description, color = '#2196F3' } = req.body;
|
||||
|
||||
const list = await req.prisma.shoppingList.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
ownerId: req.user.id
|
||||
},
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
items: true,
|
||||
members: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Aktivite kaydı oluştur
|
||||
await req.prisma.activity.create({
|
||||
data: {
|
||||
listId: list.id,
|
||||
userId: req.user.id,
|
||||
action: 'list_created',
|
||||
details: {
|
||||
listName: list.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Socket.IO ile gerçek zamanlı bildirim gönder
|
||||
req.io.emit('list_created', {
|
||||
list: { ...list, userRole: 'owner' },
|
||||
user: req.user
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Liste başarıyla oluşturuldu',
|
||||
data: {
|
||||
list: { ...list, userRole: 'owner' }
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Liste detaylarını getir
|
||||
* GET /api/lists/:listId
|
||||
*/
|
||||
router.get('/:listId', [
|
||||
authenticateToken,
|
||||
validateCuidParam('listId'),
|
||||
checkListMembership
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { listId } = req.params;
|
||||
|
||||
const list = await req.prisma.shoppingList.findUnique({
|
||||
where: { id: listId },
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
items: {
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
category: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ isPurchased: 'asc' },
|
||||
{ priority: 'desc' },
|
||||
{ sortOrder: 'asc' },
|
||||
{ createdAt: 'asc' }
|
||||
]
|
||||
},
|
||||
activities: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: {
|
||||
...list,
|
||||
userRole: req.userRole
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Liste güncelle
|
||||
* PUT /api/lists/:listId
|
||||
*/
|
||||
router.put('/:listId', [
|
||||
authenticateToken,
|
||||
validateCuidParam('listId'),
|
||||
checkListMembership,
|
||||
requireListEditPermission,
|
||||
body('name')
|
||||
.optional()
|
||||
.isLength({ min: 1, max: 100 })
|
||||
.withMessage('Liste adı 1-100 karakter arasında olmalı'),
|
||||
body('description')
|
||||
.optional()
|
||||
.isLength({ max: 500 })
|
||||
.withMessage('Açıklama en fazla 500 karakter olabilir'),
|
||||
body('color')
|
||||
.optional()
|
||||
.matches(/^#[0-9A-F]{6}$/i)
|
||||
.withMessage('Geçerli bir hex renk kodu girin'),
|
||||
body('isShared')
|
||||
.optional()
|
||||
.isBoolean()
|
||||
.withMessage('Paylaşım durumu boolean olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { listId } = req.params;
|
||||
const { name, description, color, isShared } = req.body;
|
||||
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (color !== undefined) updateData.color = color;
|
||||
if (isShared !== undefined) updateData.isShared = isShared;
|
||||
|
||||
const updatedList = await req.prisma.shoppingList.update({
|
||||
where: { id: listId },
|
||||
data: updateData,
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
items: true,
|
||||
members: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Aktivite kaydı oluştur
|
||||
await req.prisma.activity.create({
|
||||
data: {
|
||||
listId: listId,
|
||||
userId: req.user.id,
|
||||
action: 'list_updated',
|
||||
details: {
|
||||
changes: updateData
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Socket.IO ile gerçek zamanlı güncelleme gönder
|
||||
req.io.to(`list_${listId}`).emit('list_updated', {
|
||||
list: { ...updatedList, userRole: req.userRole },
|
||||
user: req.user,
|
||||
changes: updateData
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Liste başarıyla güncellendi',
|
||||
data: {
|
||||
list: { ...updatedList, userRole: req.userRole }
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Liste sil
|
||||
* DELETE /api/lists/:listId
|
||||
*/
|
||||
router.delete('/:listId', [
|
||||
authenticateToken,
|
||||
validateCuidParam('listId'),
|
||||
checkListMembership
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { listId } = req.params;
|
||||
|
||||
// Sadece liste sahibi silebilir
|
||||
if (req.userRole !== 'owner') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Sadece liste sahibi listeyi silebilir'
|
||||
});
|
||||
}
|
||||
|
||||
// Soft delete (isActive = false)
|
||||
await req.prisma.shoppingList.update({
|
||||
where: { id: listId },
|
||||
data: { isActive: false }
|
||||
});
|
||||
|
||||
// Aktivite kaydı oluştur
|
||||
await req.prisma.activity.create({
|
||||
data: {
|
||||
listId: listId,
|
||||
userId: req.user.id,
|
||||
action: 'list_deleted',
|
||||
details: {
|
||||
listName: req.list.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Socket.IO ile gerçek zamanlı bildirim gönder
|
||||
req.io.to(`list_${listId}`).emit('list_deleted', {
|
||||
listId: listId,
|
||||
user: req.user
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Liste başarıyla silindi'
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Listeye üye ekle
|
||||
* POST /api/lists/:listId/members
|
||||
*/
|
||||
router.post('/:listId/members', [
|
||||
authenticateToken,
|
||||
validateCuidParam('listId'),
|
||||
checkListMembership,
|
||||
requireListEditPermission,
|
||||
body('email')
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('Geçerli bir e-posta adresi girin'),
|
||||
body('role')
|
||||
.optional()
|
||||
.isIn(['admin', 'member', 'viewer', 'EDITOR', 'VIEWER'])
|
||||
.withMessage('Geçerli bir rol seçin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { listId } = req.params;
|
||||
let { email, role = 'member' } = req.body;
|
||||
|
||||
// Role mapping
|
||||
if (role === 'EDITOR') role = 'member';
|
||||
if (role === 'VIEWER') role = 'viewer';
|
||||
|
||||
// Kullanıcıyı bul
|
||||
const targetUser = await req.prisma.user.findUnique({
|
||||
where: { email, isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Bu e-posta adresine sahip aktif kullanıcı bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Zaten üye mi kontrol et
|
||||
const existingMember = await req.prisma.listMember.findUnique({
|
||||
where: {
|
||||
listId_userId: {
|
||||
listId: listId,
|
||||
userId: targetUser.id
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existingMember) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Bu kullanıcı zaten liste üyesi'
|
||||
});
|
||||
}
|
||||
|
||||
// Liste sahibi kendini üye olarak ekleyemez
|
||||
if (targetUser.id === req.list.ownerId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Liste sahibi zaten tüm yetkilere sahip'
|
||||
});
|
||||
}
|
||||
|
||||
// Üye ekle
|
||||
const newMember = await req.prisma.listMember.create({
|
||||
data: {
|
||||
listId: listId,
|
||||
userId: targetUser.id,
|
||||
role: role
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Aktivite kaydı oluştur
|
||||
await req.prisma.activity.create({
|
||||
data: {
|
||||
listId: listId,
|
||||
userId: req.user.id,
|
||||
action: 'member_added',
|
||||
details: {
|
||||
addedUser: {
|
||||
id: targetUser.id,
|
||||
username: targetUser.username,
|
||||
firstName: targetUser.firstName,
|
||||
lastName: targetUser.lastName
|
||||
},
|
||||
role: role
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bildirim oluştur
|
||||
await req.prisma.notification.create({
|
||||
data: {
|
||||
userId: targetUser.id,
|
||||
title: 'Listeye Eklendi',
|
||||
message: `${req.user.firstName} ${req.user.lastName} sizi "${req.list.name}" listesine ekledi`,
|
||||
type: 'list_shared',
|
||||
data: {
|
||||
listId: listId,
|
||||
listName: req.list.name,
|
||||
invitedBy: {
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
firstName: req.user.firstName,
|
||||
lastName: req.user.lastName
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Socket.IO ile gerçek zamanlı bildirim gönder
|
||||
req.io.to(`list_${listId}`).emit('member_added', {
|
||||
member: newMember,
|
||||
user: req.user
|
||||
});
|
||||
|
||||
// Yeni üyeye özel bildirim gönder
|
||||
req.io.to(`user_${targetUser.id}`).emit('list_invitation', {
|
||||
listId: listId,
|
||||
listName: req.list.name,
|
||||
invitedBy: req.user
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Üye başarıyla eklendi',
|
||||
data: { member: newMember }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Liste üyesini çıkar
|
||||
* DELETE /api/lists/:listId/members/:userId
|
||||
*/
|
||||
router.delete('/:listId/members/:userId', [
|
||||
authenticateToken,
|
||||
validateCuidParam('listId'),
|
||||
validateCuidParam('userId'),
|
||||
checkListMembership,
|
||||
requireListEditPermission
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { listId, userId } = req.params;
|
||||
|
||||
// Üyeliği bul
|
||||
const membership = await req.prisma.listMember.findUnique({
|
||||
where: {
|
||||
listId_userId: {
|
||||
listId: listId,
|
||||
userId: userId
|
||||
}
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Üye bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Üyeliği sil
|
||||
await req.prisma.listMember.delete({
|
||||
where: {
|
||||
listId_userId: {
|
||||
listId: listId,
|
||||
userId: userId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Aktivite kaydı oluştur
|
||||
await req.prisma.activity.create({
|
||||
data: {
|
||||
listId: listId,
|
||||
userId: req.user.id,
|
||||
action: 'member_removed',
|
||||
details: {
|
||||
removedUser: {
|
||||
id: membership.user.id,
|
||||
username: membership.user.username,
|
||||
firstName: membership.user.firstName,
|
||||
lastName: membership.user.lastName
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Socket.IO ile gerçek zamanlı bildirim gönder
|
||||
req.io.to(`list_${listId}`).emit('member_removed', {
|
||||
userId: userId,
|
||||
user: req.user
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Üye başarıyla çıkarıldı'
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
541
backend/src/routes/notifications.js
Normal file
541
backend/src/routes/notifications.js
Normal file
@@ -0,0 +1,541 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult, param, query } = require('express-validator');
|
||||
const { asyncHandler, formatValidationErrors } = require('../middleware/errorHandler');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Kullanıcının bildirimlerini getir
|
||||
* GET /api/notifications
|
||||
*/
|
||||
router.get('/', [
|
||||
authenticateToken,
|
||||
query('page').optional().isInt({ min: 1 }).withMessage('Sayfa numarası pozitif bir sayı olmalı'),
|
||||
query('limit').optional().isInt({ min: 1, max: 50 }).withMessage('Limit 1-50 arasında olmalı'),
|
||||
query('unreadOnly').optional().isBoolean().withMessage('unreadOnly boolean değer olmalı'),
|
||||
query('type').optional().isIn(['list_invite', 'item_added', 'item_removed', 'item_purchased', 'list_shared', 'system']).withMessage('Geçerli bir bildirim türü seçin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
unreadOnly = false,
|
||||
type
|
||||
} = req.query;
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Filtreleme koşulları
|
||||
const whereCondition = {
|
||||
userId: req.user.id
|
||||
};
|
||||
|
||||
if (unreadOnly) {
|
||||
whereCondition.isRead = false;
|
||||
}
|
||||
|
||||
if (type) {
|
||||
whereCondition.type = type;
|
||||
}
|
||||
|
||||
const [notifications, totalCount, unreadCount] = await Promise.all([
|
||||
req.prisma.notification.findMany({
|
||||
where: whereCondition,
|
||||
include: {
|
||||
relatedList: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
relatedUser: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
skip: parseInt(skip),
|
||||
take: parseInt(limit)
|
||||
}),
|
||||
req.prisma.notification.count({ where: whereCondition }),
|
||||
req.prisma.notification.count({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
isRead: false
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
notifications,
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
totalCount,
|
||||
hasNext: skip + parseInt(limit) < totalCount,
|
||||
hasPrev: page > 1
|
||||
},
|
||||
unreadCount
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Okunmamış bildirim sayısını getir
|
||||
* GET /api/notifications/unread-count
|
||||
*/
|
||||
router.get('/unread-count', [
|
||||
authenticateToken
|
||||
], asyncHandler(async (req, res) => {
|
||||
const unreadCount = await req.prisma.notification.count({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
isRead: false
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { unreadCount }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Bildirimi okundu olarak işaretle
|
||||
* PUT /api/notifications/:notificationId/read
|
||||
*/
|
||||
router.put('/:notificationId/read', [
|
||||
authenticateToken,
|
||||
param('notificationId').isUUID().withMessage('Geçerli bir bildirim ID\'si girin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { notificationId } = req.params;
|
||||
|
||||
const notification = await req.prisma.notification.findUnique({
|
||||
where: {
|
||||
id: notificationId,
|
||||
userId: req.user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!notification) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Bildirim bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
const updatedNotification = await req.prisma.notification.update({
|
||||
where: { id: notificationId },
|
||||
data: {
|
||||
isRead: true,
|
||||
readAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Bildirim okundu olarak işaretlendi',
|
||||
data: { notification: updatedNotification }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Tüm bildirimleri okundu olarak işaretle
|
||||
* PUT /api/notifications/mark-all-read
|
||||
*/
|
||||
router.put('/mark-all-read', [
|
||||
authenticateToken
|
||||
], asyncHandler(async (req, res) => {
|
||||
const result = await req.prisma.notification.updateMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
isRead: false
|
||||
},
|
||||
data: {
|
||||
isRead: true,
|
||||
readAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${result.count} bildirim okundu olarak işaretlendi`,
|
||||
data: { updatedCount: result.count }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Bildirimi sil
|
||||
* DELETE /api/notifications/:notificationId
|
||||
*/
|
||||
router.delete('/:notificationId', [
|
||||
authenticateToken,
|
||||
param('notificationId').isUUID().withMessage('Geçerli bir bildirim ID\'si girin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { notificationId } = req.params;
|
||||
|
||||
const notification = await req.prisma.notification.findUnique({
|
||||
where: {
|
||||
id: notificationId,
|
||||
userId: req.user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!notification) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Bildirim bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
await req.prisma.notification.delete({
|
||||
where: { id: notificationId }
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Bildirim başarıyla silindi'
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Okunmuş bildirimleri temizle
|
||||
* DELETE /api/notifications/clear-read
|
||||
*/
|
||||
router.delete('/clear-read', [
|
||||
authenticateToken
|
||||
], asyncHandler(async (req, res) => {
|
||||
const result = await req.prisma.notification.deleteMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
isRead: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${result.count} okunmuş bildirim temizlendi`,
|
||||
data: { deletedCount: result.count }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Tüm bildirimleri temizle
|
||||
* DELETE /api/notifications/clear-all
|
||||
*/
|
||||
router.delete('/clear-all', [
|
||||
authenticateToken
|
||||
], asyncHandler(async (req, res) => {
|
||||
const result = await req.prisma.notification.deleteMany({
|
||||
where: {
|
||||
userId: req.user.id
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${result.count} bildirim temizlendi`,
|
||||
data: { deletedCount: result.count }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Bildirim ayarlarını getir
|
||||
* GET /api/notifications/settings
|
||||
*/
|
||||
router.get('/settings', [
|
||||
authenticateToken
|
||||
], asyncHandler(async (req, res) => {
|
||||
const settings = await req.prisma.setting.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
key: {
|
||||
startsWith: 'notification_'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Varsayılan ayarlar
|
||||
const defaultSettings = {
|
||||
notification_list_invite: 'true',
|
||||
notification_item_added: 'true',
|
||||
notification_item_removed: 'true',
|
||||
notification_item_purchased: 'true',
|
||||
notification_list_shared: 'true',
|
||||
notification_system: 'true',
|
||||
notification_push_enabled: 'true',
|
||||
notification_email_enabled: 'false'
|
||||
};
|
||||
|
||||
// Kullanıcı ayarlarını varsayılanlarla birleştir
|
||||
const userSettings = {};
|
||||
settings.forEach(setting => {
|
||||
userSettings[setting.key] = setting.value;
|
||||
});
|
||||
|
||||
const finalSettings = { ...defaultSettings, ...userSettings };
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { settings: finalSettings }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Bildirim ayarlarını güncelle
|
||||
* PUT /api/notifications/settings
|
||||
*/
|
||||
router.put('/settings', [
|
||||
authenticateToken,
|
||||
body('settings').isObject().withMessage('Ayarlar obje formatında olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { settings } = req.body;
|
||||
|
||||
// Geçerli ayar anahtarları
|
||||
const validKeys = [
|
||||
'notification_list_invite',
|
||||
'notification_item_added',
|
||||
'notification_item_removed',
|
||||
'notification_item_purchased',
|
||||
'notification_list_shared',
|
||||
'notification_system',
|
||||
'notification_push_enabled',
|
||||
'notification_email_enabled'
|
||||
];
|
||||
|
||||
// Geçersiz anahtarları filtrele
|
||||
const validSettings = {};
|
||||
Object.keys(settings).forEach(key => {
|
||||
if (validKeys.includes(key)) {
|
||||
validSettings[key] = settings[key].toString();
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(validSettings).length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Geçerli ayar bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Ayarları güncelle veya oluştur
|
||||
const updatePromises = Object.entries(validSettings).map(([key, value]) =>
|
||||
req.prisma.setting.upsert({
|
||||
where: {
|
||||
userId_key: {
|
||||
userId: req.user.id,
|
||||
key
|
||||
}
|
||||
},
|
||||
update: { value },
|
||||
create: {
|
||||
userId: req.user.id,
|
||||
key,
|
||||
value
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Bildirim ayarları güncellendi',
|
||||
data: { settings: validSettings }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Sistem bildirimi gönder (Admin)
|
||||
* POST /api/notifications/system
|
||||
*/
|
||||
router.post('/system', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
body('title')
|
||||
.trim()
|
||||
.isLength({ min: 1, max: 100 })
|
||||
.withMessage('Başlık 1-100 karakter arasında olmalı'),
|
||||
body('message')
|
||||
.trim()
|
||||
.isLength({ min: 1, max: 500 })
|
||||
.withMessage('Mesaj 1-500 karakter arasında olmalı'),
|
||||
body('targetUsers')
|
||||
.optional()
|
||||
.isArray()
|
||||
.withMessage('Hedef kullanıcılar dizi formatında olmalı'),
|
||||
body('targetUsers.*')
|
||||
.optional()
|
||||
.isUUID()
|
||||
.withMessage('Geçerli kullanıcı ID\'leri girin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { title, message, targetUsers } = req.body;
|
||||
|
||||
let users;
|
||||
if (targetUsers && targetUsers.length > 0) {
|
||||
// Belirli kullanıcılara gönder
|
||||
users = await req.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: targetUsers },
|
||||
isActive: true
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
} else {
|
||||
// Tüm aktif kullanıcılara gönder
|
||||
users = await req.prisma.user.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true }
|
||||
});
|
||||
}
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Hedef kullanıcı bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Bildirimleri oluştur
|
||||
const notifications = users.map(user => ({
|
||||
userId: user.id,
|
||||
type: 'system',
|
||||
title,
|
||||
message,
|
||||
data: JSON.stringify({
|
||||
adminId: req.user.id,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}));
|
||||
|
||||
const result = await req.prisma.notification.createMany({
|
||||
data: notifications
|
||||
});
|
||||
|
||||
// Socket.IO ile gerçek zamanlı bildirim gönder
|
||||
if (req.io) {
|
||||
users.forEach(user => {
|
||||
req.io.to(`user_${user.id}`).emit('notification', {
|
||||
type: 'system',
|
||||
title,
|
||||
message,
|
||||
createdAt: new Date()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: `${result.count} kullanıcıya sistem bildirimi gönderildi`,
|
||||
data: { sentCount: result.count }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Bildirim istatistikleri (Admin)
|
||||
* GET /api/notifications/stats
|
||||
*/
|
||||
router.get('/stats/overview', [
|
||||
authenticateToken,
|
||||
requireAdmin
|
||||
], asyncHandler(async (req, res) => {
|
||||
const [
|
||||
totalNotifications,
|
||||
unreadNotifications,
|
||||
notificationsByType,
|
||||
recentNotifications
|
||||
] = await Promise.all([
|
||||
req.prisma.notification.count(),
|
||||
req.prisma.notification.count({ where: { isRead: false } }),
|
||||
req.prisma.notification.groupBy({
|
||||
by: ['type'],
|
||||
_count: {
|
||||
id: true
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: 'desc'
|
||||
}
|
||||
}
|
||||
}),
|
||||
req.prisma.notification.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 24 * 60 * 60 * 1000) // Son 24 saat
|
||||
}
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: 10
|
||||
})
|
||||
]);
|
||||
|
||||
const typeStats = {};
|
||||
notificationsByType.forEach(item => {
|
||||
typeStats[item.type] = item._count.id;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
overview: {
|
||||
total: totalNotifications,
|
||||
unread: unreadNotifications,
|
||||
read: totalNotifications - unreadNotifications
|
||||
},
|
||||
byType: typeStats,
|
||||
recent: recentNotifications
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
722
backend/src/routes/products.js
Normal file
722
backend/src/routes/products.js
Normal file
@@ -0,0 +1,722 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult, param, query } = require('express-validator');
|
||||
const { asyncHandler, formatValidationErrors } = require('../middleware/errorHandler');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const {
|
||||
validateProductCreation,
|
||||
validatePagination,
|
||||
validateSearch,
|
||||
validateDateRange,
|
||||
validateUUIDParam,
|
||||
validateCuid
|
||||
} = require('../utils/validators');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Ürün arama
|
||||
* GET /api/products/search
|
||||
*/
|
||||
router.get('/search', [
|
||||
authenticateToken,
|
||||
query('q')
|
||||
.isLength({ min: 2 })
|
||||
.withMessage('Arama terimi en az 2 karakter olmalı'),
|
||||
query('categoryId').optional().custom(validateCuid).withMessage('Geçerli bir kategori ID\'si girin'),
|
||||
query('limit').optional().isInt({ min: 1, max: 50 }).withMessage('Limit 1-50 arasında olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
console.log('🔍 Products API called with query:', req.query);
|
||||
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.log('❌ Products validation errors:', errors.array());
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { q, categoryId, limit = 20 } = req.query;
|
||||
|
||||
const whereCondition = {
|
||||
isActive: true,
|
||||
OR: [
|
||||
{
|
||||
name: {
|
||||
contains: q
|
||||
}
|
||||
},
|
||||
{
|
||||
barcode: {
|
||||
contains: q
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (categoryId) {
|
||||
whereCondition.categoryId = categoryId;
|
||||
}
|
||||
|
||||
const products = await req.prisma.product.findMany({
|
||||
where: whereCondition,
|
||||
include: {
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
icon: true
|
||||
}
|
||||
},
|
||||
priceHistory: {
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: 1,
|
||||
select: {
|
||||
price: true,
|
||||
createdAt: true
|
||||
}
|
||||
}
|
||||
},
|
||||
take: parseInt(limit),
|
||||
orderBy: [
|
||||
{ name: 'asc' }
|
||||
]
|
||||
});
|
||||
|
||||
// Son fiyatı ekle
|
||||
const productsWithPrice = products.map(product => ({
|
||||
...product,
|
||||
currentPrice: product.priceHistory[0]?.price || null,
|
||||
lastPriceUpdate: product.priceHistory[0]?.createdAt || null,
|
||||
priceHistory: undefined // Gereksiz veriyi kaldır
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { products: productsWithPrice }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Barkod ile ürün arama
|
||||
* GET /api/products/barcode/:barcode
|
||||
*/
|
||||
router.get('/barcode/:barcode', [
|
||||
authenticateToken,
|
||||
param('barcode').isLength({ min: 8, max: 20 }).withMessage('Geçerli bir barkod girin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { barcode } = req.params;
|
||||
|
||||
const product = await req.prisma.product.findFirst({
|
||||
where: {
|
||||
barcode,
|
||||
isActive: true
|
||||
},
|
||||
include: {
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
},
|
||||
priceHistory: {
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: 5,
|
||||
select: {
|
||||
price: true,
|
||||
createdAt: true,
|
||||
location: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Ürün bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Son fiyatı ekle
|
||||
const productWithPrice = {
|
||||
...product,
|
||||
currentPrice: product.priceHistory[0]?.price || null,
|
||||
lastPriceUpdate: product.priceHistory[0]?.createdAt || null
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { product: productWithPrice }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Ürün detayı getir
|
||||
* GET /api/products/:productId
|
||||
*/
|
||||
router.get('/:productId', [
|
||||
authenticateToken,
|
||||
param('productId').custom(validateCuid).withMessage('Geçerli bir ürün ID\'si girin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { productId } = req.params;
|
||||
|
||||
const product = await req.prisma.product.findUnique({
|
||||
where: {
|
||||
id: productId,
|
||||
isActive: true
|
||||
},
|
||||
include: {
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
},
|
||||
priceHistory: {
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: 10,
|
||||
select: {
|
||||
price: true,
|
||||
createdAt: true,
|
||||
location: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
listItems: {
|
||||
where: {
|
||||
list: {
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Ürün bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Son fiyatı ve istatistikleri ekle
|
||||
const productWithStats = {
|
||||
...product,
|
||||
currentPrice: product.priceHistory[0]?.price || null,
|
||||
lastPriceUpdate: product.priceHistory[0]?.createdAt || null,
|
||||
usageCount: product._count.listItems
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { product: productWithStats }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Tüm ürünleri listele
|
||||
* GET /api/products
|
||||
*/
|
||||
router.get('/', [
|
||||
authenticateToken,
|
||||
query('page').optional().isInt({ min: 1 }).withMessage('Sayfa numarası pozitif bir sayı olmalı'),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }).withMessage('Limit 1-100 arasında olmalı'),
|
||||
query('search').optional().isLength({ min: 2 }).withMessage('Arama terimi en az 2 karakter olmalı'),
|
||||
query('categoryId').optional().custom(validateCuid).withMessage('Geçerli bir kategori ID\'si girin'),
|
||||
query('sortBy').optional().isIn(['name', 'createdAt', 'usageCount']).withMessage('Geçerli bir sıralama kriteri seçin'),
|
||||
query('sortOrder').optional().isIn(['asc', 'desc']).withMessage('Geçerli bir sıralama yönü seçin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
search,
|
||||
categoryId,
|
||||
sortBy = 'name',
|
||||
sortOrder = 'asc'
|
||||
} = req.query;
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Filtreleme koşulları
|
||||
const whereCondition = {
|
||||
isActive: true
|
||||
};
|
||||
|
||||
if (search) {
|
||||
whereCondition.OR = [
|
||||
{ name: { contains: search } },
|
||||
{ barcode: { contains: search } }
|
||||
];
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
whereCondition.categoryId = categoryId;
|
||||
}
|
||||
|
||||
// Sıralama
|
||||
let orderBy = {};
|
||||
if (sortBy === 'usageCount') {
|
||||
orderBy = {
|
||||
listItems: {
|
||||
_count: sortOrder
|
||||
}
|
||||
};
|
||||
} else {
|
||||
orderBy[sortBy] = sortOrder;
|
||||
}
|
||||
|
||||
const [products, totalCount] = await Promise.all([
|
||||
req.prisma.product.findMany({
|
||||
where: whereCondition,
|
||||
include: {
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
icon: true
|
||||
}
|
||||
},
|
||||
priceHistory: {
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: 1,
|
||||
select: {
|
||||
price: true,
|
||||
createdAt: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
listItems: {
|
||||
where: {
|
||||
list: {
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy,
|
||||
skip: parseInt(skip),
|
||||
take: parseInt(limit)
|
||||
}),
|
||||
req.prisma.product.count({ where: whereCondition })
|
||||
]);
|
||||
|
||||
// Son fiyatları ekle
|
||||
const productsWithPrice = products.map(product => ({
|
||||
...product,
|
||||
currentPrice: product.priceHistory[0]?.price || null,
|
||||
lastPriceUpdate: product.priceHistory[0]?.createdAt || null,
|
||||
usageCount: product._count.listItems,
|
||||
priceHistory: undefined,
|
||||
_count: undefined
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
products: productsWithPrice,
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
totalCount,
|
||||
hasNext: skip + parseInt(limit) < totalCount,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Yeni ürün oluştur
|
||||
* POST /api/products
|
||||
*/
|
||||
router.post('/', [
|
||||
authenticateToken,
|
||||
body('name')
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 100 })
|
||||
.withMessage('Ürün adı 2-100 karakter arasında olmalı'),
|
||||
body('barcode')
|
||||
.optional({ checkFalsy: true })
|
||||
.isLength({ min: 8, max: 20 })
|
||||
.withMessage('Barkod 8-20 karakter arasında olmalı'),
|
||||
body('categoryId')
|
||||
.custom(validateCuid)
|
||||
.withMessage('Geçerli bir kategori ID\'si girin'),
|
||||
body('price')
|
||||
.optional()
|
||||
.isFloat({ min: 0 })
|
||||
.withMessage('Fiyat pozitif bir sayı olmalı'),
|
||||
body('location')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage('Konum en fazla 100 karakter olmalı'),
|
||||
body('unit')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 20 })
|
||||
.withMessage('Birim en fazla 20 karakter olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { name, barcode, categoryId, price, location, unit } = req.body;
|
||||
|
||||
// Kategori kontrolü
|
||||
const category = await req.prisma.category.findUnique({
|
||||
where: { id: categoryId, isActive: true }
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kategori bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Barkod benzersizlik kontrolü
|
||||
if (barcode) {
|
||||
const existingProduct = await req.prisma.product.findFirst({
|
||||
where: {
|
||||
barcode,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (existingProduct) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Bu barkoda sahip bir ürün zaten mevcut'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Transaction ile ürün ve fiyat geçmişi oluştur
|
||||
const result = await req.prisma.$transaction(async (prisma) => {
|
||||
const product = await prisma.product.create({
|
||||
data: {
|
||||
name,
|
||||
barcode,
|
||||
categoryId,
|
||||
unit
|
||||
},
|
||||
include: {
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Eğer fiyat verilmişse fiyat geçmişine ekle
|
||||
if (price !== undefined && price !== null) {
|
||||
await prisma.priceHistory.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
price: parseFloat(price),
|
||||
location: location || null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return product;
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Ürün başarıyla oluşturuldu',
|
||||
data: {
|
||||
product: {
|
||||
...result,
|
||||
currentPrice: price || null
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Ürün güncelle
|
||||
* PUT /api/products/:productId
|
||||
*/
|
||||
router.put('/:productId', [
|
||||
authenticateToken,
|
||||
param('productId').custom(validateCuid).withMessage('Geçerli bir ürün ID\'si girin'),
|
||||
body('name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 100 })
|
||||
.withMessage('Ürün adı 2-100 karakter arasında olmalı'),
|
||||
body('barcode')
|
||||
.optional({ checkFalsy: true })
|
||||
.isLength({ min: 8, max: 20 })
|
||||
.withMessage('Barkod 8-20 karakter arasında olmalı'),
|
||||
body('categoryId')
|
||||
.optional()
|
||||
.custom(validateCuid)
|
||||
.withMessage('Geçerli bir kategori ID\'si girin'),
|
||||
body('description')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 500 })
|
||||
.withMessage('Açıklama en fazla 500 karakter olabilir'),
|
||||
|
||||
body('brand')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage('Marka en fazla 100 karakter olabilir')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
const { name, barcode, categoryId, description, brand } = req.body;
|
||||
|
||||
// Ürün kontrolü
|
||||
const existingProduct = await req.prisma.product.findUnique({
|
||||
where: { id: productId, isActive: true }
|
||||
});
|
||||
|
||||
if (!existingProduct) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Ürün bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Admin değilse sadece kendi oluşturduğu ürünleri güncelleyebilir
|
||||
if (!req.user.isAdmin && existingProduct.createdBy !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Bu ürünü güncelleme yetkiniz yok'
|
||||
});
|
||||
}
|
||||
|
||||
// Kategori kontrolü
|
||||
if (categoryId) {
|
||||
const category = await req.prisma.category.findUnique({
|
||||
where: { id: categoryId, isActive: true }
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kategori bulunamadı'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Barkod benzersizlik kontrolü
|
||||
if (barcode && barcode !== existingProduct.barcode) {
|
||||
const duplicateProduct = await req.prisma.product.findFirst({
|
||||
where: {
|
||||
barcode,
|
||||
isActive: true,
|
||||
id: { not: productId }
|
||||
}
|
||||
});
|
||||
|
||||
if (duplicateProduct) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Bu barkoda sahip bir ürün zaten mevcut'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
if (name) updateData.name = name;
|
||||
if (barcode !== undefined) updateData.barcode = barcode;
|
||||
if (categoryId) updateData.categoryId = categoryId;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (brand !== undefined) updateData.brand = brand;
|
||||
if (unit !== undefined) updateData.unit = unit;
|
||||
|
||||
const updatedProduct = await req.prisma.product.update({
|
||||
where: { id: productId },
|
||||
data: updateData,
|
||||
include: {
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
},
|
||||
priceHistory: {
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
take: 1,
|
||||
select: {
|
||||
price: true,
|
||||
createdAt: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Ürün başarıyla güncellendi',
|
||||
data: {
|
||||
product: {
|
||||
...updatedProduct,
|
||||
currentPrice: updatedProduct.priceHistory[0]?.price || null,
|
||||
lastPriceUpdate: updatedProduct.priceHistory[0]?.createdAt || null,
|
||||
priceHistory: undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Ürün fiyatı güncelle
|
||||
* POST /api/products/:productId/price
|
||||
*/
|
||||
router.post('/:productId/price', [
|
||||
authenticateToken,
|
||||
param('productId').custom(validateCuid).withMessage('Geçerli bir ürün ID\'si girin'),
|
||||
body('price')
|
||||
.isFloat({ min: 0 })
|
||||
.withMessage('Fiyat pozitif bir sayı olmalı'),
|
||||
body('location')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage('Konum en fazla 100 karakter olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { productId } = req.params;
|
||||
const { price, location } = req.body;
|
||||
|
||||
// Ürün kontrolü
|
||||
const product = await req.prisma.product.findUnique({
|
||||
where: { id: productId, isActive: true }
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Ürün bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Fiyat geçmişine ekle
|
||||
const priceHistory = await req.prisma.priceHistory.create({
|
||||
data: {
|
||||
productId,
|
||||
price: parseFloat(price),
|
||||
location: location || null,
|
||||
createdBy: req.user.id
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Fiyat başarıyla eklendi',
|
||||
data: { priceHistory }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Ürün sil
|
||||
* DELETE /api/products/:productId
|
||||
*/
|
||||
router.delete('/:productId', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
param('productId').custom(validateCuid).withMessage('Geçerli bir ürün ID\'si girin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { productId } = req.params;
|
||||
|
||||
// Ürün kontrolü
|
||||
const product = await req.prisma.product.findUnique({
|
||||
where: { id: productId, isActive: true }
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Ürün bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Admin değilse sadece kendi oluşturduğu ürünleri silebilir
|
||||
if (!req.user.isAdmin && product.createdBy !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Bu ürünü silme yetkiniz yok'
|
||||
});
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
await req.prisma.product.update({
|
||||
where: { id: productId },
|
||||
data: { isActive: false }
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Ürün başarıyla silindi'
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
611
backend/src/routes/users.js
Normal file
611
backend/src/routes/users.js
Normal file
@@ -0,0 +1,611 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult, param, query } = require('express-validator');
|
||||
const { asyncHandler, formatValidationErrors } = require('../middleware/errorHandler');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Kullanıcı bildirim/tercih ayarlarını getir
|
||||
* GET /api/users/settings
|
||||
* Not: Özel route'u parametre yakalayıcılarından (/:userId) ÖNCE tanımlayın
|
||||
*/
|
||||
router.get('/settings', [
|
||||
authenticateToken,
|
||||
], asyncHandler(async (req, res) => {
|
||||
const key = `user:${req.user.id}:settings`;
|
||||
const setting = await req.prisma.setting.findUnique({ where: { key } });
|
||||
|
||||
let settings;
|
||||
if (setting) {
|
||||
try {
|
||||
settings = JSON.parse(setting.value);
|
||||
} catch (e) {
|
||||
settings = {};
|
||||
}
|
||||
} else {
|
||||
const now = new Date().toISOString();
|
||||
settings = {
|
||||
id: `user-settings-${req.user.id}`,
|
||||
userId: req.user.id,
|
||||
emailNotifications: true,
|
||||
pushNotifications: true,
|
||||
listInviteNotifications: true,
|
||||
itemUpdateNotifications: true,
|
||||
priceAlertNotifications: false,
|
||||
theme: 'system',
|
||||
language: 'tr',
|
||||
currency: 'TL',
|
||||
timezone: 'Europe/Istanbul',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { settings }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kullanıcı bildirim/tercih ayarlarını güncelle
|
||||
* PUT /api/users/settings
|
||||
* Not: Özel route'u parametre yakalayıcılarından (/:userId) ÖNCE tanımlayın
|
||||
*/
|
||||
router.put('/settings', [
|
||||
authenticateToken,
|
||||
body('emailNotifications').optional().isBoolean(),
|
||||
body('pushNotifications').optional().isBoolean(),
|
||||
body('listInviteNotifications').optional().isBoolean(),
|
||||
body('itemUpdateNotifications').optional().isBoolean(),
|
||||
body('priceAlertNotifications').optional().isBoolean(),
|
||||
body('theme').optional().isIn(['light', 'dark', 'system']),
|
||||
body('language').optional().isString(),
|
||||
body('currency').optional().isString(),
|
||||
body('timezone').optional().isString(),
|
||||
], asyncHandler(async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const key = `user:${req.user.id}:settings`;
|
||||
const existing = await req.prisma.setting.findUnique({ where: { key } });
|
||||
let current = {};
|
||||
if (existing) {
|
||||
try {
|
||||
current = JSON.parse(existing.value);
|
||||
} catch (e) {
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const merged = {
|
||||
...current,
|
||||
...req.body,
|
||||
id: current.id || `user-settings-${req.user.id}`,
|
||||
userId: req.user.id,
|
||||
updatedAt: now,
|
||||
createdAt: current.createdAt || now,
|
||||
};
|
||||
|
||||
await req.prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: JSON.stringify(merged), type: 'json' },
|
||||
create: { key, value: JSON.stringify(merged), type: 'json' },
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Ayarlar güncellendi',
|
||||
data: { settings: merged }
|
||||
});
|
||||
}));
|
||||
/**
|
||||
* Kullanıcı arama
|
||||
* GET /api/users/search
|
||||
*/
|
||||
router.get('/search', [
|
||||
authenticateToken,
|
||||
query('q')
|
||||
.isLength({ min: 2 })
|
||||
.withMessage('Arama terimi en az 2 karakter olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { q, limit = 10 } = req.query;
|
||||
|
||||
const users = await req.prisma.user.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
OR: [
|
||||
{
|
||||
username: {
|
||||
contains: q,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
},
|
||||
{
|
||||
firstName: {
|
||||
contains: q,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
},
|
||||
{
|
||||
lastName: {
|
||||
contains: q,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
},
|
||||
{
|
||||
email: {
|
||||
contains: q,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
avatar: true,
|
||||
createdAt: true
|
||||
},
|
||||
take: parseInt(limit),
|
||||
orderBy: {
|
||||
username: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { users }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kullanıcı profili getir
|
||||
* GET /api/users/:userId
|
||||
*/
|
||||
router.get('/:userId', [
|
||||
authenticateToken,
|
||||
param('userId').isUUID().withMessage('Geçerli bir kullanıcı ID\'si girin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
|
||||
const user = await req.prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
isActive: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
ownedLists: {
|
||||
where: { isActive: true }
|
||||
},
|
||||
sharedLists: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kullanıcı bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Eğer kendi profili değilse, e-posta adresini gizle
|
||||
if (userId !== req.user.id && !req.user.isAdmin) {
|
||||
delete user.email;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { user }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Tüm kullanıcıları listele (Admin)
|
||||
* GET /api/users
|
||||
*/
|
||||
router.get('/', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
query('page').optional().isInt({ min: 1 }).withMessage('Sayfa numarası pozitif bir sayı olmalı'),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }).withMessage('Limit 1-100 arasında olmalı'),
|
||||
query('search').optional().isLength({ min: 2 }).withMessage('Arama terimi en az 2 karakter olmalı'),
|
||||
query('status').optional().isIn(['active', 'inactive', 'all']).withMessage('Geçerli bir durum seçin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
search,
|
||||
status = 'active',
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc'
|
||||
} = req.query;
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Filtreleme koşulları
|
||||
const whereCondition = {};
|
||||
|
||||
if (status === 'active') {
|
||||
whereCondition.isActive = true;
|
||||
} else if (status === 'inactive') {
|
||||
whereCondition.isActive = false;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
whereCondition.OR = [
|
||||
{ username: { contains: search, mode: 'insensitive' } },
|
||||
{ firstName: { contains: search, mode: 'insensitive' } },
|
||||
{ lastName: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } }
|
||||
];
|
||||
}
|
||||
|
||||
// Sıralama
|
||||
const orderBy = {};
|
||||
orderBy[sortBy] = sortOrder;
|
||||
|
||||
const [users, totalCount] = await Promise.all([
|
||||
req.prisma.user.findMany({
|
||||
where: whereCondition,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
avatar: true,
|
||||
isActive: true,
|
||||
isAdmin: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
ownedLists: {
|
||||
where: { isActive: true }
|
||||
},
|
||||
sharedLists: true,
|
||||
notifications: {
|
||||
where: { isRead: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy,
|
||||
skip: parseInt(skip),
|
||||
take: parseInt(limit)
|
||||
}),
|
||||
req.prisma.user.count({ where: whereCondition })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
users,
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
totalCount,
|
||||
hasNext: skip + parseInt(limit) < totalCount,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kullanıcı durumunu güncelle (Admin)
|
||||
* PUT /api/users/:userId/status
|
||||
*/
|
||||
router.put('/:userId/status', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
param('userId').isString().isLength({ min: 1 }).withMessage('Geçerli bir kullanıcı ID\'si girin'),
|
||||
body('isActive').isBoolean().withMessage('Durum boolean değer olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { userId } = req.params;
|
||||
const { isActive } = req.body;
|
||||
|
||||
// Kendi hesabını deaktive edemez
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Kendi hesabınızı deaktive edemezsiniz'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await req.prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kullanıcı bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
const updatedUser = await req.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isActive },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
isActive: true,
|
||||
isAdmin: true,
|
||||
updatedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Kullanıcı ${isActive ? 'aktif' : 'pasif'} hale getirildi`,
|
||||
data: { user: updatedUser }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kullanıcı admin yetkisi güncelle (Admin)
|
||||
* PUT /api/users/:userId/admin
|
||||
*/
|
||||
router.put('/:userId/admin', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
param('userId').isString().isLength({ min: 1 }).withMessage('Geçerli bir kullanıcı ID\'si girin'),
|
||||
body('isAdmin').isBoolean().withMessage('Admin durumu boolean değer olmalı')
|
||||
], asyncHandler(async (req, res) => {
|
||||
// Validation hatalarını kontrol et
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { userId } = req.params;
|
||||
const { isAdmin } = req.body;
|
||||
|
||||
// Kendi admin yetkisini kaldıramaz
|
||||
if (userId === req.user.id && !isAdmin) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Kendi admin yetkinizi kaldıramazsınız'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await req.prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kullanıcı bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
const updatedUser = await req.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isAdmin },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
isActive: true,
|
||||
isAdmin: true,
|
||||
updatedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Kullanıcı ${isAdmin ? 'admin' : 'normal kullanıcı'} yapıldı`,
|
||||
data: { user: updatedUser }
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kullanıcı sil (Admin)
|
||||
* DELETE /api/users/:userId
|
||||
*/
|
||||
router.delete('/:userId', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
param('userId').isUUID().withMessage('Geçerli bir kullanıcı ID\'si girin')
|
||||
], asyncHandler(async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
|
||||
// Kendi hesabını silemez
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Kendi hesabınızı silemezsiniz'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await req.prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kullanıcı bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
// Soft delete (isActive = false)
|
||||
await req.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
isActive: false,
|
||||
email: `deleted_${Date.now()}_${user.email}`, // E-posta çakışmasını önle
|
||||
username: `deleted_${Date.now()}_${user.username}` // Kullanıcı adı çakışmasını önle
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Kullanıcı başarıyla silindi'
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kullanıcı istatistikleri (Admin)
|
||||
* GET /api/users/stats
|
||||
*/
|
||||
router.get('/stats/overview', [
|
||||
authenticateToken,
|
||||
requireAdmin
|
||||
], asyncHandler(async (req, res) => {
|
||||
const [
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
adminUsers,
|
||||
newUsersThisMonth,
|
||||
totalLists,
|
||||
totalProducts
|
||||
] = await Promise.all([
|
||||
req.prisma.user.count(),
|
||||
req.prisma.user.count({ where: { isActive: true } }),
|
||||
req.prisma.user.count({ where: { isAdmin: true, isActive: true } }),
|
||||
req.prisma.user.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
req.prisma.shoppingList.count({ where: { isActive: true } }),
|
||||
req.prisma.product.count({ where: { isActive: true } })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
users: {
|
||||
total: totalUsers,
|
||||
active: activeUsers,
|
||||
inactive: totalUsers - activeUsers,
|
||||
admins: adminUsers,
|
||||
newThisMonth: newUsersThisMonth
|
||||
},
|
||||
lists: {
|
||||
total: totalLists
|
||||
},
|
||||
products: {
|
||||
total: totalProducts
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Kullanıcı bildirim/tercih ayarlarını getir
|
||||
* GET /api/users/settings
|
||||
*/
|
||||
|
||||
/**
|
||||
* Admin: Kullanıcı şifresini sıfırla
|
||||
* PUT /api/users/:userId/password
|
||||
*/
|
||||
router.put('/:userId/password', [
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
param('userId').isUUID().withMessage('Geçerli bir kullanıcı ID\'si girin'),
|
||||
body('newPassword').isLength({ min: 6 }).withMessage('Yeni şifre en az 6 karakter olmalı'),
|
||||
], asyncHandler(async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Girilen bilgilerde hatalar var',
|
||||
errors: formatValidationErrors(errors)
|
||||
});
|
||||
}
|
||||
|
||||
const { userId } = req.params;
|
||||
const { newPassword } = req.body;
|
||||
|
||||
const user = await req.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kullanıcı bulunamadı'
|
||||
});
|
||||
}
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
await req.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { password: hashedPassword }
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Kullanıcı şifresi güncellendi'
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
164
backend/src/server.js
Normal file
164
backend/src/server.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const compression = require('compression');
|
||||
const morgan = require('morgan');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const session = require('express-session');
|
||||
const { createServer } = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
require('dotenv').config();
|
||||
|
||||
const passport = require('./config/passport');
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const userRoutes = require('./routes/users');
|
||||
const listRoutes = require('./routes/lists');
|
||||
const itemRoutes = require('./routes/items');
|
||||
const productRoutes = require('./routes/products');
|
||||
const categoryRoutes = require('./routes/categories');
|
||||
const notificationRoutes = require('./routes/notifications');
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
|
||||
const socketHandler = require('./services/socketHandler');
|
||||
const { errorHandler, notFound } = require('./middleware/errorHandler');
|
||||
const { createNotificationService } = require('./services/notificationService');
|
||||
const { setupUtf8mb4 } = require('./utils/dbCharsetSetup');
|
||||
|
||||
// Prisma client'ı başlat
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Ensure DB charset supports emojis (utf8mb4)
|
||||
setupUtf8mb4(prisma).catch(() => {});
|
||||
|
||||
// Express uygulamasını oluştur
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
// Socket.IO'yu başlat
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
|
||||
methods: ["GET", "POST", "PUT", "DELETE"],
|
||||
credentials: true
|
||||
}
|
||||
});
|
||||
|
||||
// NotificationService'i initialize et
|
||||
const notificationService = createNotificationService(prisma, io);
|
||||
console.log('📧 NotificationService initialized');
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 dakika
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100, // İstek limiti
|
||||
message: {
|
||||
error: 'Çok fazla istek gönderdiniz. Lütfen daha sonra tekrar deneyin.'
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Middleware'ler
|
||||
app.use(helmet()); // Güvenlik başlıkları
|
||||
app.use(compression()); // Gzip sıkıştırma
|
||||
app.use(morgan('combined')); // HTTP isteklerini logla
|
||||
app.use(limiter); // Rate limiting
|
||||
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',').map(origin => origin.trim()) : ["http://localhost:3000"],
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Session middleware (Google OAuth için gerekli)
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'hmarket-session-secret',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 saat
|
||||
}
|
||||
}));
|
||||
|
||||
// Passport middleware
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
// Static dosyalar
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
|
||||
// Prisma'yı request'e ekle
|
||||
app.use((req, res, next) => {
|
||||
req.prisma = prisma;
|
||||
req.io = io;
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
});
|
||||
});
|
||||
|
||||
// API rotaları
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/lists', listRoutes);
|
||||
app.use('/api/items', itemRoutes);
|
||||
app.use('/api/products', productRoutes);
|
||||
app.use('/api/categories', categoryRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// Socket.IO event handler'ları
|
||||
socketHandler(io, prisma);
|
||||
|
||||
// Error handling middleware'leri
|
||||
app.use(notFound);
|
||||
app.use(errorHandler);
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('🛑 SIGTERM sinyali alındı. Sunucu kapatılıyor...');
|
||||
await prisma.$disconnect();
|
||||
server.close(() => {
|
||||
console.log('✅ Sunucu başarıyla kapatıldı.');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('🛑 SIGINT sinyali alındı. Sunucu kapatılıyor...');
|
||||
await prisma.$disconnect();
|
||||
server.close(() => {
|
||||
console.log('✅ Sunucu başarıyla kapatıldı.');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Sunucuyu başlat
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 Server ${PORT} portunda çalışıyor - port 7001`);
|
||||
console.log(`📊 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`🌐 CORS Origin: ${process.env.CORS_ORIGIN || 'http://localhost:7000'}`);
|
||||
console.log(`⏰ ${new Date().toLocaleString('tr-TR')}`);
|
||||
// Server başlatıldı - port 7002
|
||||
});
|
||||
|
||||
module.exports = { app, server, io, prisma };
|
||||
|
||||
|
||||
|
||||
523
backend/src/services/notificationService.js
Normal file
523
backend/src/services/notificationService.js
Normal file
@@ -0,0 +1,523 @@
|
||||
const admin = require('firebase-admin');
|
||||
|
||||
class NotificationService {
|
||||
constructor(prisma, io) {
|
||||
this.prisma = prisma;
|
||||
this.io = io;
|
||||
this.initializeFirebase();
|
||||
}
|
||||
|
||||
initializeFirebase() {
|
||||
try {
|
||||
if (!admin.apps.length) {
|
||||
const serviceAccount = {
|
||||
type: process.env.FIREBASE_TYPE,
|
||||
project_id: process.env.FIREBASE_PROJECT_ID,
|
||||
private_key_id: process.env.FIREBASE_PRIVATE_KEY_ID,
|
||||
private_key: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
client_email: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
client_id: process.env.FIREBASE_CLIENT_ID,
|
||||
auth_uri: process.env.FIREBASE_AUTH_URI,
|
||||
token_uri: process.env.FIREBASE_TOKEN_URI,
|
||||
auth_provider_x509_cert_url: process.env.FIREBASE_AUTH_PROVIDER_X509_CERT_URL,
|
||||
client_x509_cert_url: process.env.FIREBASE_CLIENT_X509_CERT_URL
|
||||
};
|
||||
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount)
|
||||
});
|
||||
|
||||
console.log('Firebase Admin SDK initialized successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Firebase initialization error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Veritabanına bildirim kaydet
|
||||
*/
|
||||
async createNotification(data) {
|
||||
try {
|
||||
const notification = await this.prisma.notification.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
data: data.data ? JSON.stringify(data.data) : null,
|
||||
relatedListId: data.relatedListId || null,
|
||||
relatedUserId: data.relatedUserId || null
|
||||
},
|
||||
include: {
|
||||
relatedList: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
relatedUser: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return notification;
|
||||
} catch (error) {
|
||||
console.error('Error creating notification:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket.IO ile gerçek zamanlı bildirim gönder
|
||||
*/
|
||||
async sendRealtimeNotification(userId, notification) {
|
||||
try {
|
||||
if (this.io) {
|
||||
this.io.to(`user_${userId}`).emit('notification', notification);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending realtime notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push notification gönder
|
||||
*/
|
||||
async sendPushNotification(userId, notification) {
|
||||
try {
|
||||
// Kullanıcının push notification ayarını kontrol et
|
||||
const setting = await this.prisma.setting.findUnique({
|
||||
where: {
|
||||
userId_key: {
|
||||
userId,
|
||||
key: 'notification_push_enabled'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (setting && setting.value === 'false') {
|
||||
return; // Push notification kapalı
|
||||
}
|
||||
|
||||
// Kullanıcının device token'larını al
|
||||
const deviceTokens = await this.prisma.deviceToken.findMany({
|
||||
where: {
|
||||
userId,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (deviceTokens.length === 0) {
|
||||
return; // Device token yok
|
||||
}
|
||||
|
||||
const tokens = deviceTokens.map(dt => dt.token);
|
||||
|
||||
const message = {
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.message
|
||||
},
|
||||
data: {
|
||||
type: notification.type,
|
||||
notificationId: notification.id,
|
||||
...(notification.data ? JSON.parse(notification.data) : {})
|
||||
},
|
||||
tokens
|
||||
};
|
||||
|
||||
const response = await admin.messaging().sendMulticast(message);
|
||||
|
||||
// Başarısız token'ları temizle
|
||||
if (response.failureCount > 0) {
|
||||
const failedTokens = [];
|
||||
response.responses.forEach((resp, idx) => {
|
||||
if (!resp.success) {
|
||||
failedTokens.push(tokens[idx]);
|
||||
}
|
||||
});
|
||||
|
||||
if (failedTokens.length > 0) {
|
||||
await this.prisma.deviceToken.updateMany({
|
||||
where: {
|
||||
token: { in: failedTokens }
|
||||
},
|
||||
data: {
|
||||
isActive: false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Push notification sent to ${response.successCount} devices`);
|
||||
} catch (error) {
|
||||
console.error('Error sending push notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tam bildirim gönderme (DB + Realtime + Push)
|
||||
*/
|
||||
async sendNotification(data) {
|
||||
try {
|
||||
// Kullanıcının bildirim ayarını kontrol et
|
||||
const notificationKey = `notification_${data.type}`;
|
||||
const setting = await this.prisma.setting.findUnique({
|
||||
where: {
|
||||
userId_key: {
|
||||
userId: data.userId,
|
||||
key: notificationKey
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (setting && setting.value === 'false') {
|
||||
return; // Bu tür bildirim kapalı
|
||||
}
|
||||
|
||||
// Veritabanına kaydet
|
||||
const notification = await this.createNotification(data);
|
||||
|
||||
// Gerçek zamanlı gönder
|
||||
await this.sendRealtimeNotification(data.userId, notification);
|
||||
|
||||
// Push notification gönder
|
||||
await this.sendPushNotification(data.userId, notification);
|
||||
|
||||
return notification;
|
||||
} catch (error) {
|
||||
console.error('Error in sendNotification:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Birden fazla kullanıcıya bildirim gönder
|
||||
*/
|
||||
async sendNotificationToMultipleUsers(userIds, data) {
|
||||
try {
|
||||
const promises = userIds.map(userId =>
|
||||
this.sendNotification({ ...data, userId })
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
const successful = results.filter(r => r.status === 'fulfilled').length;
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
|
||||
console.log(`Notifications sent: ${successful} successful, ${failed} failed`);
|
||||
|
||||
return { successful, failed };
|
||||
} catch (error) {
|
||||
console.error('Error sending notifications to multiple users:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste üyelerine bildirim gönder
|
||||
*/
|
||||
async sendNotificationToListMembers(listId, data, excludeUserId = null) {
|
||||
try {
|
||||
const members = await this.prisma.listMember.findMany({
|
||||
where: {
|
||||
listId,
|
||||
userId: excludeUserId ? { not: excludeUserId } : undefined
|
||||
},
|
||||
select: {
|
||||
userId: true
|
||||
}
|
||||
});
|
||||
|
||||
const userIds = members.map(m => m.userId);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
return { successful: 0, failed: 0 };
|
||||
}
|
||||
|
||||
return await this.sendNotificationToMultipleUsers(userIds, {
|
||||
...data,
|
||||
relatedListId: listId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending notifications to list members:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste daveti bildirimi
|
||||
*/
|
||||
async sendListInviteNotification(invitedUserId, inviterUser, list) {
|
||||
return await this.sendNotification({
|
||||
userId: invitedUserId,
|
||||
type: 'list_invite',
|
||||
title: 'Liste Daveti',
|
||||
message: `${inviterUser.firstName} ${inviterUser.lastName} sizi "${list.name}" listesine davet etti`,
|
||||
data: {
|
||||
inviterId: inviterUser.id,
|
||||
listId: list.id
|
||||
},
|
||||
relatedListId: list.id,
|
||||
relatedUserId: inviterUser.id
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ürün eklendi bildirimi
|
||||
*/
|
||||
async sendItemAddedNotification(listId, adderUser, itemName, excludeUserId = null) {
|
||||
return await this.sendNotificationToListMembers(listId, {
|
||||
type: 'item_added',
|
||||
title: 'Ürün Eklendi',
|
||||
message: `${adderUser.firstName} "${itemName}" ürününü listeye ekledi`,
|
||||
data: {
|
||||
adderId: adderUser.id,
|
||||
itemName
|
||||
},
|
||||
relatedUserId: adderUser.id
|
||||
}, excludeUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ürün silindi bildirimi
|
||||
*/
|
||||
async sendItemRemovedNotification(listId, removerUser, itemName, excludeUserId = null) {
|
||||
return await this.sendNotificationToListMembers(listId, {
|
||||
type: 'item_removed',
|
||||
title: 'Ürün Silindi',
|
||||
message: `${removerUser.firstName} "${itemName}" ürününü listeden sildi`,
|
||||
data: {
|
||||
removerId: removerUser.id,
|
||||
itemName
|
||||
},
|
||||
relatedUserId: removerUser.id
|
||||
}, excludeUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ürün satın alındı bildirimi
|
||||
*/
|
||||
async sendItemPurchasedNotification(listId, purchaserUser, itemName, excludeUserId = null) {
|
||||
return await this.sendNotificationToListMembers(listId, {
|
||||
type: 'item_purchased',
|
||||
title: 'Ürün Satın Alındı',
|
||||
message: `${purchaserUser.firstName} "${itemName}" ürününü satın aldı`,
|
||||
data: {
|
||||
purchaserId: purchaserUser.id,
|
||||
itemName
|
||||
},
|
||||
relatedUserId: purchaserUser.id
|
||||
}, excludeUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste paylaşıldı bildirimi
|
||||
*/
|
||||
async sendListSharedNotification(listId, sharerUser, sharedWithUser, listName) {
|
||||
return await this.sendNotification({
|
||||
userId: sharedWithUser.id,
|
||||
type: 'list_shared',
|
||||
title: 'Liste Paylaşıldı',
|
||||
message: `${sharerUser.firstName} "${listName}" listesini sizinle paylaştı`,
|
||||
data: {
|
||||
sharerId: sharerUser.id,
|
||||
listName
|
||||
},
|
||||
relatedListId: listId,
|
||||
relatedUserId: sharerUser.id
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Device token kaydet
|
||||
*/
|
||||
async registerDeviceToken(userId, token, deviceInfo = {}) {
|
||||
try {
|
||||
// Mevcut token'ı kontrol et
|
||||
const existingToken = await this.prisma.deviceToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
if (existingToken) {
|
||||
// Token'ı güncelle
|
||||
return await this.prisma.deviceToken.update({
|
||||
where: { id: existingToken.id },
|
||||
data: {
|
||||
isActive: true,
|
||||
deviceInfo: deviceInfo ? JSON.stringify(deviceInfo) : null,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Yeni token oluştur
|
||||
return await this.prisma.deviceToken.create({
|
||||
data: {
|
||||
userId,
|
||||
token,
|
||||
deviceInfo: deviceInfo ? JSON.stringify(deviceInfo) : null
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error registering device token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Device token'ı deaktive et
|
||||
*/
|
||||
async unregisterDeviceToken(userId, token) {
|
||||
try {
|
||||
return await this.prisma.deviceToken.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
token
|
||||
},
|
||||
data: {
|
||||
isActive: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error unregistering device token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste üyelerine bildirim gönder
|
||||
*/
|
||||
async notifyListMembers(listId, excludeUserId, notificationData) {
|
||||
try {
|
||||
// Liste üyelerini getir
|
||||
const listMembers = await this.prisma.listMember.findMany({
|
||||
where: {
|
||||
listId,
|
||||
userId: {
|
||||
not: excludeUserId // Aksiyonu yapan kullanıcıyı hariç tut
|
||||
}
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Liste sahibini de dahil et (eğer üye değilse)
|
||||
const list = await this.prisma.shoppingList.findUnique({
|
||||
where: { id: listId },
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const memberUserIds = listMembers.map(member => member.userId);
|
||||
if (list.owner.id !== excludeUserId && !memberUserIds.includes(list.owner.id)) {
|
||||
listMembers.push({
|
||||
user: list.owner,
|
||||
userId: list.owner.id
|
||||
});
|
||||
}
|
||||
|
||||
// Her üyeye bildirim gönder
|
||||
const notifications = [];
|
||||
for (const member of listMembers) {
|
||||
|
||||
// Socket.IO ile gerçek zamanlı bildirim
|
||||
if (this.io) {
|
||||
this.io.to(`user_${member.userId}`).emit('notification', {
|
||||
type: notificationData.type,
|
||||
title: notificationData.title,
|
||||
message: notificationData.message,
|
||||
data: notificationData.data,
|
||||
timestamp: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// Veritabanına bildirim kaydet
|
||||
const notification = await this.createNotification({
|
||||
userId: member.userId,
|
||||
type: notificationData.type,
|
||||
title: notificationData.title,
|
||||
message: notificationData.message,
|
||||
data: notificationData.data,
|
||||
relatedListId: listId,
|
||||
relatedUserId: excludeUserId
|
||||
});
|
||||
|
||||
notifications.push(notification);
|
||||
|
||||
// Push notification gönder
|
||||
await this.sendPushNotification(member.userId, {
|
||||
title: notificationData.title,
|
||||
body: notificationData.message,
|
||||
data: notificationData.data
|
||||
});
|
||||
}
|
||||
|
||||
return notifications;
|
||||
} catch (error) {
|
||||
console.error('Error notifying list members:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let notificationServiceInstance = null;
|
||||
|
||||
// Factory function
|
||||
const createNotificationService = (prisma, io) => {
|
||||
if (!notificationServiceInstance) {
|
||||
notificationServiceInstance = new NotificationService(prisma, io);
|
||||
}
|
||||
return notificationServiceInstance;
|
||||
};
|
||||
|
||||
// Export both class and factory
|
||||
module.exports = {
|
||||
NotificationService,
|
||||
createNotificationService,
|
||||
// Default export for backward compatibility
|
||||
notifyListMembers: async (...args) => {
|
||||
if (!notificationServiceInstance) {
|
||||
throw new Error('NotificationService not initialized. Call createNotificationService first.');
|
||||
}
|
||||
return notificationServiceInstance.notifyListMembers(...args);
|
||||
},
|
||||
createNotification: async (...args) => {
|
||||
if (!notificationServiceInstance) {
|
||||
throw new Error('NotificationService not initialized. Call createNotificationService first.');
|
||||
}
|
||||
return notificationServiceInstance.createNotification(...args);
|
||||
},
|
||||
sendPushNotification: async (...args) => {
|
||||
if (!notificationServiceInstance) {
|
||||
throw new Error('NotificationService not initialized. Call createNotificationService first.');
|
||||
}
|
||||
return notificationServiceInstance.sendPushNotification(...args);
|
||||
}
|
||||
};
|
||||
359
backend/src/services/socketHandler.js
Normal file
359
backend/src/services/socketHandler.js
Normal file
@@ -0,0 +1,359 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
/**
|
||||
* Socket.IO event handler'ları
|
||||
* Gerçek zamanlı senkronizasyon için
|
||||
*/
|
||||
function socketHandler(io, prisma) {
|
||||
|
||||
// Socket kimlik doğrulama middleware'i
|
||||
io.use(async (socket, next) => {
|
||||
try {
|
||||
const token = socket.handshake.auth.token || socket.handshake.headers.authorization?.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return next(new Error('Token bulunamadı'));
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: decoded.userId,
|
||||
isActive: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true,
|
||||
isAdmin: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return next(new Error('Kullanıcı bulunamadı'));
|
||||
}
|
||||
|
||||
socket.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(new Error('Geçersiz token'));
|
||||
}
|
||||
});
|
||||
|
||||
io.on('connection', async (socket) => {
|
||||
console.log(`👤 Kullanıcı bağlandı: ${socket.user.username} (${socket.id})`);
|
||||
|
||||
// Kullanıcıyı kendi odasına ekle (kişisel bildirimler için)
|
||||
socket.join(`user_${socket.user.id}`);
|
||||
|
||||
// Kullanıcının listelerini getir ve ilgili odalara ekle
|
||||
try {
|
||||
const userLists = await prisma.shoppingList.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: socket.user.id },
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId: socket.user.id
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
isActive: true
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
// Her liste için odaya katıl
|
||||
userLists.forEach(list => {
|
||||
socket.join(`list_${list.id}`);
|
||||
});
|
||||
|
||||
console.log(`📋 ${socket.user.username} ${userLists.length} listeye bağlandı`);
|
||||
} catch (error) {
|
||||
console.error('Liste odalarına katılım hatası:', error);
|
||||
}
|
||||
|
||||
// Liste odalarına katılma
|
||||
socket.on('join_list', async (data) => {
|
||||
try {
|
||||
const { listId } = data;
|
||||
|
||||
// Kullanıcının bu listeye erişimi var mı kontrol et
|
||||
const hasAccess = await checkListAccess(socket.user.id, listId, prisma);
|
||||
|
||||
if (!hasAccess) {
|
||||
socket.emit('error', { message: 'Bu listeye erişim yetkiniz yok' });
|
||||
return;
|
||||
}
|
||||
|
||||
socket.join(`list_${listId}`);
|
||||
socket.emit('joined_list', { listId });
|
||||
|
||||
// Diğer kullanıcılara bildir
|
||||
socket.to(`list_${listId}`).emit('user_joined_list', {
|
||||
user: socket.user,
|
||||
listId
|
||||
});
|
||||
|
||||
console.log(`📋 ${socket.user.username} liste ${listId} odasına katıldı`);
|
||||
} catch (error) {
|
||||
console.error('Liste odası katılım hatası:', error);
|
||||
socket.emit('error', { message: 'Liste odası katılımında hata oluştu' });
|
||||
}
|
||||
});
|
||||
|
||||
// Liste odalarından ayrılma
|
||||
socket.on('leave_list', (data) => {
|
||||
const { listId } = data;
|
||||
socket.leave(`list_${listId}`);
|
||||
socket.to(`list_${listId}`).emit('user_left_list', {
|
||||
user: socket.user,
|
||||
listId
|
||||
});
|
||||
console.log(`📋 ${socket.user.username} liste ${listId} odasından ayrıldı`);
|
||||
});
|
||||
|
||||
// Ürün ekleme
|
||||
socket.on('item_added', async (data) => {
|
||||
try {
|
||||
const { listId, item } = data;
|
||||
|
||||
// Erişim kontrolü
|
||||
const hasAccess = await checkListAccess(socket.user.id, listId, prisma);
|
||||
if (!hasAccess) {
|
||||
socket.emit('error', { message: 'Bu listeye erişim yetkiniz yok' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Aktivite kaydı oluştur
|
||||
await prisma.activity.create({
|
||||
data: {
|
||||
listId: listId,
|
||||
userId: socket.user.id,
|
||||
action: 'item_added',
|
||||
details: {
|
||||
itemName: item.customName || item.product?.name,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Diğer kullanıcılara bildir
|
||||
socket.to(`list_${listId}`).emit('item_added', {
|
||||
item,
|
||||
user: socket.user,
|
||||
listId
|
||||
});
|
||||
|
||||
console.log(`➕ ${socket.user.username} liste ${listId}'e ürün ekledi`);
|
||||
} catch (error) {
|
||||
console.error('Ürün ekleme hatası:', error);
|
||||
socket.emit('error', { message: 'Ürün ekleme sırasında hata oluştu' });
|
||||
}
|
||||
});
|
||||
|
||||
// Ürün güncelleme
|
||||
socket.on('item_updated', async (data) => {
|
||||
try {
|
||||
const { listId, itemId, changes } = data;
|
||||
|
||||
// Erişim kontrolü
|
||||
const hasAccess = await checkListAccess(socket.user.id, listId, prisma);
|
||||
if (!hasAccess) {
|
||||
socket.emit('error', { message: 'Bu listeye erişim yetkiniz yok' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Aktivite kaydı oluştur
|
||||
await prisma.activity.create({
|
||||
data: {
|
||||
listId: listId,
|
||||
userId: socket.user.id,
|
||||
action: 'item_updated',
|
||||
details: {
|
||||
itemId,
|
||||
changes
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Diğer kullanıcılara bildir
|
||||
socket.to(`list_${listId}`).emit('item_updated', {
|
||||
itemId,
|
||||
changes,
|
||||
user: socket.user,
|
||||
listId
|
||||
});
|
||||
|
||||
console.log(`✏️ ${socket.user.username} liste ${listId}'de ürün güncelledi`);
|
||||
} catch (error) {
|
||||
console.error('Ürün güncelleme hatası:', error);
|
||||
socket.emit('error', { message: 'Ürün güncelleme sırasında hata oluştu' });
|
||||
}
|
||||
});
|
||||
|
||||
// Ürün satın alma durumu değişikliği
|
||||
socket.on('item_purchased', async (data) => {
|
||||
try {
|
||||
const { listId, itemId, isPurchased } = data;
|
||||
|
||||
// Erişim kontrolü
|
||||
const hasAccess = await checkListAccess(socket.user.id, listId, prisma);
|
||||
if (!hasAccess) {
|
||||
socket.emit('error', { message: 'Bu listeye erişim yetkiniz yok' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Aktivite kaydı oluştur
|
||||
await prisma.activity.create({
|
||||
data: {
|
||||
listId: listId,
|
||||
userId: socket.user.id,
|
||||
action: isPurchased ? 'item_purchased' : 'item_unpurchased',
|
||||
details: {
|
||||
itemId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Diğer kullanıcılara bildir
|
||||
socket.to(`list_${listId}`).emit('item_purchased', {
|
||||
itemId,
|
||||
isPurchased,
|
||||
purchasedBy: socket.user.id,
|
||||
purchasedAt: isPurchased ? new Date() : null,
|
||||
user: socket.user,
|
||||
listId
|
||||
});
|
||||
|
||||
console.log(`${isPurchased ? '✅' : '❌'} ${socket.user.username} liste ${listId}'de ürün durumunu değiştirdi`);
|
||||
} catch (error) {
|
||||
console.error('Ürün satın alma durumu hatası:', error);
|
||||
socket.emit('error', { message: 'Ürün durumu değişikliğinde hata oluştu' });
|
||||
}
|
||||
});
|
||||
|
||||
// Ürün silme
|
||||
socket.on('item_deleted', async (data) => {
|
||||
try {
|
||||
const { listId, itemId } = data;
|
||||
|
||||
// Erişim kontrolü
|
||||
const hasAccess = await checkListAccess(socket.user.id, listId, prisma);
|
||||
if (!hasAccess) {
|
||||
socket.emit('error', { message: 'Bu listeye erişim yetkiniz yok' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Aktivite kaydı oluştur
|
||||
await prisma.activity.create({
|
||||
data: {
|
||||
listId: listId,
|
||||
userId: socket.user.id,
|
||||
action: 'item_deleted',
|
||||
details: {
|
||||
itemId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Diğer kullanıcılara bildir
|
||||
socket.to(`list_${listId}`).emit('item_deleted', {
|
||||
itemId,
|
||||
user: socket.user,
|
||||
listId
|
||||
});
|
||||
|
||||
console.log(`🗑️ ${socket.user.username} liste ${listId}'den ürün sildi`);
|
||||
} catch (error) {
|
||||
console.error('Ürün silme hatası:', error);
|
||||
socket.emit('error', { message: 'Ürün silme sırasında hata oluştu' });
|
||||
}
|
||||
});
|
||||
|
||||
// Kullanıcı yazıyor bildirimi
|
||||
socket.on('typing_start', (data) => {
|
||||
const { listId } = data;
|
||||
socket.to(`list_${listId}`).emit('user_typing', {
|
||||
user: socket.user,
|
||||
listId
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('typing_stop', (data) => {
|
||||
const { listId } = data;
|
||||
socket.to(`list_${listId}`).emit('user_stopped_typing', {
|
||||
user: socket.user,
|
||||
listId
|
||||
});
|
||||
});
|
||||
|
||||
// Ping-pong (bağlantı kontrolü)
|
||||
socket.on('ping', () => {
|
||||
socket.emit('pong');
|
||||
});
|
||||
|
||||
// Bağlantı kopma
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log(`👋 Kullanıcı ayrıldı: ${socket.user.username} (${reason})`);
|
||||
|
||||
// Tüm liste odalarına ayrılma bildirimi gönder
|
||||
socket.rooms.forEach(room => {
|
||||
if (room.startsWith('list_')) {
|
||||
socket.to(room).emit('user_disconnected', {
|
||||
user: socket.user
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Hata yakalama
|
||||
socket.on('error', (error) => {
|
||||
console.error(`Socket hatası (${socket.user.username}):`, error);
|
||||
});
|
||||
});
|
||||
|
||||
// Bağlantı hatası yakalama
|
||||
io.on('connect_error', (error) => {
|
||||
console.error('Socket.IO bağlantı hatası:', error);
|
||||
});
|
||||
|
||||
console.log('🔌 Socket.IO handler başlatıldı');
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcının listeye erişim yetkisi var mı kontrol et
|
||||
*/
|
||||
async function checkListAccess(userId, listId, prisma) {
|
||||
try {
|
||||
const access = await prisma.shoppingList.findFirst({
|
||||
where: {
|
||||
id: listId,
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId: userId
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
return !!access;
|
||||
} catch (error) {
|
||||
console.error('Liste erişim kontrolü hatası:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = socketHandler;
|
||||
42
backend/src/utils/dbCharsetSetup.js
Normal file
42
backend/src/utils/dbCharsetSetup.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const url = require('url');
|
||||
|
||||
function getDatabaseName(databaseUrl) {
|
||||
try {
|
||||
const parsed = new url.URL(databaseUrl);
|
||||
// pathname like "/dbname"; strip leading '/'
|
||||
const dbPath = parsed.pathname || '';
|
||||
const dbName = dbPath.startsWith('/') ? dbPath.slice(1) : dbPath;
|
||||
return dbName.split('?')[0];
|
||||
} catch {
|
||||
// Fallback: basic parsing
|
||||
const parts = (databaseUrl || '').split('/');
|
||||
return (parts[parts.length - 1] || '').split('?')[0];
|
||||
}
|
||||
}
|
||||
|
||||
async function setupUtf8mb4(prisma) {
|
||||
const databaseUrl = process.env.DATABASE_URL || '';
|
||||
const dbName = getDatabaseName(databaseUrl);
|
||||
if (!dbName) {
|
||||
console.warn('⚠️ DATABASE_URL bulunamadı veya veritabanı adı çözümlenemedi. UTF8MB4 kurulumu atlandı.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try altering database charset/collation
|
||||
try {
|
||||
await prisma.$executeRawUnsafe(`ALTER DATABASE \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||
console.log('✅ Veritabanı varsayılan karakter seti/collation utf8mb4 olarak ayarlandı.');
|
||||
} catch (err) {
|
||||
console.warn('⚠️ Veritabanı charset ayarlanırken hata oluştu:', err?.message || err);
|
||||
}
|
||||
|
||||
// Try converting categories table
|
||||
try {
|
||||
await prisma.$executeRawUnsafe('ALTER TABLE `categories` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
|
||||
console.log('✅ `categories` tablosu utf8mb4 olarak dönüştürüldü.');
|
||||
} catch (err) {
|
||||
console.warn('⚠️ `categories` tablosu dönüştürülürken hata oluştu:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setupUtf8mb4 };
|
||||
379
backend/src/utils/helpers.js
Normal file
379
backend/src/utils/helpers.js
Normal file
@@ -0,0 +1,379 @@
|
||||
const crypto = require('crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
/**
|
||||
* Sayfalama hesaplamaları
|
||||
* @param {number} page - Sayfa numarası
|
||||
* @param {number} limit - Sayfa başına öğe sayısı
|
||||
* @returns {object} Skip ve take değerleri
|
||||
*/
|
||||
const calculatePagination = (page = 1, limit = 10) => {
|
||||
const pageNum = Math.max(1, parseInt(page));
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(limit)));
|
||||
|
||||
return {
|
||||
skip: (pageNum - 1) * limitNum,
|
||||
take: limitNum,
|
||||
page: pageNum,
|
||||
limit: limitNum
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sayfalama meta bilgilerini oluştur
|
||||
* @param {number} total - Toplam öğe sayısı
|
||||
* @param {number} page - Mevcut sayfa
|
||||
* @param {number} limit - Sayfa başına öğe sayısı
|
||||
* @returns {object} Meta bilgileri
|
||||
*/
|
||||
const createPaginationMeta = (total, page, limit) => {
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* API yanıt formatı
|
||||
* @param {boolean} success - İşlem başarılı mı
|
||||
* @param {string} message - Mesaj
|
||||
* @param {any} data - Veri
|
||||
* @param {object} meta - Meta bilgileri
|
||||
* @returns {object} Formatlanmış yanıt
|
||||
*/
|
||||
const createResponse = (success, message, data = null, meta = null) => {
|
||||
const response = {
|
||||
success,
|
||||
message,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (data !== null) {
|
||||
response.data = data;
|
||||
}
|
||||
|
||||
if (meta !== null) {
|
||||
response.meta = meta;
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Başarılı yanıt oluştur
|
||||
* @param {string} message - Mesaj
|
||||
* @param {any} data - Veri
|
||||
* @param {object} meta - Meta bilgileri
|
||||
* @returns {object} Başarılı yanıt
|
||||
*/
|
||||
const successResponse = (message, data = null, meta = null) => {
|
||||
return createResponse(true, message, data, meta);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hata yanıtı oluştur
|
||||
* @param {string} message - Hata mesajı
|
||||
* @param {any} data - Hata detayları
|
||||
* @returns {object} Hata yanıtı
|
||||
*/
|
||||
const errorResponse = (message, data = null) => {
|
||||
return createResponse(false, message, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* JWT token oluştur
|
||||
* @param {object} payload - Token içeriği
|
||||
* @param {string} expiresIn - Geçerlilik süresi
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
const generateToken = (payload, expiresIn = process.env.JWT_EXPIRES_IN || '7d') => {
|
||||
return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn });
|
||||
};
|
||||
|
||||
/**
|
||||
* JWT token doğrula
|
||||
* @param {string} token - JWT token
|
||||
* @returns {object} Decoded token
|
||||
*/
|
||||
const verifyToken = (token) => {
|
||||
return jwt.verify(token, process.env.JWT_SECRET);
|
||||
};
|
||||
|
||||
/**
|
||||
* Rastgele string oluştur
|
||||
* @param {number} length - String uzunluğu
|
||||
* @returns {string} Rastgele string
|
||||
*/
|
||||
const generateRandomString = (length = 32) => {
|
||||
return crypto.randomBytes(length).toString('hex');
|
||||
};
|
||||
|
||||
/**
|
||||
* Güvenli karşılaştırma
|
||||
* @param {string} a - İlk string
|
||||
* @param {string} b - İkinci string
|
||||
* @returns {boolean} Eşit mi
|
||||
*/
|
||||
const safeCompare = (a, b) => {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
};
|
||||
|
||||
/**
|
||||
* Slug oluştur
|
||||
* @param {string} text - Metin
|
||||
* @returns {string} Slug
|
||||
*/
|
||||
const createSlug = (text) => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/ç/g, 'c')
|
||||
.replace(/ğ/g, 'g')
|
||||
.replace(/ı/g, 'i')
|
||||
.replace(/ö/g, 'o')
|
||||
.replace(/ş/g, 's')
|
||||
.replace(/ü/g, 'u')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim('-');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tarih formatla
|
||||
* @param {Date} date - Tarih
|
||||
* @param {string} locale - Dil kodu
|
||||
* @returns {string} Formatlanmış tarih
|
||||
*/
|
||||
const formatDate = (date, locale = 'tr-TR') => {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
/**
|
||||
* Fiyat formatla
|
||||
* @param {number} price - Fiyat
|
||||
* @param {string} currency - Para birimi
|
||||
* @returns {string} Formatlanmış fiyat
|
||||
*/
|
||||
const formatPrice = (price, currency = 'TRY') => {
|
||||
return new Intl.NumberFormat('tr-TR', {
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dosya boyutu formatla
|
||||
* @param {number} bytes - Byte cinsinden boyut
|
||||
* @returns {string} Formatlanmış boyut
|
||||
*/
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
/**
|
||||
* E-posta adresi maskele
|
||||
* @param {string} email - E-posta adresi
|
||||
* @returns {string} Maskelenmiş e-posta
|
||||
*/
|
||||
const maskEmail = (email) => {
|
||||
const [username, domain] = email.split('@');
|
||||
const maskedUsername = username.charAt(0) + '*'.repeat(username.length - 2) + username.charAt(username.length - 1);
|
||||
return `${maskedUsername}@${domain}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Telefon numarası maskele
|
||||
* @param {string} phone - Telefon numarası
|
||||
* @returns {string} Maskelenmiş telefon
|
||||
*/
|
||||
const maskPhone = (phone) => {
|
||||
if (phone.length < 4) return phone;
|
||||
return phone.substring(0, 2) + '*'.repeat(phone.length - 4) + phone.substring(phone.length - 2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dizi elemanlarını karıştır
|
||||
* @param {Array} array - Karıştırılacak dizi
|
||||
* @returns {Array} Karıştırılmış dizi
|
||||
*/
|
||||
const shuffleArray = (array) => {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dizi elemanlarını grupla
|
||||
* @param {Array} array - Gruplandırılacak dizi
|
||||
* @param {string} key - Gruplama anahtarı
|
||||
* @returns {object} Gruplandırılmış obje
|
||||
*/
|
||||
const groupBy = (array, key) => {
|
||||
return array.reduce((groups, item) => {
|
||||
const group = item[key];
|
||||
groups[group] = groups[group] || [];
|
||||
groups[group].push(item);
|
||||
return groups;
|
||||
}, {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Debounce fonksiyonu
|
||||
* @param {Function} func - Debounce edilecek fonksiyon
|
||||
* @param {number} wait - Bekleme süresi (ms)
|
||||
* @returns {Function} Debounce edilmiş fonksiyon
|
||||
*/
|
||||
const debounce = (func, wait) => {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Throttle fonksiyonu
|
||||
* @param {Function} func - Throttle edilecek fonksiyon
|
||||
* @param {number} limit - Limit süresi (ms)
|
||||
* @returns {Function} Throttle edilmiş fonksiyon
|
||||
*/
|
||||
const throttle = (func, limit) => {
|
||||
let inThrottle;
|
||||
return function(...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Renk hex kodunu RGB'ye çevir
|
||||
* @param {string} hex - Hex renk kodu
|
||||
* @returns {object} RGB değerleri
|
||||
*/
|
||||
const hexToRgb = (hex) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* RGB değerlerini hex koduna çevir
|
||||
* @param {number} r - Kırmızı değeri
|
||||
* @param {number} g - Yeşil değeri
|
||||
* @param {number} b - Mavi değeri
|
||||
* @returns {string} Hex renk kodu
|
||||
*/
|
||||
const rgbToHex = (r, g, b) => {
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* URL'den query parametrelerini çıkar
|
||||
* @param {string} url - URL
|
||||
* @returns {object} Query parametreleri
|
||||
*/
|
||||
const parseQueryParams = (url) => {
|
||||
const params = {};
|
||||
const urlObj = new URL(url);
|
||||
urlObj.searchParams.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nesne değerlerini temizle (null, undefined, empty string)
|
||||
* @param {object} obj - Temizlenecek nesne
|
||||
* @returns {object} Temizlenmiş nesne
|
||||
*/
|
||||
const cleanObject = (obj) => {
|
||||
const cleaned = {};
|
||||
Object.keys(obj).forEach(key => {
|
||||
if (obj[key] !== null && obj[key] !== undefined && obj[key] !== '') {
|
||||
cleaned[key] = obj[key];
|
||||
}
|
||||
});
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
/**
|
||||
* İki tarih arasındaki farkı hesapla
|
||||
* @param {Date} date1 - İlk tarih
|
||||
* @param {Date} date2 - İkinci tarih
|
||||
* @returns {object} Tarih farkı
|
||||
*/
|
||||
const dateDifference = (date1, date2) => {
|
||||
const diffTime = Math.abs(date2 - date1);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
const diffHours = Math.ceil(diffTime / (1000 * 60 * 60));
|
||||
const diffMinutes = Math.ceil(diffTime / (1000 * 60));
|
||||
|
||||
return {
|
||||
milliseconds: diffTime,
|
||||
minutes: diffMinutes,
|
||||
hours: diffHours,
|
||||
days: diffDays
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
calculatePagination,
|
||||
createPaginationMeta,
|
||||
createResponse,
|
||||
successResponse,
|
||||
errorResponse,
|
||||
generateToken,
|
||||
verifyToken,
|
||||
generateRandomString,
|
||||
safeCompare,
|
||||
createSlug,
|
||||
formatDate,
|
||||
formatPrice,
|
||||
formatFileSize,
|
||||
maskEmail,
|
||||
maskPhone,
|
||||
shuffleArray,
|
||||
groupBy,
|
||||
debounce,
|
||||
throttle,
|
||||
hexToRgb,
|
||||
rgbToHex,
|
||||
parseQueryParams,
|
||||
cleanObject,
|
||||
dateDifference
|
||||
};
|
||||
407
backend/src/utils/validators.js
Normal file
407
backend/src/utils/validators.js
Normal file
@@ -0,0 +1,407 @@
|
||||
const { body, param, query } = require('express-validator');
|
||||
|
||||
/**
|
||||
* CUID validation helper
|
||||
*/
|
||||
const validateCuid = (value) => {
|
||||
if (!value) return false;
|
||||
// CUID format: 20-30 characters, lowercase letters and numbers
|
||||
const cuidRegex = /^[a-z0-9]{20,30}$/;
|
||||
return cuidRegex.test(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Kullanıcı kayıt doğrulama kuralları
|
||||
*/
|
||||
const validateUserRegistration = [
|
||||
body('username')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 30 })
|
||||
.withMessage('Kullanıcı adı 3-30 karakter arasında olmalı')
|
||||
.matches(/^[a-zA-Z0-9_]+$/)
|
||||
.withMessage('Kullanıcı adı sadece harf, rakam ve alt çizgi içerebilir'),
|
||||
|
||||
body('email')
|
||||
.isEmail()
|
||||
.withMessage('Geçerli bir e-posta adresi girin')
|
||||
.normalizeEmail(),
|
||||
|
||||
body('password')
|
||||
.isLength({ min: 6 })
|
||||
.withMessage('Şifre en az 6 karakter olmalı')
|
||||
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
|
||||
.withMessage('Şifre en az bir küçük harf, bir büyük harf ve bir rakam içermeli'),
|
||||
|
||||
body('firstName')
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Ad 2-50 karakter arasında olmalı')
|
||||
.matches(/^[a-zA-ZçğıöşüÇĞIİÖŞÜ\s]+$/)
|
||||
.withMessage('Ad sadece harf içerebilir'),
|
||||
|
||||
body('lastName')
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Soyad 2-50 karakter arasında olmalı')
|
||||
.matches(/^[a-zA-ZçğıöşüÇĞIİÖŞÜ\s]+$/)
|
||||
.withMessage('Soyad sadece harf içerebilir')
|
||||
];
|
||||
|
||||
/**
|
||||
* Kullanıcı giriş doğrulama kuralları
|
||||
*/
|
||||
const validateUserLogin = [
|
||||
body('username')
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.withMessage('Kullanıcı adı veya e-posta gerekli'),
|
||||
|
||||
body('password')
|
||||
.notEmpty()
|
||||
.withMessage('Şifre gerekli')
|
||||
];
|
||||
|
||||
/**
|
||||
* Şifre değiştirme doğrulama kuralları
|
||||
*/
|
||||
const validatePasswordChange = [
|
||||
body('currentPassword')
|
||||
.notEmpty()
|
||||
.withMessage('Mevcut şifre gerekli'),
|
||||
|
||||
body('newPassword')
|
||||
.isLength({ min: 6 })
|
||||
.withMessage('Yeni şifre en az 6 karakter olmalı')
|
||||
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
|
||||
.withMessage('Yeni şifre en az bir küçük harf, bir büyük harf ve bir rakam içermeli')
|
||||
];
|
||||
|
||||
/**
|
||||
* Profil güncelleme doğrulama kuralları
|
||||
*/
|
||||
const validateProfileUpdate = [
|
||||
body('firstName')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Ad 2-50 karakter arasında olmalı')
|
||||
.matches(/^[a-zA-ZçğıöşüÇĞIİÖŞÜ\s]+$/)
|
||||
.withMessage('Ad sadece harf içerebilir'),
|
||||
|
||||
body('lastName')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Soyad 2-50 karakter arasında olmalı')
|
||||
.matches(/^[a-zA-ZçğıöşüÇĞIİÖŞÜ\s]+$/)
|
||||
.withMessage('Soyad sadece harf içerebilir'),
|
||||
|
||||
body('email')
|
||||
.optional()
|
||||
.isEmail()
|
||||
.withMessage('Geçerli bir e-posta adresi girin')
|
||||
.normalizeEmail()
|
||||
];
|
||||
|
||||
/**
|
||||
* Liste oluşturma doğrulama kuralları
|
||||
*/
|
||||
const validateListCreation = [
|
||||
body('name')
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 100 })
|
||||
.withMessage('Liste adı 2-100 karakter arasında olmalı'),
|
||||
|
||||
body('description')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 500 })
|
||||
.withMessage('Açıklama en fazla 500 karakter olmalı'),
|
||||
|
||||
body('color')
|
||||
.optional()
|
||||
.matches(/^#[0-9A-F]{6}$/i)
|
||||
.withMessage('Geçerli bir hex renk kodu girin (örn: #FF5722)')
|
||||
];
|
||||
|
||||
/**
|
||||
* Liste güncelleme doğrulama kuralları
|
||||
*/
|
||||
const validateListUpdate = [
|
||||
body('name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 100 })
|
||||
.withMessage('Liste adı 2-100 karakter arasında olmalı'),
|
||||
|
||||
body('description')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 500 })
|
||||
.withMessage('Açıklama en fazla 500 karakter olmalı'),
|
||||
|
||||
body('color')
|
||||
.optional()
|
||||
.matches(/^#[0-9A-F]{6}$/i)
|
||||
.withMessage('Geçerli bir hex renk kodu girin (örn: #FF5722)')
|
||||
];
|
||||
|
||||
/**
|
||||
* Liste öğesi ekleme doğrulama kuralları
|
||||
*/
|
||||
const validateListItemCreation = [
|
||||
body('name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 1, max: 100 })
|
||||
.withMessage('Ürün adı 1-100 karakter arasında olmalı'),
|
||||
|
||||
body('quantity')
|
||||
.optional()
|
||||
.isInt({ min: 1 })
|
||||
.withMessage('Miktar pozitif bir sayı olmalı'),
|
||||
|
||||
body('unit')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 20 })
|
||||
.withMessage('Birim en fazla 20 karakter olmalı'),
|
||||
|
||||
body('notes')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 200 })
|
||||
.withMessage('Notlar en fazla 200 karakter olmalı'),
|
||||
|
||||
body('productId')
|
||||
.optional()
|
||||
.custom(validateCuid)
|
||||
.withMessage('Geçerli bir ürün ID\'si girin'),
|
||||
|
||||
body('estimatedPrice')
|
||||
.optional()
|
||||
.isFloat({ min: 0 })
|
||||
.withMessage('Tahmini fiyat pozitif bir sayı olmalı'),
|
||||
|
||||
body('priority')
|
||||
.optional()
|
||||
.isIn(['LOW', 'MEDIUM', 'HIGH'])
|
||||
.withMessage('Öncelik LOW, MEDIUM veya HIGH olmalı')
|
||||
];
|
||||
|
||||
/**
|
||||
* Liste öğesi güncelleme doğrulama kuralları
|
||||
*/
|
||||
const validateListItemUpdate = [
|
||||
body('name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 1, max: 100 })
|
||||
.withMessage('Ürün adı 1-100 karakter arasında olmalı'),
|
||||
|
||||
body('quantity')
|
||||
.optional()
|
||||
.isInt({ min: 1 })
|
||||
.withMessage('Miktar pozitif bir sayı olmalı'),
|
||||
|
||||
body('unit')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 20 })
|
||||
.withMessage('Birim en fazla 20 karakter olmalı'),
|
||||
|
||||
body('notes')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 200 })
|
||||
.withMessage('Notlar en fazla 200 karakter olmalı'),
|
||||
|
||||
body('isPurchased')
|
||||
.optional()
|
||||
.isBoolean()
|
||||
.withMessage('Satın alındı durumu boolean değer olmalı'),
|
||||
|
||||
body('actualPrice')
|
||||
.optional()
|
||||
.isFloat({ min: 0 })
|
||||
.withMessage('Gerçek fiyat pozitif bir sayı olmalı')
|
||||
];
|
||||
|
||||
/**
|
||||
* Ürün oluşturma doğrulama kuralları
|
||||
*/
|
||||
const validateProductCreation = [
|
||||
body('name')
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 100 })
|
||||
.withMessage('Ürün adı 2-100 karakter arasında olmalı'),
|
||||
|
||||
body('barcode')
|
||||
.optional()
|
||||
.isLength({ min: 8, max: 20 })
|
||||
.withMessage('Barkod 8-20 karakter arasında olmalı'),
|
||||
|
||||
body('categoryId')
|
||||
.isUUID()
|
||||
.withMessage('Geçerli bir kategori ID\'si girin'),
|
||||
|
||||
body('price')
|
||||
.optional()
|
||||
.isFloat({ min: 0 })
|
||||
.withMessage('Fiyat pozitif bir sayı olmalı'),
|
||||
|
||||
body('location')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage('Konum en fazla 100 karakter olmalı')
|
||||
];
|
||||
|
||||
/**
|
||||
* Kategori oluşturma doğrulama kuralları
|
||||
*/
|
||||
const validateCategoryCreation = [
|
||||
body('name')
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Kategori adı 2-50 karakter arasında olmalı'),
|
||||
|
||||
body('color')
|
||||
.matches(/^#[0-9A-F]{6}$/i)
|
||||
.withMessage('Geçerli bir hex renk kodu girin (örn: #FF5722)'),
|
||||
|
||||
body('description')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 200 })
|
||||
.withMessage('Açıklama en fazla 200 karakter olmalı')
|
||||
];
|
||||
|
||||
/**
|
||||
* UUID parametresi doğrulama
|
||||
*/
|
||||
const validateUUIDParam = (paramName) => [
|
||||
param(paramName).isUUID().withMessage(`Geçerli bir ${paramName} girin`)
|
||||
];
|
||||
|
||||
/**
|
||||
* CUID parametresi doğrulama
|
||||
*/
|
||||
const validateCuidParam = (paramName) => [
|
||||
param(paramName).custom(validateCuid).withMessage(`Geçerli bir ${paramName} girin`)
|
||||
];
|
||||
|
||||
/**
|
||||
* Sayfalama doğrulama kuralları
|
||||
*/
|
||||
const validatePagination = [
|
||||
query('page')
|
||||
.optional()
|
||||
.isInt({ min: 1 })
|
||||
.withMessage('Sayfa numarası pozitif bir sayı olmalı'),
|
||||
|
||||
query('limit')
|
||||
.optional()
|
||||
.isInt({ min: 1, max: 100 })
|
||||
.withMessage('Limit 1-100 arasında olmalı')
|
||||
];
|
||||
|
||||
/**
|
||||
* Arama doğrulama kuralları
|
||||
*/
|
||||
const validateSearch = [
|
||||
query('q')
|
||||
.isLength({ min: 2 })
|
||||
.withMessage('Arama terimi en az 2 karakter olmalı')
|
||||
];
|
||||
|
||||
/**
|
||||
* Tarih aralığı doğrulama kuralları
|
||||
*/
|
||||
const validateDateRange = [
|
||||
query('startDate')
|
||||
.optional()
|
||||
.isISO8601()
|
||||
.withMessage('Geçerli bir başlangıç tarihi girin'),
|
||||
|
||||
query('endDate')
|
||||
.optional()
|
||||
.isISO8601()
|
||||
.withMessage('Geçerli bir bitiş tarihi girin')
|
||||
];
|
||||
|
||||
/**
|
||||
* Device token doğrulama kuralları
|
||||
*/
|
||||
const validateDeviceToken = [
|
||||
body('token')
|
||||
.trim()
|
||||
.isLength({ min: 10 })
|
||||
.withMessage('Geçerli bir device token girin'),
|
||||
|
||||
body('deviceInfo')
|
||||
.optional()
|
||||
.isObject()
|
||||
.withMessage('Device bilgisi obje formatında olmalı')
|
||||
];
|
||||
|
||||
/**
|
||||
* Liste üyesi ekleme doğrulama kuralları
|
||||
*/
|
||||
const validateAddListMember = [
|
||||
body('userId')
|
||||
.isUUID()
|
||||
.withMessage('Geçerli bir kullanıcı ID\'si girin'),
|
||||
|
||||
body('role')
|
||||
.optional()
|
||||
.isIn(['member', 'editor'])
|
||||
.withMessage('Geçerli bir rol seçin (member, editor)')
|
||||
];
|
||||
|
||||
/**
|
||||
* Bildirim ayarları doğrulama kuralları
|
||||
*/
|
||||
const validateNotificationSettings = [
|
||||
body('settings')
|
||||
.isObject()
|
||||
.withMessage('Ayarlar obje formatında olmalı')
|
||||
];
|
||||
|
||||
/**
|
||||
* Fiyat geçmişi ekleme doğrulama kuralları
|
||||
*/
|
||||
const validatePriceHistory = [
|
||||
body('price')
|
||||
.isFloat({ min: 0 })
|
||||
.withMessage('Fiyat pozitif bir sayı olmalı'),
|
||||
|
||||
body('location')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage('Konum en fazla 100 karakter olmalı')
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
validateUserRegistration,
|
||||
validateUserLogin,
|
||||
validatePasswordChange,
|
||||
validateProfileUpdate,
|
||||
validateListCreation,
|
||||
validateListUpdate,
|
||||
validateListItemCreation,
|
||||
validateListItemUpdate,
|
||||
validateProductCreation,
|
||||
validateCategoryCreation,
|
||||
validateUUIDParam,
|
||||
validateCuidParam,
|
||||
validatePagination,
|
||||
validateSearch,
|
||||
validateDateRange,
|
||||
validateDeviceToken,
|
||||
validateAddListMember,
|
||||
validateNotificationSettings,
|
||||
validatePriceHistory,
|
||||
validateCuid
|
||||
};
|
||||
Reference in New Issue
Block a user