hMarket Trae ilk versiyon
This commit is contained in:
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Production
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.suo
|
||||||
|
*.ntvs
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
backend/uploads/
|
||||||
74
README.md
Normal file
74
README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# HMarket - Çok Kullanıcılı Market Listesi Uygulaması
|
||||||
|
|
||||||
|
## 📋 Proje Hakkında
|
||||||
|
HMarket, gerçek zamanlı işbirliği özellikli market listesi uygulamasıdır. Kullanıcılar market listelerini oluşturabilir, paylaşabilir ve gerçek zamanlı olarak güncelleyebilir.
|
||||||
|
|
||||||
|
## 🚀 Teknolojiler
|
||||||
|
- **Frontend**: React 19, TypeScript, Material-UI, React Query
|
||||||
|
- **Backend**: Node.js, Express, Socket.IO, Prisma
|
||||||
|
- **Veritabanı**: SQLite (geliştirme), PostgreSQL (production)
|
||||||
|
|
||||||
|
## 📁 Proje Yapısı
|
||||||
|
```
|
||||||
|
hmarket_trae/
|
||||||
|
├── frontend/ # React uygulaması
|
||||||
|
├── backend/ # Node.js API
|
||||||
|
├── docs/ # Dokümantasyon
|
||||||
|
└── README.md # Bu dosya
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Kurulum
|
||||||
|
|
||||||
|
### Gereksinimler
|
||||||
|
- Node.js 16+
|
||||||
|
- npm veya yarn
|
||||||
|
|
||||||
|
### Adımlar
|
||||||
|
1. Projeyi klonlayın
|
||||||
|
2. Backend kurulumu:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:push
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Frontend kurulumu:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Proje Boyutu Optimizasyonu
|
||||||
|
|
||||||
|
### Mevcut Durum
|
||||||
|
- **Toplam boyut**: ~1.16 GB
|
||||||
|
- **Frontend**: ~840 MB (node_modules: ~840 MB)
|
||||||
|
- **Backend**: ~347 MB (node_modules: ~347 MB)
|
||||||
|
|
||||||
|
### Optimizasyon Yapılan İşlemler
|
||||||
|
1. ✅ Ana dizine `.gitignore` dosyası eklendi
|
||||||
|
2. ✅ Build klasörleri temizlendi
|
||||||
|
3. ✅ npm cache temizlendi
|
||||||
|
4. ✅ Gereksiz dosyalar kaldırıldı
|
||||||
|
|
||||||
|
### Boyut Azaltma Önerileri
|
||||||
|
- `node_modules` klasörleri Git'e dahil edilmemelidir
|
||||||
|
- Production build'lerde sadece gerekli dosyalar kullanılmalıdır
|
||||||
|
- Docker kullanımında multi-stage build tercih edilmelidir
|
||||||
|
|
||||||
|
## 🎯 Son Güncellemeler
|
||||||
|
- ✅ EstimatedPrice alanı tamamen kaldırıldı
|
||||||
|
- ✅ Ürün simgeleri düzeltildi (ürün resmi varsa gösterilir)
|
||||||
|
- ✅ "Ne zaman eklendiği" tarihi kaldırıldı
|
||||||
|
- ✅ Proje boyutu optimize edildi
|
||||||
|
|
||||||
|
## 🔍 Geliştirme Notları
|
||||||
|
- Icon sorunu çözüldü: Artık ürünlerin kendi resimleri gösteriliyor
|
||||||
|
- Proje boyutunun büyük olmasının ana sebebi `node_modules` klasörleridir
|
||||||
|
- `.gitignore` dosyası ile bu klasörler versiyon kontrolüne dahil edilmeyecektir
|
||||||
|
|
||||||
|
## 📝 Lisans
|
||||||
|
MIT License
|
||||||
101
Yapi.md
Normal file
101
Yapi.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
Lütfen aşağıdaki özelliklere sahip, çok kullanıcılı, çapraz platform mobil market listesi uygulaması geliştir. Tüm geliştirme süreci ve açıklamalar Türkçe olacak.
|
||||||
|
|
||||||
|
TEMEL ÖZELLİKLER:
|
||||||
|
|
||||||
|
1. Çoklu Kullanıcı ve Gerçek Zamanlı Senkronizasyon:
|
||||||
|
|
||||||
|
* Birden fazla kullanıcı aynı listeyi gerçek zamanlı paylaşabilecek
|
||||||
|
* Bir kullanıcının yaptığı değişiklikler (ürün ekleme, silme, durum güncelleme) anlık olarak tüm kullanıcılara yansıyacak
|
||||||
|
* mqtt veya benzeri teknoloji ile gerçek zamanlı veri aktarımı sağlanacak
|
||||||
|
|
||||||
|
2. Bildirim Sistemi:
|
||||||
|
|
||||||
|
* Listeden ürün eklendiğinde/silindiğinde bildirim gönderilecek
|
||||||
|
* Ürün durumu değiştiğinde (örn: alındı olarak işaretlendi) bildirim gösterilecek
|
||||||
|
* Push notification desteği olacak
|
||||||
|
|
||||||
|
3. Liste ve Ürün Yönetimi:
|
||||||
|
|
||||||
|
* Kullanıcılar yeni liste oluşturabilecek
|
||||||
|
* Listeye ürün ekleme yöntemleri:
|
||||||
|
|
||||||
|
* Manuel yazarak ürün ekleme
|
||||||
|
* Var olan ürünlerden seçerek ekleme
|
||||||
|
* (Opsiyonel) Barkod okuyarak ürün ekleme
|
||||||
|
|
||||||
|
* Her ürün için aşağıdaki bilgiler kaydedilecek:
|
||||||
|
|
||||||
|
* Ürün Adı
|
||||||
|
* Fiyat
|
||||||
|
* Ekleme Tarihi
|
||||||
|
* Barkod
|
||||||
|
* Kategori
|
||||||
|
|
||||||
|
4. Fiyat Geçmişi:
|
||||||
|
|
||||||
|
* Her ürün için fiyat geçmişi tutulacak
|
||||||
|
* Aynı ürünün farklı zamanlarda farklı fiyatları kaydedilecek
|
||||||
|
* Fiyat değişimleri grafik veya liste olarak gösterilebilecek
|
||||||
|
|
||||||
|
5. Yönetim Arayüzü (Admin Panel):
|
||||||
|
|
||||||
|
* Kullanıcı ekleme/düzenleme/silme işlemleri
|
||||||
|
* Gelişmiş dashboard (istatistikler, grafikler, özet bilgiler)
|
||||||
|
* Tüm listeleri görüntüleme ve yönetme
|
||||||
|
* Tüm ürünleri görüntüleme ve yönetme
|
||||||
|
* Ürünlerin geçmiş fiyatlarını listeleme ve analiz etme
|
||||||
|
|
||||||
|
TEKNİK GEREKSINIMLER:
|
||||||
|
|
||||||
|
* Veritabanı: MariaDB
|
||||||
|
* Platform Desteği:
|
||||||
|
|
||||||
|
* Web (masaüstü bilgisayarlar için)
|
||||||
|
* Android
|
||||||
|
* iOS
|
||||||
|
|
||||||
|
* Mimari: Modern, ölçeklenebilir ve bakımı kolay bir yapı kullan
|
||||||
|
* Güvenlik: Kullanıcı kimlik doğrulama ve yetkilendirme sistemi
|
||||||
|
|
||||||
|
BEKLENEN ÇIKTILAR:
|
||||||
|
|
||||||
|
1. Uygulamanın tüm kaynak kodları
|
||||||
|
2. Veritabanı şeması ve kurulum scripti
|
||||||
|
3. Kurulum ve çalıştırma talimatları (Türkçe)
|
||||||
|
4. Kullanılan teknolojiler ve framework'ler hakkında açıklama
|
||||||
|
5. API dokümantasyonu (varsa)
|
||||||
|
|
||||||
|
GELİŞTİRME YAKLAŞIMI:
|
||||||
|
|
||||||
|
* Her adımı açıklayarak ilerle
|
||||||
|
* Önce temel özellikleri çalışır hale getir, sonra ek özellikleri ekle
|
||||||
|
* Kod örneklerini yorumlarla açıkla
|
||||||
|
* Karşılaşılabilecek sorunlar ve çözüm önerileri sun
|
||||||
|
* Modern ve kullanıcı dostu bir arayüz tasarla
|
||||||
|
|
||||||
|
EK NOTLAR:
|
||||||
|
|
||||||
|
* Performans optimizasyonuna dikkat et
|
||||||
|
* Mobil cihazlar için responsive tasarım uygula, Ürün ve Listelerde swipe yapısı kullanabilirsin
|
||||||
|
* Offline çalışma modu düşünülebilir (senkronizasyon ile)
|
||||||
|
* Önerilerim ve ek özellik taleplerimi kabul edecek esneklikte ol
|
||||||
|
|
||||||
|
Lütfen geliştirmeye başlamadan önce:
|
||||||
|
|
||||||
|
1. Kullanacağın teknoloji stack'ini öner
|
||||||
|
2. Projenin genel mimarisini açıkla
|
||||||
|
3. Geliştirme aşamalarını sırala
|
||||||
|
|
||||||
|
Hazır olduğunda geliştirmeye başlayalım!
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Liste Paylaş dediğimde
|
||||||
|
"Girilen bilgilerde hatalar var" diyor
|
||||||
|
|
||||||
|
Ürün oluşturulurken Birimi de yazılmalı, Ürün seçilirken de varsayılan olarak gösterilmelidir.
|
||||||
|
|
||||||
|
Google ile kullanıcı oluşturma ve Login olmada hatalar alıyorum
|
||||||
|
|
||||||
|
Projede gereksiz olan kod, eklenti ve dosyaları temizleyip sade bir hale getirelim
|
||||||
53
backend/.env.example
Normal file
53
backend/.env.example
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# HMarket Backend Environment Variables
|
||||||
|
|
||||||
|
# Sunucu Ayarları
|
||||||
|
PORT=7001
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Veritabanı Bağlantısı (MariaDB)
|
||||||
|
DATABASE_URL="mysql://username:password@localhost:3306/hmarket"
|
||||||
|
|
||||||
|
# JWT Ayarları
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-here
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# CORS Ayarları
|
||||||
|
CORS_ORIGIN=http://localhost:7000
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Google OAuth Ayarları
|
||||||
|
GOOGLE_CLIENT_ID=your-google-client-id
|
||||||
|
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||||
|
GOOGLE_CALLBACK_URL=http://localhost:7001/api/auth/google/callback
|
||||||
|
|
||||||
|
# Session Ayarları
|
||||||
|
SESSION_SECRET=your-session-secret-key
|
||||||
|
|
||||||
|
# Firebase Cloud Messaging (Push Notifications)
|
||||||
|
FIREBASE_PROJECT_ID=your-firebase-project-id
|
||||||
|
FIREBASE_PRIVATE_KEY_ID=your-private-key-id
|
||||||
|
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nyour-private-key-here\n-----END PRIVATE KEY-----\n"
|
||||||
|
FIREBASE_CLIENT_EMAIL=your-service-account@your-project.iam.gserviceaccount.com
|
||||||
|
FIREBASE_CLIENT_ID=your-client-id
|
||||||
|
FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth
|
||||||
|
FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
|
# File Upload
|
||||||
|
MAX_FILE_SIZE=5242880
|
||||||
|
UPLOAD_PATH=uploads/
|
||||||
|
|
||||||
|
# Email Ayarları (Davet linkleri için)
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASS=your-app-password
|
||||||
|
|
||||||
|
# Redis (Opsiyonel - Session ve Cache için)
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
6757
backend/package-lock.json
generated
Normal file
6757
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
backend/package.json
Normal file
59
backend/package.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "hmarket-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "HMarket Backend API - Çok kullanıcılı market listesi uygulaması",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "nodemon src/server.js",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:seed": "node prisma/seed.js",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"market",
|
||||||
|
"shopping",
|
||||||
|
"list",
|
||||||
|
"realtime",
|
||||||
|
"nodejs",
|
||||||
|
"express",
|
||||||
|
"socket.io"
|
||||||
|
],
|
||||||
|
"author": "HMarket Team",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.7.1",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"express-session": "^1.18.2",
|
||||||
|
"express-validator": "^7.0.1",
|
||||||
|
"firebase-admin": "^12.0.0",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
"socket.io": "^4.7.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.8",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"nodemon": "^3.0.2",
|
||||||
|
"prisma": "^5.7.1",
|
||||||
|
"supertest": "^6.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "node prisma/seed.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`email` VARCHAR(191) NOT NULL,
|
||||||
|
`username` VARCHAR(191) NOT NULL,
|
||||||
|
`firstName` VARCHAR(191) NOT NULL,
|
||||||
|
`lastName` VARCHAR(191) NOT NULL,
|
||||||
|
`password` VARCHAR(191) NULL,
|
||||||
|
`avatar` VARCHAR(191) NULL,
|
||||||
|
`googleId` VARCHAR(191) NULL,
|
||||||
|
`authProvider` VARCHAR(191) NOT NULL DEFAULT 'local',
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`isAdmin` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
`lastLoginAt` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `users_email_key`(`email`),
|
||||||
|
UNIQUE INDEX `users_username_key`(`username`),
|
||||||
|
UNIQUE INDEX `users_googleId_key`(`googleId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `device_tokens` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NOT NULL,
|
||||||
|
`token` VARCHAR(191) NOT NULL,
|
||||||
|
`platform` VARCHAR(191) NOT NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `device_tokens_token_key`(`token`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `shopping_lists` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`description` VARCHAR(191) NULL,
|
||||||
|
`color` VARCHAR(191) NOT NULL DEFAULT '#2196F3',
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`ownerId` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `list_members` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`listId` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NOT NULL,
|
||||||
|
`role` VARCHAR(191) NOT NULL DEFAULT 'member',
|
||||||
|
`joinedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `list_members_listId_userId_key`(`listId`, `userId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `list_invitations` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`listId` VARCHAR(191) NOT NULL,
|
||||||
|
`email` VARCHAR(191) NOT NULL,
|
||||||
|
`role` VARCHAR(191) NOT NULL DEFAULT 'member',
|
||||||
|
`token` VARCHAR(191) NOT NULL,
|
||||||
|
`isUsed` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`expiresAt` DATETIME(3) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `list_invitations_token_key`(`token`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `categories` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`description` VARCHAR(191) NULL,
|
||||||
|
`icon` VARCHAR(191) NULL,
|
||||||
|
`color` VARCHAR(191) NOT NULL DEFAULT '#757575',
|
||||||
|
`sortOrder` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `categories_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `products` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`description` VARCHAR(191) NULL,
|
||||||
|
`barcode` VARCHAR(191) NULL,
|
||||||
|
`brand` VARCHAR(191) NULL,
|
||||||
|
`unit` VARCHAR(191) NULL,
|
||||||
|
`categoryId` VARCHAR(191) NULL,
|
||||||
|
`image` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `products_barcode_key`(`barcode`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `list_items` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`listId` VARCHAR(191) NOT NULL,
|
||||||
|
`productId` VARCHAR(191) NULL,
|
||||||
|
`customName` VARCHAR(191) NULL,
|
||||||
|
`quantity` DOUBLE NOT NULL DEFAULT 1,
|
||||||
|
`unit` VARCHAR(191) NOT NULL DEFAULT 'adet',
|
||||||
|
`price` DECIMAL(10, 2) NULL,
|
||||||
|
`note` VARCHAR(191) NULL,
|
||||||
|
`isPurchased` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`purchasedAt` DATETIME(3) NULL,
|
||||||
|
`purchasedBy` VARCHAR(191) NULL,
|
||||||
|
`priority` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`sortOrder` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `price_history` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`productId` VARCHAR(191) NOT NULL,
|
||||||
|
`price` DECIMAL(10, 2) NOT NULL,
|
||||||
|
`store` VARCHAR(191) NULL,
|
||||||
|
`location` VARCHAR(191) NULL,
|
||||||
|
`source` VARCHAR(191) NOT NULL DEFAULT 'user',
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `notifications` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NOT NULL,
|
||||||
|
`title` VARCHAR(191) NOT NULL,
|
||||||
|
`message` VARCHAR(191) NOT NULL,
|
||||||
|
`type` VARCHAR(191) NOT NULL,
|
||||||
|
`data` JSON NULL,
|
||||||
|
`isRead` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `activities` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`listId` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NOT NULL,
|
||||||
|
`action` VARCHAR(191) NOT NULL,
|
||||||
|
`details` JSON NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `settings` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`key` VARCHAR(191) NOT NULL,
|
||||||
|
`value` VARCHAR(191) NOT NULL,
|
||||||
|
`type` VARCHAR(191) NOT NULL DEFAULT 'string',
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `settings_key_key`(`key`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `device_tokens` ADD CONSTRAINT `device_tokens_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `shopping_lists` ADD CONSTRAINT `shopping_lists_ownerId_fkey` FOREIGN KEY (`ownerId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `list_members` ADD CONSTRAINT `list_members_listId_fkey` FOREIGN KEY (`listId`) REFERENCES `shopping_lists`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `list_members` ADD CONSTRAINT `list_members_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `list_invitations` ADD CONSTRAINT `list_invitations_listId_fkey` FOREIGN KEY (`listId`) REFERENCES `shopping_lists`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `products` ADD CONSTRAINT `products_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `categories`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `list_items` ADD CONSTRAINT `list_items_listId_fkey` FOREIGN KEY (`listId`) REFERENCES `shopping_lists`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `list_items` ADD CONSTRAINT `list_items_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `products`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `price_history` ADD CONSTRAINT `price_history_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `products`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `notifications` ADD CONSTRAINT `notifications_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `activities` ADD CONSTRAINT `activities_listId_fkey` FOREIGN KEY (`listId`) REFERENCES `shopping_lists`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `activities` ADD CONSTRAINT `activities_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "mysql"
|
||||||
232
backend/prisma/schema.prisma
Normal file
232
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
// HMarket Veritabanı Şeması
|
||||||
|
// MariaDB için Prisma ORM konfigürasyonu
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kullanıcı modeli
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
username String @unique
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
password String? // OAuth kullanıcıları için opsiyonel
|
||||||
|
avatar String?
|
||||||
|
googleId String? @unique // Google OAuth ID
|
||||||
|
authProvider String @default("local") // "local", "google"
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
isAdmin Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
|
||||||
|
// İlişkiler
|
||||||
|
ownedLists ShoppingList[] @relation("ListOwner")
|
||||||
|
sharedLists ListMember[]
|
||||||
|
notifications Notification[]
|
||||||
|
deviceTokens DeviceToken[]
|
||||||
|
activities Activity[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cihaz token'ları (Push notification için)
|
||||||
|
model DeviceToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
token String @unique
|
||||||
|
platform String // "ios", "android", "web"
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("device_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alışveriş listesi modeli
|
||||||
|
model ShoppingList {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
color String @default("#2196F3") // Hex renk kodu
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
ownerId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// İlişkiler
|
||||||
|
owner User @relation("ListOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
members ListMember[]
|
||||||
|
items ListItem[]
|
||||||
|
activities Activity[]
|
||||||
|
invitations ListInvitation[]
|
||||||
|
|
||||||
|
@@map("shopping_lists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste üyeleri (çoklu kullanıcı desteği)
|
||||||
|
model ListMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
listId String
|
||||||
|
userId String
|
||||||
|
role String @default("member") // "admin", "member", "viewer"
|
||||||
|
joinedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// İlişkiler
|
||||||
|
list ShoppingList @relation(fields: [listId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([listId, userId])
|
||||||
|
@@map("list_members")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste davetleri
|
||||||
|
model ListInvitation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
listId String
|
||||||
|
email String
|
||||||
|
role String @default("member")
|
||||||
|
token String @unique
|
||||||
|
isUsed Boolean @default(false)
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
list ShoppingList @relation(fields: [listId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("list_invitations")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ürün kategorileri
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
description String?
|
||||||
|
icon String? // Icon adı veya URL
|
||||||
|
color String @default("#757575")
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// İlişkiler
|
||||||
|
products Product[]
|
||||||
|
|
||||||
|
@@map("categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ürün modeli
|
||||||
|
model Product {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
barcode String? @unique
|
||||||
|
brand String?
|
||||||
|
unit String? // Ürün varsayılan birimi
|
||||||
|
categoryId String?
|
||||||
|
image String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// İlişkiler
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
|
listItems ListItem[]
|
||||||
|
priceHistory PriceHistory[]
|
||||||
|
|
||||||
|
@@map("products")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste öğeleri
|
||||||
|
model ListItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
listId String
|
||||||
|
productId String?
|
||||||
|
customName String? // Ürün yoksa manuel isim
|
||||||
|
quantity Float @default(1)
|
||||||
|
unit String @default("adet") // "kg", "lt", "adet", vb.
|
||||||
|
price Decimal? @db.Decimal(10, 2)
|
||||||
|
note String?
|
||||||
|
isPurchased Boolean @default(false)
|
||||||
|
purchasedAt DateTime?
|
||||||
|
purchasedBy String?
|
||||||
|
priority Int @default(0) // 0: normal, 1: önemli, 2: acil
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// İlişkiler
|
||||||
|
list ShoppingList @relation(fields: [listId], references: [id], onDelete: Cascade)
|
||||||
|
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@map("list_items")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fiyat geçmişi
|
||||||
|
model PriceHistory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
productId String
|
||||||
|
price Decimal @db.Decimal(10, 2)
|
||||||
|
store String? // Mağaza adı
|
||||||
|
location String? // Konum bilgisi
|
||||||
|
source String @default("user") // "user", "api", "scraping"
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// İlişkiler
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("price_history")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bildirimler
|
||||||
|
model Notification {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
title String
|
||||||
|
message String
|
||||||
|
type String // "list_shared", "item_added", "item_purchased", "price_alert"
|
||||||
|
data Json? // Ek veri (JSON format)
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// İlişkiler
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("notifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktivite logu (gerçek zamanlı güncellemeler için)
|
||||||
|
model Activity {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
listId String
|
||||||
|
userId String
|
||||||
|
action String // "item_added", "item_removed", "item_updated", "item_purchased"
|
||||||
|
details Json? // Aktivite detayları
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// İlişkiler
|
||||||
|
list ShoppingList @relation(fields: [listId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("activities")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sistem ayarları
|
||||||
|
model Setting {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
value String
|
||||||
|
type String @default("string") // "string", "number", "boolean", "json"
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("settings")
|
||||||
|
}
|
||||||
389
backend/prisma/seed.js
Normal file
389
backend/prisma/seed.js
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Veritabanı seed işlemi başlatılıyor...');
|
||||||
|
|
||||||
|
// Admin kullanıcısı oluştur
|
||||||
|
const hashedPassword = await bcrypt.hash('admin123', 12);
|
||||||
|
|
||||||
|
const adminUser = await prisma.user.upsert({
|
||||||
|
where: { email: 'admin@hmarket.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'admin@hmarket.com',
|
||||||
|
username: 'admin',
|
||||||
|
firstName: 'Admin',
|
||||||
|
lastName: 'User',
|
||||||
|
password: hashedPassword,
|
||||||
|
isAdmin: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Admin kullanıcısı oluşturuldu:', adminUser.email);
|
||||||
|
|
||||||
|
// Test kullanıcıları oluştur
|
||||||
|
const testUsers = [
|
||||||
|
{
|
||||||
|
email: 'ahmet@test.com',
|
||||||
|
username: 'ahmet',
|
||||||
|
firstName: 'Ahmet',
|
||||||
|
lastName: 'Yılmaz',
|
||||||
|
password: await bcrypt.hash('test123', 12),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'ayse@test.com',
|
||||||
|
username: 'ayse',
|
||||||
|
firstName: 'Ayşe',
|
||||||
|
lastName: 'Kaya',
|
||||||
|
password: await bcrypt.hash('test123', 12),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'mehmet@test.com',
|
||||||
|
username: 'mehmet',
|
||||||
|
firstName: 'Mehmet',
|
||||||
|
lastName: 'Demir',
|
||||||
|
password: await bcrypt.hash('test123', 12),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const userData of testUsers) {
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email: userData.email },
|
||||||
|
update: {},
|
||||||
|
create: userData,
|
||||||
|
});
|
||||||
|
console.log('✅ Test kullanıcısı oluşturuldu:', user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kategoriler oluştur
|
||||||
|
const categories = [
|
||||||
|
{ name: 'Meyve & Sebze', description: 'Taze meyve ve sebzeler', icon: '🥕', color: '#4CAF50' },
|
||||||
|
{ name: 'Et & Tavuk', description: 'Kırmızı et, beyaz et ve şarküteri ürünleri', icon: '🥩', color: '#F44336' },
|
||||||
|
{ name: 'Süt Ürünleri', description: 'Süt, peynir, yoğurt ve diğer süt ürünleri', icon: '🥛', color: '#2196F3' },
|
||||||
|
{ name: 'Fırın & Pastane', description: 'Ekmek, pasta ve unlu mamuller', icon: '🍞', color: '#FF9800' },
|
||||||
|
{ name: 'Atıştırmalık', description: 'Çikolata, bisküvi ve atıştırmalık ürünler', icon: '🍿', color: '#9C27B0' },
|
||||||
|
{ name: 'İçecek', description: 'Alkolsüz içecekler ve sıcak içecekler', icon: '🥤', color: '#00BCD4' },
|
||||||
|
{ name: 'Temizlik', description: 'Ev temizlik ürünleri ve deterjanlar', icon: '🧽', color: '#607D8B' },
|
||||||
|
{ name: 'Kişisel Bakım', description: 'Kişisel hijyen ve bakım ürünleri', icon: '🧴', color: '#E91E63' },
|
||||||
|
{ name: 'Bebek', description: 'Bebek bakım ürünleri ve mama', icon: '🍼', color: '#FFEB3B' },
|
||||||
|
{ name: 'Dondurulmuş', description: 'Dondurulmuş gıda ürünleri', icon: '🧊', color: '#3F51B5' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const categoryData of categories) {
|
||||||
|
const category = await prisma.category.upsert({
|
||||||
|
where: { name: categoryData.name },
|
||||||
|
update: {},
|
||||||
|
create: categoryData,
|
||||||
|
});
|
||||||
|
console.log('✅ Kategori oluşturuldu:', category.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Örnek ürünler oluştur
|
||||||
|
const products = [
|
||||||
|
// Meyve & Sebze
|
||||||
|
{ name: 'Elma', categoryName: 'Meyve & Sebze', barcode: '1234567890123', averagePrice: 12.50 },
|
||||||
|
{ name: 'Muz', categoryName: 'Meyve & Sebze', barcode: '1234567890124', averagePrice: 18.00 },
|
||||||
|
{ name: 'Domates', categoryName: 'Meyve & Sebze', barcode: '1234567890125', averagePrice: 8.75 },
|
||||||
|
{ name: 'Salatalık', categoryName: 'Meyve & Sebze', barcode: '1234567890126', averagePrice: 6.50 },
|
||||||
|
{ name: 'Soğan', categoryName: 'Meyve & Sebze', barcode: '1234567890127', averagePrice: 4.25 },
|
||||||
|
{ name: 'Patates', categoryName: 'Meyve & Sebze', barcode: '1234567890137', averagePrice: 5.75 },
|
||||||
|
{ name: 'Havuç', categoryName: 'Meyve & Sebze', barcode: '1234567890138', averagePrice: 7.50 },
|
||||||
|
{ name: 'Biber', categoryName: 'Meyve & Sebze', barcode: '1234567890139', averagePrice: 15.00 },
|
||||||
|
{ name: 'Portakal', categoryName: 'Meyve & Sebze', barcode: '1234567890140', averagePrice: 10.00 },
|
||||||
|
{ name: 'Limon', categoryName: 'Meyve & Sebze', barcode: '1234567890141', averagePrice: 8.00 },
|
||||||
|
|
||||||
|
// Süt Ürünleri
|
||||||
|
{ name: 'Süt 1L', categoryName: 'Süt Ürünleri', barcode: '1234567890128', averagePrice: 8.75 },
|
||||||
|
{ name: 'Yoğurt 500g', categoryName: 'Süt Ürünleri', barcode: '1234567890129', averagePrice: 12.50 },
|
||||||
|
{ name: 'Beyaz Peynir', categoryName: 'Süt Ürünleri', barcode: '1234567890130', averagePrice: 45.00 },
|
||||||
|
{ name: 'Kaşar Peyniri', categoryName: 'Süt Ürünleri', barcode: '1234567890131', averagePrice: 65.00 },
|
||||||
|
{ name: 'Tereyağı', categoryName: 'Süt Ürünleri', barcode: '1234567890142', averagePrice: 35.00 },
|
||||||
|
{ name: 'Yumurta 30\'lu', categoryName: 'Süt Ürünleri', barcode: '1234567890143', averagePrice: 85.00 },
|
||||||
|
{ name: 'Krema', categoryName: 'Süt Ürünleri', barcode: '1234567890144', averagePrice: 15.50 },
|
||||||
|
|
||||||
|
// Fırın & Pastane
|
||||||
|
{ name: 'Ekmek', categoryName: 'Fırın & Pastane', barcode: '1234567890132', averagePrice: 4.50 },
|
||||||
|
{ name: 'Simit', categoryName: 'Fırın & Pastane', barcode: '1234567890133', averagePrice: 2.50 },
|
||||||
|
{ name: 'Pide', categoryName: 'Fırın & Pastane', barcode: '1234567890145', averagePrice: 8.00 },
|
||||||
|
{ name: 'Çörek', categoryName: 'Fırın & Pastane', barcode: '1234567890146', averagePrice: 6.50 },
|
||||||
|
{ name: 'Kek', categoryName: 'Fırın & Pastane', barcode: '1234567890147', averagePrice: 25.00 },
|
||||||
|
|
||||||
|
// İçecek
|
||||||
|
{ name: 'Su 1.5L', categoryName: 'İçecek', barcode: '1234567890134', averagePrice: 2.50 },
|
||||||
|
{ name: 'Çay', categoryName: 'İçecek', barcode: '1234567890135', averagePrice: 35.00 },
|
||||||
|
{ name: 'Kahve', categoryName: 'İçecek', barcode: '1234567890136', averagePrice: 85.00 },
|
||||||
|
{ name: 'Meyve Suyu', categoryName: 'İçecek', barcode: '1234567890148', averagePrice: 12.50 },
|
||||||
|
{ name: 'Kola', categoryName: 'İçecek', barcode: '1234567890149', averagePrice: 8.75 },
|
||||||
|
{ name: 'Ayran', categoryName: 'İçecek', barcode: '1234567890150', averagePrice: 4.50 },
|
||||||
|
|
||||||
|
// Et & Tavuk
|
||||||
|
{ name: 'Tavuk But', categoryName: 'Et & Tavuk', barcode: '1234567890151', averagePrice: 45.00 },
|
||||||
|
{ name: 'Dana Kıyma', categoryName: 'Et & Tavuk', barcode: '1234567890152', averagePrice: 120.00 },
|
||||||
|
{ name: 'Köfte', categoryName: 'Et & Tavuk', barcode: '1234567890153', averagePrice: 85.00 },
|
||||||
|
{ name: 'Sosis', categoryName: 'Et & Tavuk', barcode: '1234567890154', averagePrice: 25.00 },
|
||||||
|
|
||||||
|
// Temizlik
|
||||||
|
{ name: 'Deterjan', categoryName: 'Temizlik', barcode: '1234567890155', averagePrice: 45.00 },
|
||||||
|
{ name: 'Sabun', categoryName: 'Temizlik', barcode: '1234567890156', averagePrice: 8.50 },
|
||||||
|
{ name: 'Şampuan', categoryName: 'Temizlik', barcode: '1234567890157', averagePrice: 35.00 },
|
||||||
|
{ name: 'Diş Macunu', categoryName: 'Kişisel Bakım', barcode: '1234567890158', averagePrice: 18.50 },
|
||||||
|
|
||||||
|
// Atıştırmalık
|
||||||
|
{ name: 'Çikolata', categoryName: 'Atıştırmalık', barcode: '1234567890159', averagePrice: 12.50 },
|
||||||
|
{ name: 'Bisküvi', categoryName: 'Atıştırmalık', barcode: '1234567890160', averagePrice: 8.75 },
|
||||||
|
{ name: 'Cips', categoryName: 'Atıştırmalık', barcode: '1234567890161', averagePrice: 6.50 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const productData of products) {
|
||||||
|
const category = await prisma.category.findUnique({
|
||||||
|
where: { name: productData.categoryName }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
const product = await prisma.product.upsert({
|
||||||
|
where: { barcode: productData.barcode },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: productData.name,
|
||||||
|
barcode: productData.barcode,
|
||||||
|
categoryId: category.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Ürün oluşturuldu:', product.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Örnek alışveriş listeleri oluştur
|
||||||
|
const ahmetUser = await prisma.user.findUnique({
|
||||||
|
where: { email: 'ahmet@test.com' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const ayseUser = await prisma.user.findUnique({
|
||||||
|
where: { email: 'ayse@test.com' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const mehmetUser = await prisma.user.findUnique({
|
||||||
|
where: { email: 'mehmet@test.com' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ahmet için alışveriş listeleri
|
||||||
|
if (ahmetUser) {
|
||||||
|
const shoppingLists = [
|
||||||
|
{
|
||||||
|
name: 'Haftalık Alışveriş',
|
||||||
|
description: 'Bu haftanın market alışverişi',
|
||||||
|
color: '#4CAF50',
|
||||||
|
items: [
|
||||||
|
{ productName: 'Elma', quantity: 2, unit: 'kg', price: 15.50 },
|
||||||
|
{ productName: 'Süt 1L', quantity: 1, unit: 'adet', price: 8.75 },
|
||||||
|
{ customName: 'Deterjan', quantity: 1, unit: 'adet', price: 25.00, note: 'Çamaşır deterjanı' },
|
||||||
|
{ productName: 'Ekmek', quantity: 2, unit: 'adet', price: 9.00 },
|
||||||
|
{ productName: 'Yumurta 30\'lu', quantity: 1, unit: 'adet', price: 85.00 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Kahvaltı Malzemeleri',
|
||||||
|
description: 'Kahvaltı için gerekli ürünler',
|
||||||
|
color: '#FF9800',
|
||||||
|
items: [
|
||||||
|
{ productName: 'Ekmek', quantity: 2, unit: 'adet', price: 9.00 },
|
||||||
|
{ productName: 'Beyaz Peynir', quantity: 1, unit: 'kg', price: 45.00 },
|
||||||
|
{ productName: 'Tereyağı', quantity: 1, unit: 'adet', price: 35.00 },
|
||||||
|
{ productName: 'Domates', quantity: 1, unit: 'kg', price: 8.75 },
|
||||||
|
{ productName: 'Salatalık', quantity: 2, unit: 'adet', price: 13.00 },
|
||||||
|
{ productName: 'Çay', quantity: 1, unit: 'adet', price: 35.00 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const listData of shoppingLists) {
|
||||||
|
const shoppingList = await prisma.shoppingList.create({
|
||||||
|
data: {
|
||||||
|
name: listData.name,
|
||||||
|
description: listData.description,
|
||||||
|
color: listData.color,
|
||||||
|
ownerId: ahmetUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const itemData of listData.items) {
|
||||||
|
let productId = null;
|
||||||
|
|
||||||
|
if (itemData.productName) {
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { name: itemData.productName }
|
||||||
|
});
|
||||||
|
if (product) {
|
||||||
|
productId = product.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.listItem.create({
|
||||||
|
data: {
|
||||||
|
listId: shoppingList.id,
|
||||||
|
productId: productId,
|
||||||
|
customName: itemData.customName,
|
||||||
|
quantity: itemData.quantity,
|
||||||
|
unit: itemData.unit,
|
||||||
|
price: itemData.price,
|
||||||
|
note: itemData.note,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Alışveriş listesi oluşturuldu:', shoppingList.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ayşe için alışveriş listeleri
|
||||||
|
if (ayseUser) {
|
||||||
|
const ayseShoppingLists = [
|
||||||
|
{
|
||||||
|
name: 'Parti Alışverişi',
|
||||||
|
description: 'Doğum günü partisi için',
|
||||||
|
color: '#E91E63',
|
||||||
|
items: [
|
||||||
|
{ productName: 'Kek', quantity: 1, unit: 'adet', price: 25.00 },
|
||||||
|
{ productName: 'Çikolata', quantity: 5, unit: 'adet', price: 62.50 },
|
||||||
|
{ productName: 'Bisküvi', quantity: 3, unit: 'paket', price: 26.25 },
|
||||||
|
{ productName: 'Kola', quantity: 6, unit: 'adet', price: 52.50 },
|
||||||
|
{ productName: 'Meyve Suyu', quantity: 4, unit: 'adet', price: 50.00 },
|
||||||
|
{ customName: 'Parti Süsleri', quantity: 1, unit: 'set', price: 45.00 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Temizlik Malzemeleri',
|
||||||
|
description: 'Ev temizliği için gerekli ürünler',
|
||||||
|
color: '#607D8B',
|
||||||
|
items: [
|
||||||
|
{ productName: 'Deterjan', quantity: 2, unit: 'adet', price: 90.00 },
|
||||||
|
{ productName: 'Sabun', quantity: 3, unit: 'adet', price: 25.50 },
|
||||||
|
{ productName: 'Şampuan', quantity: 1, unit: 'adet', price: 35.00 },
|
||||||
|
{ customName: 'Cam Temizleyici', quantity: 1, unit: 'adet', price: 18.50 },
|
||||||
|
{ customName: 'Yer Bezi', quantity: 2, unit: 'adet', price: 15.00 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const listData of ayseShoppingLists) {
|
||||||
|
const shoppingList = await prisma.shoppingList.create({
|
||||||
|
data: {
|
||||||
|
name: listData.name,
|
||||||
|
description: listData.description,
|
||||||
|
color: listData.color,
|
||||||
|
ownerId: ayseUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const itemData of listData.items) {
|
||||||
|
let productId = null;
|
||||||
|
|
||||||
|
if (itemData.productName) {
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { name: itemData.productName }
|
||||||
|
});
|
||||||
|
if (product) {
|
||||||
|
productId = product.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.listItem.create({
|
||||||
|
data: {
|
||||||
|
listId: shoppingList.id,
|
||||||
|
productId: productId,
|
||||||
|
customName: itemData.customName,
|
||||||
|
quantity: itemData.quantity,
|
||||||
|
unit: itemData.unit,
|
||||||
|
price: itemData.price,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Alışveriş listesi oluşturuldu:', shoppingList.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mehmet için alışveriş listesi
|
||||||
|
if (mehmetUser) {
|
||||||
|
const mehmetShoppingList = {
|
||||||
|
name: 'Et ve Protein',
|
||||||
|
description: 'Protein ihtiyacı için et ürünleri',
|
||||||
|
color: '#F44336',
|
||||||
|
items: [
|
||||||
|
{ productName: 'Tavuk But', quantity: 2, unit: 'kg', price: 90.00 },
|
||||||
|
{ productName: 'Dana Kıyma', quantity: 1, unit: 'kg', price: 120.00 },
|
||||||
|
{ productName: 'Köfte', quantity: 1, unit: 'kg', price: 85.00 },
|
||||||
|
{ productName: 'Sosis', quantity: 2, unit: 'paket', price: 50.00 },
|
||||||
|
{ productName: 'Yumurta 30\'lu', quantity: 1, unit: 'adet', price: 85.00 },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const shoppingList = await prisma.shoppingList.create({
|
||||||
|
data: {
|
||||||
|
name: mehmetShoppingList.name,
|
||||||
|
description: mehmetShoppingList.description,
|
||||||
|
color: mehmetShoppingList.color,
|
||||||
|
ownerId: mehmetUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const itemData of mehmetShoppingList.items) {
|
||||||
|
let productId = null;
|
||||||
|
|
||||||
|
if (itemData.productName) {
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { name: itemData.productName }
|
||||||
|
});
|
||||||
|
if (product) {
|
||||||
|
productId = product.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.listItem.create({
|
||||||
|
data: {
|
||||||
|
listId: shoppingList.id,
|
||||||
|
productId: productId,
|
||||||
|
quantity: itemData.quantity,
|
||||||
|
unit: itemData.unit,
|
||||||
|
price: itemData.price,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Alışveriş listesi oluşturuldu:', shoppingList.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Tüm örnek alışveriş listeleri oluşturuldu');
|
||||||
|
|
||||||
|
// Sistem ayarları
|
||||||
|
const settings = [
|
||||||
|
{ key: 'app_name', value: 'HMarket', type: 'string' },
|
||||||
|
{ key: 'app_version', value: '1.0.0', type: 'string' },
|
||||||
|
{ key: 'max_list_members', value: '10', type: 'number' },
|
||||||
|
{ key: 'enable_notifications', value: 'true', type: 'boolean' },
|
||||||
|
{ key: 'default_currency', value: 'TL', type: 'string' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const settingData of settings) {
|
||||||
|
await prisma.setting.upsert({
|
||||||
|
where: { key: settingData.key },
|
||||||
|
update: { value: settingData.value },
|
||||||
|
create: settingData,
|
||||||
|
});
|
||||||
|
console.log('✅ Ayar oluşturuldu:', settingData.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎉 Seed işlemi tamamlandı!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Seed işlemi sırasında hata:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
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
|
||||||
|
};
|
||||||
225
docs/database-setup.md
Normal file
225
docs/database-setup.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# 🗄️ Veritabanı Kurulum Talimatları
|
||||||
|
|
||||||
|
## MariaDB Kurulumu
|
||||||
|
|
||||||
|
### Windows için MariaDB Kurulumu
|
||||||
|
|
||||||
|
1. **MariaDB İndirme**
|
||||||
|
- [MariaDB resmi sitesinden](https://mariadb.org/download/) Windows sürümünü indirin
|
||||||
|
- MSI installer'ı çalıştırın
|
||||||
|
|
||||||
|
2. **Kurulum Adımları**
|
||||||
|
```
|
||||||
|
- "Custom" kurulum seçin
|
||||||
|
- Root şifresini belirleyin (güvenli bir şifre seçin)
|
||||||
|
- Port: 3306 (varsayılan)
|
||||||
|
- Character Set: UTF8
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Servis Kontrolü**
|
||||||
|
```bash
|
||||||
|
# Servisin çalışıp çalışmadığını kontrol edin
|
||||||
|
net start MariaDB
|
||||||
|
```
|
||||||
|
|
||||||
|
### MariaDB Veritabanı Oluşturma
|
||||||
|
|
||||||
|
1. **MariaDB'ye Bağlanma**
|
||||||
|
```bash
|
||||||
|
mysql -u root -p
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Veritabanı Oluşturma**
|
||||||
|
```sql
|
||||||
|
-- HMarket veritabanını oluştur
|
||||||
|
CREATE DATABASE hmarket CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Uygulama için kullanıcı oluştur
|
||||||
|
CREATE USER 'hmarket_user'@'localhost' IDENTIFIED BY 'güvenli_şifre_buraya';
|
||||||
|
|
||||||
|
-- Kullanıcıya yetki ver
|
||||||
|
GRANT ALL PRIVILEGES ON hmarket.* TO 'hmarket_user'@'localhost';
|
||||||
|
|
||||||
|
-- Yetkileri yenile
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
|
-- Çıkış
|
||||||
|
EXIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Kurulumu
|
||||||
|
|
||||||
|
### 1. Bağımlılıkları Yükle
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Environment Dosyasını Ayarla
|
||||||
|
```bash
|
||||||
|
# .env.example dosyasını .env olarak kopyala
|
||||||
|
copy .env.example .env
|
||||||
|
|
||||||
|
# .env dosyasını düzenle ve veritabanı bilgilerini gir
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. .env Dosyası Örnek Konfigürasyonu
|
||||||
|
```env
|
||||||
|
# Veritabanı bağlantısı
|
||||||
|
DATABASE_URL="mysql://hmarket_user:güvenli_şifre_buraya@localhost:3306/hmarket"
|
||||||
|
|
||||||
|
# JWT ayarları
|
||||||
|
JWT_SECRET=super-gizli-jwt-anahtari-buraya-yazin
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Sunucu ayarları
|
||||||
|
PORT=3001
|
||||||
|
NODE_ENV=development
|
||||||
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Prisma Kurulumu ve Veritabanı Migrasyonu
|
||||||
|
```bash
|
||||||
|
# Prisma client'ı oluştur
|
||||||
|
npm run db:generate
|
||||||
|
|
||||||
|
# Veritabanı şemasını uygula
|
||||||
|
npm run db:push
|
||||||
|
|
||||||
|
# Başlangıç verilerini yükle
|
||||||
|
npm run db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Sunucuyu Başlat
|
||||||
|
```bash
|
||||||
|
# Geliştirme modu
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Üretim modu
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Veritabanı Şeması Açıklaması
|
||||||
|
|
||||||
|
### Ana Tablolar
|
||||||
|
|
||||||
|
#### 1. **users** - Kullanıcılar
|
||||||
|
- Kullanıcı bilgileri, kimlik doğrulama
|
||||||
|
- Admin yetkileri
|
||||||
|
- Profil bilgileri
|
||||||
|
|
||||||
|
#### 2. **shopping_lists** - Alışveriş Listeleri
|
||||||
|
- Liste bilgileri (isim, açıklama, renk)
|
||||||
|
- Sahiplik bilgisi
|
||||||
|
- Aktiflik durumu
|
||||||
|
|
||||||
|
#### 3. **list_members** - Liste Üyeleri
|
||||||
|
- Çoklu kullanıcı desteği
|
||||||
|
- Rol tabanlı yetkilendirme (admin, member, viewer)
|
||||||
|
- Katılım tarihi
|
||||||
|
|
||||||
|
#### 4. **products** - Ürünler
|
||||||
|
- Ürün bilgileri (isim, barkod, kategori)
|
||||||
|
- Marka bilgisi
|
||||||
|
- Resim desteği
|
||||||
|
|
||||||
|
#### 5. **list_items** - Liste Öğeleri
|
||||||
|
- Listedeki ürünler
|
||||||
|
- Miktar, birim, fiyat bilgileri
|
||||||
|
- Satın alma durumu
|
||||||
|
- Öncelik seviyeleri
|
||||||
|
|
||||||
|
#### 6. **categories** - Kategoriler
|
||||||
|
- Ürün kategorileri
|
||||||
|
- Icon ve renk desteği
|
||||||
|
- Sıralama
|
||||||
|
|
||||||
|
#### 7. **price_history** - Fiyat Geçmişi
|
||||||
|
- Ürün fiyat takibi
|
||||||
|
- Mağaza ve konum bilgisi
|
||||||
|
- Zaman serisi verileri
|
||||||
|
|
||||||
|
#### 8. **notifications** - Bildirimler
|
||||||
|
- Push notification desteği
|
||||||
|
- Bildirim türleri
|
||||||
|
- Okunma durumu
|
||||||
|
|
||||||
|
#### 9. **activities** - Aktivite Logu
|
||||||
|
- Gerçek zamanlı güncellemeler için
|
||||||
|
- Kullanıcı aktiviteleri
|
||||||
|
- JSON veri desteği
|
||||||
|
|
||||||
|
### İlişkiler
|
||||||
|
|
||||||
|
```
|
||||||
|
User (1) -----> (N) ShoppingList (sahiplik)
|
||||||
|
User (N) <----> (N) ShoppingList (üyelik - ListMember tablosu üzerinden)
|
||||||
|
ShoppingList (1) -----> (N) ListItem
|
||||||
|
Product (1) -----> (N) ListItem
|
||||||
|
Product (1) -----> (N) PriceHistory
|
||||||
|
Category (1) -----> (N) Product
|
||||||
|
User (1) -----> (N) Notification
|
||||||
|
User (1) -----> (N) Activity
|
||||||
|
ShoppingList (1) -----> (N) Activity
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performans Optimizasyonları
|
||||||
|
|
||||||
|
### İndeksler
|
||||||
|
Prisma otomatik olarak aşağıdaki indeksleri oluşturur:
|
||||||
|
- Primary key'ler
|
||||||
|
- Unique constraint'ler
|
||||||
|
- Foreign key'ler
|
||||||
|
|
||||||
|
### Ek Optimizasyonlar
|
||||||
|
```sql
|
||||||
|
-- Sık kullanılan sorgular için ek indeksler
|
||||||
|
CREATE INDEX idx_list_items_list_purchased ON list_items(listId, isPurchased);
|
||||||
|
CREATE INDEX idx_activities_list_created ON activities(listId, createdAt);
|
||||||
|
CREATE INDEX idx_price_history_product_created ON price_history(productId, createdAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Yedekleme
|
||||||
|
|
||||||
|
### Otomatik Yedekleme Script'i
|
||||||
|
```bash
|
||||||
|
# Günlük yedekleme
|
||||||
|
mysqldump -u hmarket_user -p hmarket > backup_$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# Haftalık tam yedekleme
|
||||||
|
mysqldump -u hmarket_user -p --all-databases > full_backup_$(date +%Y%m%d).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sorun Giderme
|
||||||
|
|
||||||
|
### Yaygın Hatalar
|
||||||
|
|
||||||
|
1. **Bağlantı Hatası**
|
||||||
|
```
|
||||||
|
Error: P1001: Can't reach database server
|
||||||
|
```
|
||||||
|
- MariaDB servisinin çalıştığını kontrol edin
|
||||||
|
- Bağlantı bilgilerini doğrulayın
|
||||||
|
|
||||||
|
2. **Yetki Hatası**
|
||||||
|
```
|
||||||
|
Error: Access denied for user
|
||||||
|
```
|
||||||
|
- Kullanıcı yetkilerini kontrol edin
|
||||||
|
- Şifrenin doğru olduğundan emin olun
|
||||||
|
|
||||||
|
3. **Şema Hatası**
|
||||||
|
```
|
||||||
|
Error: Table doesn't exist
|
||||||
|
```
|
||||||
|
- `npm run db:push` komutunu çalıştırın
|
||||||
|
- Veritabanının doğru oluşturulduğunu kontrol edin
|
||||||
|
|
||||||
|
### Log Kontrolü
|
||||||
|
```bash
|
||||||
|
# MariaDB loglarını kontrol et
|
||||||
|
tail -f /var/log/mysql/error.log
|
||||||
|
|
||||||
|
# Uygulama loglarını kontrol et
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
46
frontend/README.md
Normal file
46
frontend/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Getting Started with Create React App
|
||||||
|
|
||||||
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm start`
|
||||||
|
|
||||||
|
Runs the app in the development mode.\
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||||
|
|
||||||
|
The page will reload if you make edits.\
|
||||||
|
You will also see any lint errors in the console.
|
||||||
|
|
||||||
|
### `npm test`
|
||||||
|
|
||||||
|
Launches the test runner in the interactive watch mode.\
|
||||||
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `build` folder.\
|
||||||
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.\
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||||
|
|
||||||
|
### `npm run eject`
|
||||||
|
|
||||||
|
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||||
|
|
||||||
|
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||||
|
|
||||||
|
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||||
|
|
||||||
|
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||||
|
|
||||||
|
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||||
17174
frontend/package-lock.json
generated
Normal file
17174
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
frontend/package.json
Normal file
59
frontend/package.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@mui/icons-material": "^7.3.4",
|
||||||
|
"@mui/material": "^7.3.4",
|
||||||
|
"@mui/x-date-pickers": "^8.14.1",
|
||||||
|
"@tanstack/react-query": "^5.90.5",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/node": "^16.18.126",
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.2",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.65.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-router-dom": "^7.9.4",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"web-vitals": "^2.1.4",
|
||||||
|
"yup": "^1.7.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
43
frontend/public/index.html
Normal file
43
frontend/public/index.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>React App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
frontend/public/logo192.png
Normal file
BIN
frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/public/logo512.png
Normal file
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
202
frontend/src/App.tsx
Normal file
202
frontend/src/App.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||||
|
import { CssBaseline, Box } from '@mui/material';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
|
// Context providers
|
||||||
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
import { SocketProvider } from './contexts/SocketContext';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import Navbar from './components/Layout/Navbar';
|
||||||
|
import ProtectedRoute from './components/Auth/ProtectedRoute';
|
||||||
|
import GoogleCallback from './components/Auth/GoogleCallback';
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
import { LoginPage, RegisterPage } from './pages/Auth';
|
||||||
|
import { DashboardPage } from './pages/Dashboard';
|
||||||
|
import { ListsPage, ListDetailPage, ListEditPage } from './pages/Lists';
|
||||||
|
import { ProductsPage } from './pages/Products';
|
||||||
|
import { ProfilePage } from './pages/Profile';
|
||||||
|
import { AdminPage } from './pages/Admin';
|
||||||
|
|
||||||
|
// Create theme
|
||||||
|
const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
main: '#FF5722',
|
||||||
|
light: '#FF8A65',
|
||||||
|
dark: '#D84315',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#4CAF50',
|
||||||
|
light: '#81C784',
|
||||||
|
dark: '#388E3C',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#F5F5F5',
|
||||||
|
paper: '#FFFFFF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||||
|
h4: {
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h5: {
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h6: {
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
textTransform: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiAppBar: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create query client
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<AuthProvider>
|
||||||
|
<SocketProvider>
|
||||||
|
<Router>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||||
|
<Navbar />
|
||||||
|
<Box component="main" sx={{ flexGrow: 1, pt: 2 }}>
|
||||||
|
<Routes>
|
||||||
|
{/* Public routes */}
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/auth/google/callback" element={<GoogleCallback />} />
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
|
<Route path="/" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Navigate to="/dashboard" replace />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/dashboard" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/lists" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ListsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/lists/:listId" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ListDetailPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/lists/:listId/edit" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ListEditPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/products" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProductsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/profile" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProfilePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/admin" element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* Catch all route */}
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Toast notifications */}
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 4000,
|
||||||
|
style: {
|
||||||
|
background: '#363636',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
duration: 3000,
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#4CAF50',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
duration: 5000,
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#f44336',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Router>
|
||||||
|
</SocketProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
61
frontend/src/components/Auth/GoogleCallback.tsx
Normal file
61
frontend/src/components/Auth/GoogleCallback.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const GoogleCallback: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { handleGoogleCallback } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCallback = async () => {
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
const error = searchParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error('Google ile giriş yapılamadı: ' + error);
|
||||||
|
navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await handleGoogleCallback(token);
|
||||||
|
toast.success('Google ile başarıyla giriş yapıldı!');
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google callback error:', error);
|
||||||
|
toast.error('Giriş işlemi sırasında bir hata oluştu');
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Geçersiz callback');
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCallback();
|
||||||
|
}, [searchParams, navigate, handleGoogleCallback]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
Google ile giriş yapılıyor...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GoogleCallback;
|
||||||
39
frontend/src/components/Auth/ProtectedRoute.tsx
Normal file
39
frontend/src/components/Auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Box, CircularProgress } from '@mui/material';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
requireAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, requireAdmin = false }) => {
|
||||||
|
const { isAuthenticated, isLoading, user } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
minHeight="100vh"
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireAdmin && user?.role !== 'ADMIN') {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
282
frontend/src/components/Layout/Navbar.tsx
Normal file
282
frontend/src/components/Layout/Navbar.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
useTheme,
|
||||||
|
useMediaQuery,
|
||||||
|
Drawer,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Divider,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Menu as MenuIcon,
|
||||||
|
Notifications as NotificationsIcon,
|
||||||
|
AccountCircle,
|
||||||
|
Dashboard,
|
||||||
|
List as ListIcon,
|
||||||
|
Inventory,
|
||||||
|
Settings,
|
||||||
|
AdminPanelSettings,
|
||||||
|
Logout,
|
||||||
|
ShoppingCart,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { notificationsAPI } from '../../services/api';
|
||||||
|
|
||||||
|
const Navbar: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const { user, logout, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
// Get unread notifications count
|
||||||
|
const { data: unreadCount } = useQuery({
|
||||||
|
queryKey: ['notifications', 'unread-count'],
|
||||||
|
queryFn: () => notificationsAPI.getUnreadCount(),
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
refetchInterval: 30000, // Refetch every 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleProfileMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrawerToggle = () => {
|
||||||
|
setMobileOpen(!mobileOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
handleMenuClose();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ text: 'Anasayfa', icon: <Dashboard />, path: '/dashboard' },
|
||||||
|
{ text: 'Listeler', icon: <ListIcon />, path: '/lists' },
|
||||||
|
{ text: 'Ürünler', icon: <Inventory />, path: '/products' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (user?.role === 'ADMIN') {
|
||||||
|
menuItems.push({ text: 'Yönetim', icon: <AdminPanelSettings />, path: '/admin' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawer = (
|
||||||
|
<Box sx={{ width: 250 }}>
|
||||||
|
<Box sx={{ p: 2, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<ShoppingCart sx={{ mr: 1, color: 'primary.main' }} />
|
||||||
|
<Typography variant="h6" color="primary.main" fontWeight="bold">
|
||||||
|
HMarket
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<ListItem key={item.text} disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
onClick={() => {
|
||||||
|
navigate(item.path);
|
||||||
|
setMobileOpen(false);
|
||||||
|
}}
|
||||||
|
selected={location.pathname === item.path}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.text} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/profile');
|
||||||
|
setMobileOpen(false);
|
||||||
|
}}
|
||||||
|
selected={location.pathname === '/profile'}
|
||||||
|
>
|
||||||
|
<ListItemIcon><Settings /></ListItemIcon>
|
||||||
|
<ListItemText primary="Profil" />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemButton onClick={handleLogout}>
|
||||||
|
<ListItemIcon><Logout /></ListItemIcon>
|
||||||
|
<ListItemText primary="Çıkış" />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<AppBar position="sticky" color="primary">
|
||||||
|
<Toolbar>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', flexGrow: 1 }}>
|
||||||
|
<ShoppingCart sx={{ mr: 1 }} />
|
||||||
|
<Typography variant="h6" component="div" fontWeight="bold">
|
||||||
|
HMarket
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
variant={location.pathname === '/login' ? 'outlined' : 'text'}
|
||||||
|
>
|
||||||
|
Giriş
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => navigate('/register')}
|
||||||
|
variant={location.pathname === '/register' ? 'outlined' : 'text'}
|
||||||
|
>
|
||||||
|
Kayıt
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBar position="sticky" color="primary">
|
||||||
|
<Toolbar>
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
aria-label="open drawer"
|
||||||
|
edge="start"
|
||||||
|
onClick={handleDrawerToggle}
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', flexGrow: 1 }}>
|
||||||
|
<ShoppingCart sx={{ mr: 1 }} />
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
component="div"
|
||||||
|
fontWeight="bold"
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
>
|
||||||
|
HMarket
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mr: 2 }}>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.text}
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
variant={location.pathname === item.path ? 'outlined' : 'text'}
|
||||||
|
startIcon={item.icon}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<IconButton color="inherit" onClick={() => navigate('/notifications')}>
|
||||||
|
<Badge badgeContent={unreadCount?.data?.data?.count || 0} color="error">
|
||||||
|
<NotificationsIcon />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
size="large"
|
||||||
|
edge="end"
|
||||||
|
aria-label="account of current user"
|
||||||
|
aria-controls="primary-search-account-menu"
|
||||||
|
aria-haspopup="true"
|
||||||
|
onClick={handleProfileMenuOpen}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
{user?.avatar ? (
|
||||||
|
<Avatar src={user.avatar} sx={{ width: 32, height: 32 }} />
|
||||||
|
) : (
|
||||||
|
<AccountCircle />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
{/* Mobile drawer */}
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={handleDrawerToggle}
|
||||||
|
ModalProps={{
|
||||||
|
keepMounted: true, // Better open performance on mobile.
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'block', md: 'none' },
|
||||||
|
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: 250 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Profile menu */}
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
id="primary-search-account-menu"
|
||||||
|
keepMounted
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={() => { navigate('/profile'); handleMenuClose(); }}>
|
||||||
|
<Settings sx={{ mr: 1 }} />
|
||||||
|
Profil
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleLogout}>
|
||||||
|
<Logout sx={{ mr: 1 }} />
|
||||||
|
Çıkış
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
317
frontend/src/components/ShoppingList/ShoppingListFilters.tsx
Normal file
317
frontend/src/components/ShoppingList/ShoppingListFilters.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Collapse,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
useTheme,
|
||||||
|
alpha,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
FilterList,
|
||||||
|
Clear,
|
||||||
|
Sort,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
|
interface ShoppingListFiltersProps {
|
||||||
|
searchTerm: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
filterCompleted?: boolean;
|
||||||
|
onFilterCompletedChange: (value: boolean | undefined) => void;
|
||||||
|
filterCategory: string;
|
||||||
|
onFilterCategoryChange: (value: string) => void;
|
||||||
|
sortBy: string;
|
||||||
|
onSortByChange: (value: string) => void;
|
||||||
|
sortOrder: string;
|
||||||
|
onSortOrderChange: (value: string) => void;
|
||||||
|
categories: Array<{ id: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShoppingListFilters: React.FC<ShoppingListFiltersProps> = ({
|
||||||
|
searchTerm,
|
||||||
|
onSearchChange,
|
||||||
|
filterCompleted,
|
||||||
|
onFilterCompletedChange,
|
||||||
|
filterCategory,
|
||||||
|
onFilterCategoryChange,
|
||||||
|
sortBy,
|
||||||
|
onSortByChange,
|
||||||
|
sortOrder,
|
||||||
|
onSortOrderChange,
|
||||||
|
categories,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||||
|
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
searchTerm ||
|
||||||
|
filterCompleted !== undefined ||
|
||||||
|
filterCategory !== 'all' ||
|
||||||
|
sortBy !== 'createdAt' ||
|
||||||
|
sortOrder !== 'desc';
|
||||||
|
|
||||||
|
const clearAllFilters = () => {
|
||||||
|
onSearchChange('');
|
||||||
|
onFilterCompletedChange(undefined);
|
||||||
|
onFilterCategoryChange('all');
|
||||||
|
onSortByChange('createdAt');
|
||||||
|
onSortOrderChange('desc');
|
||||||
|
setSearchExpanded(false);
|
||||||
|
setFiltersExpanded(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilterStatusLabel = (filterCompleted: boolean | undefined) => {
|
||||||
|
if (filterCompleted === undefined) return 'Tümü';
|
||||||
|
return filterCompleted ? 'Tamamlanan' : 'Bekleyen';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryLabel = (categoryId: string) => {
|
||||||
|
if (categoryId === 'all') return 'Tüm Kategoriler';
|
||||||
|
const category = categories.find(c => c.id === categoryId);
|
||||||
|
return category?.name || 'Bilinmeyen';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortLabel = (sortBy: string) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'createdAt': return 'Eklenme Tarihi';
|
||||||
|
case 'product.name': return 'Ürün Adı';
|
||||||
|
case 'quantity': return 'Miktar';
|
||||||
|
case 'priority': return 'Öncelik';
|
||||||
|
default: return 'Eklenme Tarihi';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
{/* Search and Filter Toggle */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
{/* Search Toggle */}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setSearchExpanded(!searchExpanded)}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: searchTerm
|
||||||
|
? alpha(theme.palette.primary.main, 0.1)
|
||||||
|
: alpha(theme.palette.grey[500], 0.1),
|
||||||
|
color: searchTerm ? 'primary.main' : 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: searchTerm
|
||||||
|
? alpha(theme.palette.primary.main, 0.2)
|
||||||
|
: alpha(theme.palette.grey[500], 0.2),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
{/* Filter Toggle */}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: hasActiveFilters
|
||||||
|
? alpha(theme.palette.primary.main, 0.1)
|
||||||
|
: alpha(theme.palette.grey[500], 0.1),
|
||||||
|
color: hasActiveFilters ? 'primary.main' : 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: hasActiveFilters
|
||||||
|
? alpha(theme.palette.primary.main, 0.2)
|
||||||
|
: alpha(theme.palette.grey[500], 0.2),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilterList />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
{/* Active Filter Chips */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<>
|
||||||
|
{searchTerm && (
|
||||||
|
<Chip
|
||||||
|
label={`"${searchTerm}"`}
|
||||||
|
size="small"
|
||||||
|
onDelete={() => onSearchChange('')}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filterCompleted !== undefined && (
|
||||||
|
<Chip
|
||||||
|
label={getFilterStatusLabel(filterCompleted)}
|
||||||
|
size="small"
|
||||||
|
onDelete={() => onFilterCompletedChange(undefined)}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filterCategory !== 'all' && (
|
||||||
|
<Chip
|
||||||
|
label={getCategoryLabel(filterCategory)}
|
||||||
|
size="small"
|
||||||
|
onDelete={() => onFilterCategoryChange('all')}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(sortBy !== 'createdAt' || sortOrder !== 'desc') && (
|
||||||
|
<Chip
|
||||||
|
label={`${getSortLabel(sortBy)} (${sortOrder === 'asc' ? 'Artan' : 'Azalan'})`}
|
||||||
|
size="small"
|
||||||
|
onDelete={() => {
|
||||||
|
onSortByChange('createdAt');
|
||||||
|
onSortOrderChange('desc');
|
||||||
|
}}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
label="Temizle"
|
||||||
|
size="small"
|
||||||
|
onClick={clearAllFilters}
|
||||||
|
color="secondary"
|
||||||
|
variant="outlined"
|
||||||
|
icon={<Clear />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Collapsible Search Bar */}
|
||||||
|
<Collapse in={searchExpanded}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="Ürünlerde ara..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Search />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: searchTerm && (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => onSearchChange('')}
|
||||||
|
>
|
||||||
|
<Clear />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Expanded Filters */}
|
||||||
|
<Collapse in={filtersExpanded}>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
backgroundColor: alpha(theme.palette.grey[50], 0.5),
|
||||||
|
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr 1fr' },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Status Filter */}
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Durum</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={filterCompleted === undefined ? 'all' : filterCompleted ? 'completed' : 'pending'}
|
||||||
|
label="Durum"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
onFilterCompletedChange(
|
||||||
|
value === 'all' ? undefined : value === 'completed'
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">Tüm Ürünler</MenuItem>
|
||||||
|
<MenuItem value="pending">Bekleyen</MenuItem>
|
||||||
|
<MenuItem value="completed">Tamamlanan</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Kategori</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={filterCategory}
|
||||||
|
label="Kategori"
|
||||||
|
onChange={(e) => onFilterCategoryChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">Tüm Kategoriler</MenuItem>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<MenuItem key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* Sort By */}
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Sırala</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={sortBy}
|
||||||
|
label="Sırala"
|
||||||
|
onChange={(e) => onSortByChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="createdAt">Eklenme Tarihi</MenuItem>
|
||||||
|
<MenuItem value="product.name">Ürün Adı</MenuItem>
|
||||||
|
<MenuItem value="quantity">Miktar</MenuItem>
|
||||||
|
<MenuItem value="priority">Öncelik</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* Sort Order */}
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Sıralama</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={sortOrder}
|
||||||
|
label="Sıralama"
|
||||||
|
onChange={(e) => onSortOrderChange(e.target.value)}
|
||||||
|
startAdornment={
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Sort />
|
||||||
|
</InputAdornment>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="asc">Artan</MenuItem>
|
||||||
|
<MenuItem value="desc">Azalan</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShoppingListFilters;
|
||||||
215
frontend/src/components/ShoppingList/ShoppingListHeader.tsx
Normal file
215
frontend/src/components/ShoppingList/ShoppingListHeader.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Avatar,
|
||||||
|
LinearProgress,
|
||||||
|
Paper,
|
||||||
|
useTheme,
|
||||||
|
alpha,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
ArrowBack,
|
||||||
|
Share,
|
||||||
|
People,
|
||||||
|
ShoppingCart,
|
||||||
|
CheckCircle,
|
||||||
|
Schedule,
|
||||||
|
CalendarToday,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { tr } from 'date-fns/locale';
|
||||||
|
|
||||||
|
interface ShoppingListHeaderProps {
|
||||||
|
list: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
isShared: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
stats: {
|
||||||
|
totalItems: number;
|
||||||
|
completedItems: number;
|
||||||
|
remainingItems: number;
|
||||||
|
completionRate: number;
|
||||||
|
};
|
||||||
|
onBack: () => void;
|
||||||
|
onShare: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShoppingListHeader: React.FC<ShoppingListHeaderProps> = ({
|
||||||
|
list,
|
||||||
|
stats,
|
||||||
|
onBack,
|
||||||
|
onShare,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
{/* Navigation Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onBack}
|
||||||
|
sx={{
|
||||||
|
mr: 1,
|
||||||
|
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: alpha(theme.palette.primary.main, 0.2),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowBack />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
bgcolor: list?.color || theme.palette.primary.main,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
mr: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{list?.isShared ? <Share /> : <ShoppingCart />}
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: { xs: '1.25rem', sm: '1.5rem' },
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{list?.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Creation Date */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mt: 0.5, mb: list?.description ? 0.5 : 0 }}>
|
||||||
|
<CalendarToday sx={{ fontSize: 14, color: 'text.secondary', mr: 0.5 }} />
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{formatDistanceToNow(new Date(list?.createdAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: tr
|
||||||
|
})} oluşturuldu
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{list?.description && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
mt: 0.5,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{list?.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={onShare}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: alpha(theme.palette.secondary.main, 0.1),
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: alpha(theme.palette.secondary.main, 0.2),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<People />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Progress Section */}
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.05)} 0%, ${alpha(theme.palette.secondary.main, 0.05)} 100%)`,
|
||||||
|
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" fontWeight={500}>
|
||||||
|
İlerleme
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" color="primary" fontWeight={600}>
|
||||||
|
{Math.round(stats.completionRate)}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={stats.completionRate}
|
||||||
|
sx={{
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||||
|
'& .MuiLinearProgress-bar': {
|
||||||
|
borderRadius: 4,
|
||||||
|
background: `linear-gradient(90deg, ${theme.palette.success.main} 0%, ${theme.palette.primary.main} 100%)`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1 }}>
|
||||||
|
<ShoppingCart sx={{ fontSize: 20, color: 'primary.main', mr: 0.5 }} />
|
||||||
|
<Typography variant="h6" color="primary" fontWeight={600}>
|
||||||
|
{stats.totalItems}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||||
|
Toplam
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1 }}>
|
||||||
|
<CheckCircle sx={{ fontSize: 20, color: 'success.main', mr: 0.5 }} />
|
||||||
|
<Typography variant="h6" color="success.main" fontWeight={600}>
|
||||||
|
{stats.completedItems}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||||
|
Tamamlanan
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 1 }}>
|
||||||
|
<Schedule sx={{ fontSize: 20, color: 'warning.main', mr: 0.5 }} />
|
||||||
|
<Typography variant="h6" color="warning.main" fontWeight={600}>
|
||||||
|
{stats.remainingItems}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||||
|
Kalan
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShoppingListHeader;
|
||||||
480
frontend/src/components/ShoppingList/ShoppingListItem.tsx
Normal file
480
frontend/src/components/ShoppingList/ShoppingListItem.tsx
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Checkbox,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Avatar,
|
||||||
|
Collapse,
|
||||||
|
useTheme,
|
||||||
|
alpha,
|
||||||
|
Slide,
|
||||||
|
Fade,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
MoreVert,
|
||||||
|
ExpandMore,
|
||||||
|
ExpandLess,
|
||||||
|
Category as CategoryIcon,
|
||||||
|
AccessTime,
|
||||||
|
StickyNote2,
|
||||||
|
LocalOffer,
|
||||||
|
Delete,
|
||||||
|
Edit,
|
||||||
|
CheckCircle,
|
||||||
|
Circle,
|
||||||
|
Fastfood,
|
||||||
|
LocalDrink,
|
||||||
|
CleaningServices,
|
||||||
|
HealthAndSafety,
|
||||||
|
Pets,
|
||||||
|
Home,
|
||||||
|
SportsEsports,
|
||||||
|
MenuBook,
|
||||||
|
LocalGroceryStore,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { tr } from 'date-fns/locale';
|
||||||
|
|
||||||
|
// Kategori simgelerini döndüren fonksiyon
|
||||||
|
const getCategoryIcon = (category?: { name: string; icon?: string; color?: string }, categoryName?: string) => {
|
||||||
|
// Eğer category objesi varsa ve icon varsa, emoji ikonunu kullan
|
||||||
|
if (category?.icon && category.icon.length <= 4) {
|
||||||
|
return <span style={{ fontSize: '20px' }}>{category.icon}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback olarak kategori adına göre Material-UI ikonları
|
||||||
|
const name = category?.name || categoryName;
|
||||||
|
if (!name) return <CategoryIcon />;
|
||||||
|
|
||||||
|
const categoryLower = name.toLowerCase();
|
||||||
|
|
||||||
|
if (categoryLower.includes('gıda') || categoryLower.includes('yiyecek') || categoryLower.includes('food') || categoryLower.includes('meyve') || categoryLower.includes('sebze')) {
|
||||||
|
return <Fastfood />;
|
||||||
|
}
|
||||||
|
if (categoryLower.includes('içecek') || categoryLower.includes('drink') || categoryLower.includes('beverage')) {
|
||||||
|
return <LocalDrink />;
|
||||||
|
}
|
||||||
|
if (categoryLower.includes('temizlik') || categoryLower.includes('cleaning')) {
|
||||||
|
return <CleaningServices />;
|
||||||
|
}
|
||||||
|
if (categoryLower.includes('sağlık') || categoryLower.includes('health') || categoryLower.includes('ilaç')) {
|
||||||
|
return <HealthAndSafety />;
|
||||||
|
}
|
||||||
|
if (categoryLower.includes('pet') || categoryLower.includes('hayvan') || categoryLower.includes('evcil') || categoryLower.includes('bebek')) {
|
||||||
|
return <Pets />;
|
||||||
|
}
|
||||||
|
if (categoryLower.includes('ev') || categoryLower.includes('home') || categoryLower.includes('house')) {
|
||||||
|
return <Home />;
|
||||||
|
}
|
||||||
|
if (categoryLower.includes('oyun') || categoryLower.includes('game') || categoryLower.includes('sport')) {
|
||||||
|
return <SportsEsports />;
|
||||||
|
}
|
||||||
|
if (categoryLower.includes('kitap') || categoryLower.includes('book') || categoryLower.includes('dergi')) {
|
||||||
|
return <MenuBook />;
|
||||||
|
}
|
||||||
|
if (categoryLower.includes('market') || categoryLower.includes('grocery') || categoryLower.includes('alışveriş')) {
|
||||||
|
return <LocalGroceryStore />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CategoryIcon />;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ShoppingListItemProps {
|
||||||
|
item: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
isPurchased: boolean;
|
||||||
|
priority: 'low' | 'medium' | 'high';
|
||||||
|
|
||||||
|
category?: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
product?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
category?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
onTogglePurchased: (id: string) => void;
|
||||||
|
onEdit: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onMenuClick: (event: React.MouseEvent<HTMLElement>, itemId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShoppingListItem: React.FC<ShoppingListItemProps> = ({
|
||||||
|
item,
|
||||||
|
onTogglePurchased,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onMenuClick,
|
||||||
|
}) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||||
|
const [isSwipeActive, setIsSwipeActive] = useState(false);
|
||||||
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
const [checkboxChecked, setCheckboxChecked] = useState(item.isPurchased);
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const startX = useRef(0);
|
||||||
|
const currentX = useRef(0);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority.toUpperCase()) {
|
||||||
|
case 'HIGH':
|
||||||
|
return theme.palette.error.main;
|
||||||
|
case 'MEDIUM':
|
||||||
|
return theme.palette.warning.main;
|
||||||
|
case 'LOW':
|
||||||
|
return theme.palette.success.main;
|
||||||
|
default:
|
||||||
|
return theme.palette.grey[500];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch event handlers for swipe gesture
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
// Disable swipe for purchased items
|
||||||
|
if (item.isPurchased) return;
|
||||||
|
|
||||||
|
startX.current = e.touches[0].clientX;
|
||||||
|
setIsSwipeActive(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
// Disable swipe for purchased items
|
||||||
|
if (!isSwipeActive || item.isPurchased) return;
|
||||||
|
|
||||||
|
currentX.current = e.touches[0].clientX;
|
||||||
|
const diffX = startX.current - currentX.current;
|
||||||
|
|
||||||
|
// Only allow left swipe (positive diffX)
|
||||||
|
if (diffX > 0 && diffX <= 120) {
|
||||||
|
setSwipeOffset(diffX);
|
||||||
|
if (diffX > 60) {
|
||||||
|
setShowActions(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
// Disable swipe for purchased items
|
||||||
|
if (item.isPurchased) {
|
||||||
|
setIsSwipeActive(false);
|
||||||
|
setSwipeOffset(0);
|
||||||
|
setShowActions(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSwipeActive(false);
|
||||||
|
|
||||||
|
if (swipeOffset > 60) {
|
||||||
|
setSwipeOffset(120); // Snap to show actions
|
||||||
|
setShowActions(true);
|
||||||
|
} else {
|
||||||
|
setSwipeOffset(0); // Snap back
|
||||||
|
setShowActions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset swipe when clicking outside
|
||||||
|
const handleCardClick = (e: React.MouseEvent) => {
|
||||||
|
if (showActions) {
|
||||||
|
setSwipeOffset(0);
|
||||||
|
setShowActions(false);
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Checkbox animation handler
|
||||||
|
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCheckboxChecked(e.target.checked);
|
||||||
|
setTimeout(() => {
|
||||||
|
onTogglePurchased(item.id);
|
||||||
|
}, 150); // Small delay for animation
|
||||||
|
};
|
||||||
|
|
||||||
|
// Action handlers
|
||||||
|
const handleEdit = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(item.id);
|
||||||
|
setSwipeOffset(0);
|
||||||
|
setShowActions(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(item.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityLabel = (priority: string) => {
|
||||||
|
switch (priority.toUpperCase()) {
|
||||||
|
case 'HIGH':
|
||||||
|
return 'Yüksek';
|
||||||
|
case 'MEDIUM':
|
||||||
|
return 'Orta';
|
||||||
|
case 'LOW':
|
||||||
|
return 'Düşük';
|
||||||
|
default:
|
||||||
|
return 'Normal';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasDetails = item.notes || item.category;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
mb: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Action Buttons Background - Only show for non-purchased items */}
|
||||||
|
{!item.isPurchased && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 120,
|
||||||
|
display: 'flex',
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleEdit}
|
||||||
|
sx={{
|
||||||
|
width: 60,
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 0,
|
||||||
|
backgroundColor: theme.palette.primary.main,
|
||||||
|
color: 'white',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.primary.dark,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleDelete}
|
||||||
|
sx={{
|
||||||
|
width: 60,
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 0,
|
||||||
|
backgroundColor: theme.palette.error.main,
|
||||||
|
color: 'white',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.error.dark,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Card */}
|
||||||
|
<Card
|
||||||
|
ref={cardRef}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 2,
|
||||||
|
transform: `translateX(-${swipeOffset}px)`,
|
||||||
|
transition: isSwipeActive ? 'none' : 'transform 0.3s ease-out',
|
||||||
|
opacity: checkboxChecked ? 0.7 : 1,
|
||||||
|
backgroundColor: checkboxChecked
|
||||||
|
? alpha(theme.palette.success.main, 0.1)
|
||||||
|
: theme.palette.background.paper,
|
||||||
|
border: checkboxChecked
|
||||||
|
? `1px solid ${alpha(theme.palette.success.main, 0.3)}`
|
||||||
|
: `1px solid ${theme.palette.divider}`,
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: theme.shadows[2],
|
||||||
|
},
|
||||||
|
cursor: showActions ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
{/* Animated Checkbox */}
|
||||||
|
<Fade in={true} timeout={300}>
|
||||||
|
<Checkbox
|
||||||
|
checked={checkboxChecked}
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
icon={<Circle />}
|
||||||
|
checkedIcon={<CheckCircle />}
|
||||||
|
sx={{
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
'&.Mui-checked': {
|
||||||
|
color: theme.palette.success.main,
|
||||||
|
},
|
||||||
|
'& .MuiSvgIcon-root': {
|
||||||
|
fontSize: 32,
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
|
||||||
|
},
|
||||||
|
'&.Mui-checked .MuiSvgIcon-root': {
|
||||||
|
transform: 'scale(1.2)',
|
||||||
|
filter: 'drop-shadow(0 3px 6px rgba(0,0,0,0.2))',
|
||||||
|
},
|
||||||
|
'&:hover .MuiSvgIcon-root': {
|
||||||
|
transform: 'scale(1.1)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fade>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{/* Main Info */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
{/* Category Icon */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
color: item.product?.category?.color || theme.palette.primary.main,
|
||||||
|
flexShrink: 0,
|
||||||
|
'& .MuiSvgIcon-root': {
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getCategoryIcon(item.product?.category, item.category)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: item.isPurchased ? 'line-through' : 'none',
|
||||||
|
color: item.isPurchased
|
||||||
|
? theme.palette.text.secondary
|
||||||
|
: theme.palette.text.primary,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Quantity and Basic Info */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label={`${item.quantity} ${item.unit || 'adet'}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
height: 28,
|
||||||
|
fontWeight: 600,
|
||||||
|
backgroundColor: theme.palette.primary.main,
|
||||||
|
color: 'white',
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
px: 1.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Expand Button */}
|
||||||
|
{hasDetails && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
sx={{ p: 0.5 }}
|
||||||
|
>
|
||||||
|
{expanded ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Priority Indicator - moved to right */}
|
||||||
|
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||||
|
{getPriorityLabel(item.priority)}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: getPriorityColor(item.priority),
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Expanded Details */}
|
||||||
|
<Collapse in={expanded}>
|
||||||
|
<Box sx={{ mt: 2, pt: 2, borderTop: `1px solid ${theme.palette.divider}` }}>
|
||||||
|
|
||||||
|
|
||||||
|
{item.category && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<LocalOffer sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||||
|
<Chip
|
||||||
|
label={item.category}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: '0.75rem', height: 24 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.notes && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||||
|
<StickyNote2 sx={{ fontSize: 16, color: 'text.secondary', mt: 0.2 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{item.notes}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Menu Button */}
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => onMenuClick(e, item.id)}
|
||||||
|
sx={{ p: 0.5 }}
|
||||||
|
>
|
||||||
|
<MoreVert />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShoppingListItem;
|
||||||
3
frontend/src/components/ShoppingList/index.ts
Normal file
3
frontend/src/components/ShoppingList/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as ShoppingListItem } from './ShoppingListItem';
|
||||||
|
export { default as ShoppingListHeader } from './ShoppingListHeader';
|
||||||
|
export { default as ShoppingListFilters } from './ShoppingListFilters';
|
||||||
205
frontend/src/contexts/AuthContext.tsx
Normal file
205
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { createContext, useContext, useReducer, useEffect, ReactNode } from 'react';
|
||||||
|
import { authAPI } from '../services/api';
|
||||||
|
import { User } from '../types';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType extends AuthState {
|
||||||
|
login: (login: string, password: string) => Promise<void>;
|
||||||
|
register: (userData: RegisterData) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
updateProfile: (userData: Partial<User>) => Promise<void>;
|
||||||
|
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
||||||
|
handleGoogleCallback: (token: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterData {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthAction =
|
||||||
|
| { type: 'SET_LOADING'; payload: boolean }
|
||||||
|
| { type: 'SET_USER'; payload: { user: User; token: string } }
|
||||||
|
| { type: 'CLEAR_USER' }
|
||||||
|
| { type: 'UPDATE_USER'; payload: Partial<User> };
|
||||||
|
|
||||||
|
const initialState: AuthState = {
|
||||||
|
user: null,
|
||||||
|
token: localStorage.getItem('token'),
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_LOADING':
|
||||||
|
return { ...state, isLoading: action.payload };
|
||||||
|
case 'SET_USER':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
user: action.payload.user,
|
||||||
|
token: action.payload.token,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
case 'CLEAR_USER':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
case 'UPDATE_USER':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
user: state.user ? { ...state.user, ...action.payload } : null,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||||
|
|
||||||
|
// Backend'ten gelen kullanıcı nesnesini frontend User tipine dönüştür
|
||||||
|
const normalizeUser = (apiUser: any): User => {
|
||||||
|
return {
|
||||||
|
id: apiUser.id,
|
||||||
|
firstName: apiUser.firstName,
|
||||||
|
lastName: apiUser.lastName,
|
||||||
|
email: apiUser.email,
|
||||||
|
role: apiUser.isAdmin ? 'ADMIN' : 'USER',
|
||||||
|
isActive: apiUser.isActive ?? true,
|
||||||
|
avatar: apiUser.avatar ?? undefined,
|
||||||
|
createdAt: apiUser.createdAt,
|
||||||
|
updatedAt: apiUser.updatedAt ?? apiUser.createdAt,
|
||||||
|
settings: apiUser.settings ?? undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if user is authenticated on app load
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const response = await authAPI.getProfile();
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_USER',
|
||||||
|
payload: { user: normalizeUser(response.data.data.user), token },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
dispatch({ type: 'CLEAR_USER' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dispatch({ type: 'SET_LOADING', payload: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (login: string, password: string) => {
|
||||||
|
dispatch({ type: 'SET_LOADING', payload: true });
|
||||||
|
try {
|
||||||
|
const response = await authAPI.login({ login, password });
|
||||||
|
const { user, token } = response.data.data;
|
||||||
|
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
dispatch({ type: 'SET_USER', payload: { user: normalizeUser(user), token } });
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({ type: 'SET_LOADING', payload: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (userData: RegisterData) => {
|
||||||
|
dispatch({ type: 'SET_LOADING', payload: true });
|
||||||
|
try {
|
||||||
|
const response = await authAPI.register(userData);
|
||||||
|
const { user, token } = response.data.data;
|
||||||
|
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
dispatch({ type: 'SET_USER', payload: { user: normalizeUser(user), token } });
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({ type: 'SET_LOADING', payload: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
dispatch({ type: 'CLEAR_USER' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProfile = async (userData: Partial<User>) => {
|
||||||
|
try {
|
||||||
|
const response = await authAPI.updateProfile(userData);
|
||||||
|
dispatch({ type: 'UPDATE_USER', payload: normalizeUser(response.data.data.user) });
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePassword = async (currentPassword: string, newPassword: string) => {
|
||||||
|
try {
|
||||||
|
await authAPI.changePassword({ currentPassword, newPassword });
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleCallback = async (token: string) => {
|
||||||
|
dispatch({ type: 'SET_LOADING', payload: true });
|
||||||
|
try {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
const response = await authAPI.getProfile();
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_USER',
|
||||||
|
payload: { user: normalizeUser(response.data.data.user), token },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
dispatch({ type: 'SET_LOADING', payload: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
...state,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
updateProfile,
|
||||||
|
changePassword,
|
||||||
|
handleGoogleCallback,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
149
frontend/src/contexts/SocketContext.tsx
Normal file
149
frontend/src/contexts/SocketContext.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useRef, ReactNode } from 'react';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface SocketContextType {
|
||||||
|
socket: Socket | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SocketContext = createContext<SocketContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useSocket = () => {
|
||||||
|
const context = useContext(SocketContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useSocket must be used within a SocketProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SocketProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SocketProvider: React.FC<SocketProviderProps> = ({ children }) => {
|
||||||
|
const { user, token, isAuthenticated } = useAuth();
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = React.useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && token && user) {
|
||||||
|
// Initialize socket connection
|
||||||
|
const socketUrl = process.env.NODE_ENV === 'production'
|
||||||
|
? `http://${window.location.hostname}:7001`
|
||||||
|
: 'http://localhost:7001';
|
||||||
|
|
||||||
|
socketRef.current = io(socketUrl, {
|
||||||
|
auth: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
transports: ['websocket'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const socket = socketRef.current;
|
||||||
|
|
||||||
|
// Connection event handlers
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('Socket connected:', socket.id);
|
||||||
|
setIsConnected(true);
|
||||||
|
|
||||||
|
// Join user's personal room
|
||||||
|
socket.emit('join-user-room', user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('Socket disconnected');
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (error) => {
|
||||||
|
console.error('Socket connection error:', error);
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification handlers
|
||||||
|
socket.on('notification', (notification) => {
|
||||||
|
toast.success(notification.message, {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// List update handlers
|
||||||
|
socket.on('list-updated', (data) => {
|
||||||
|
// This will be handled by specific components using the socket
|
||||||
|
console.log('List updated:', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('list-item-added', (data) => {
|
||||||
|
toast.success(`${data.item.name} added to ${data.list.name}`, {
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('list-item-updated', (data) => {
|
||||||
|
if (data.item.isPurchased) {
|
||||||
|
toast.success(`${data.item.name} marked as purchased`, {
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('list-item-removed', (data) => {
|
||||||
|
toast(`${data.item.name} removed from ${data.list.name}`, {
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// List member handlers
|
||||||
|
socket.on('list-member-added', (data) => {
|
||||||
|
toast.success(`You've been added to ${data.list.name}`, {
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('list-member-removed', (data) => {
|
||||||
|
toast(`You've been removed from ${data.list.name}`, {
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// List invitation handlers
|
||||||
|
socket.on('list-invitation', (data) => {
|
||||||
|
toast.success(`You've been invited to join ${data.list.name}`, {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handlers
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
console.error('Socket error:', error);
|
||||||
|
toast.error(error.message || 'An error occurred', {
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
setIsConnected(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Disconnect socket if user is not authenticated
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
setIsConnected(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, token, user]);
|
||||||
|
|
||||||
|
const value: SocketContextType = {
|
||||||
|
socket: socketRef.current,
|
||||||
|
isConnected,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <SocketContext.Provider value={value}>{children}</SocketContext.Provider>;
|
||||||
|
};
|
||||||
13
frontend/src/index.css
Normal file
13
frontend/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
13
frontend/src/index.tsx
Normal file
13
frontend/src/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById('root') as HTMLElement
|
||||||
|
);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
1260
frontend/src/pages/Admin/AdminPage.tsx
Normal file
1260
frontend/src/pages/Admin/AdminPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/src/pages/Admin/index.ts
Normal file
1
frontend/src/pages/Admin/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as AdminPage } from './AdminPage';
|
||||||
287
frontend/src/pages/Auth/LoginPage.tsx
Normal file
287
frontend/src/pages/Auth/LoginPage.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Link,
|
||||||
|
Alert,
|
||||||
|
InputAdornment,
|
||||||
|
IconButton,
|
||||||
|
Divider,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Visibility,
|
||||||
|
VisibilityOff,
|
||||||
|
Email,
|
||||||
|
Lock,
|
||||||
|
ShoppingCart,
|
||||||
|
Google,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { LoginForm } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
login: yup
|
||||||
|
.string()
|
||||||
|
.required('E-posta veya kullanıcı adı gerekli'),
|
||||||
|
password: yup
|
||||||
|
.string()
|
||||||
|
.min(6, 'Şifre en az 6 karakter olmalı')
|
||||||
|
.required('Şifre gerekli'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const LoginPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { login, isLoading } = useAuth();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const from = (location.state as any)?.from?.pathname || '/dashboard';
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginForm>({
|
||||||
|
resolver: yupResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
login: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginForm) => {
|
||||||
|
try {
|
||||||
|
setError('');
|
||||||
|
await login(data.login, data.password);
|
||||||
|
toast.success('Başarıyla giriş yapıldı!');
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err.response?.data?.message || 'Giriş başarısız. Lütfen tekrar deneyin.';
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickShowPassword = () => {
|
||||||
|
setShowPassword(!showPassword);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleLogin = () => {
|
||||||
|
// Backend'deki Google OAuth endpoint'ine yönlendir
|
||||||
|
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:7001/api';
|
||||||
|
window.location.href = `${apiUrl}/auth/google`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container component="main" maxWidth="sm">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
marginTop: 8,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
padding: 4,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo and Title */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<ShoppingCart sx={{ fontSize: 40, color: 'primary.main', mr: 1 }} />
|
||||||
|
<Typography component="h1" variant="h4" color="primary.main" fontWeight="bold">
|
||||||
|
HMarket
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography component="h2" variant="h5" sx={{ mb: 3 }}>
|
||||||
|
Giriş Yap
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ width: '100%' }}>
|
||||||
|
<Controller
|
||||||
|
name="login"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="login"
|
||||||
|
label="E-posta veya Kullanıcı Adı"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
error={!!errors.login}
|
||||||
|
helperText={errors.login?.message}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Email />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
label="Şifre"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
error={!!errors.password}
|
||||||
|
helperText={errors.password?.message}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Lock />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
aria-label="toggle password visibility"
|
||||||
|
onClick={handleClickShowPassword}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
sx={{ mt: 3, mb: 2, py: 1.5 }}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Giriş Yapılıyor...' : 'Giriş Yap'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
VEYA
|
||||||
|
</Typography>
|
||||||
|
</Divider>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
startIcon={<Google />}
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
py: 1.5,
|
||||||
|
borderColor: '#db4437',
|
||||||
|
color: '#db4437',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: '#c23321',
|
||||||
|
backgroundColor: 'rgba(219, 68, 55, 0.04)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Google ile Giriş Yap
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center', mt: 2 }}>
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
variant="body2"
|
||||||
|
onClick={() => navigate('/forgot-password')}
|
||||||
|
sx={{ textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
Şifremi unuttum?
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center', mt: 2 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Hesabınız yok mu?{' '}
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
variant="body2"
|
||||||
|
onClick={() => navigate('/register')}
|
||||||
|
sx={{ textDecoration: 'none', fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
Kayıt Ol
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<Box sx={{ mt: 4, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Neden HMarket?
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 4, mt: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight="bold">
|
||||||
|
📝 Akıllı Listeler
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Alışveriş listeleri oluştur ve paylaş
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight="bold">
|
||||||
|
🔄 Gerçek Zamanlı Senkronizasyon
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Tüm cihazlarda güncellemeler
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight="bold">
|
||||||
|
💰 Fiyat Takibi
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Fiyatları takip et ve karşılaştır
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
370
frontend/src/pages/Auth/RegisterPage.tsx
Normal file
370
frontend/src/pages/Auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Link,
|
||||||
|
Alert,
|
||||||
|
InputAdornment,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Visibility,
|
||||||
|
VisibilityOff,
|
||||||
|
Email,
|
||||||
|
Lock,
|
||||||
|
Person,
|
||||||
|
ShoppingCart,
|
||||||
|
Google,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { RegisterForm } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
firstName: yup
|
||||||
|
.string()
|
||||||
|
.required('Ad gerekli')
|
||||||
|
.min(2, 'Ad en az 2 karakter olmalı'),
|
||||||
|
lastName: yup
|
||||||
|
.string()
|
||||||
|
.required('Soyad gerekli')
|
||||||
|
.min(2, 'Soyad en az 2 karakter olmalı'),
|
||||||
|
email: yup
|
||||||
|
.string()
|
||||||
|
.email('Lütfen geçerli bir e-posta adresi girin')
|
||||||
|
.required('E-posta gerekli'),
|
||||||
|
password: yup
|
||||||
|
.string()
|
||||||
|
.min(6, 'Şifre en az 6 karakter olmalı')
|
||||||
|
.matches(
|
||||||
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||||
|
'Şifre en az bir büyük harf, bir küçük harf ve bir rakam içermeli'
|
||||||
|
)
|
||||||
|
.required('Şifre gerekli'),
|
||||||
|
confirmPassword: yup
|
||||||
|
.string()
|
||||||
|
.oneOf([yup.ref('password')], 'Şifreler eşleşmeli')
|
||||||
|
.required('Lütfen şifrenizi onaylayın'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RegisterPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { register, isLoading } = useAuth();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<RegisterForm>({
|
||||||
|
resolver: yupResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterForm) => {
|
||||||
|
try {
|
||||||
|
setError('');
|
||||||
|
await register({
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
confirmPassword: data.confirmPassword,
|
||||||
|
});
|
||||||
|
toast.success('Hesap başarıyla oluşturuldu!');
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err.response?.data?.message || 'Kayıt başarısız. Lütfen tekrar deneyin.';
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickShowPassword = () => {
|
||||||
|
setShowPassword(!showPassword);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickShowConfirmPassword = () => {
|
||||||
|
setShowConfirmPassword(!showConfirmPassword);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleLogin = () => {
|
||||||
|
// Backend'deki Google OAuth endpoint'ine yönlendir
|
||||||
|
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:7001/api';
|
||||||
|
window.location.href = `${apiUrl}/auth/google`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container component="main" maxWidth="sm">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 4,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
padding: 4,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo and Title */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<ShoppingCart sx={{ fontSize: 40, color: 'primary.main', mr: 1 }} />
|
||||||
|
<Typography component="h1" variant="h4" color="primary.main" fontWeight="bold">
|
||||||
|
HMarket
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography component="h2" variant="h5" sx={{ mb: 3 }}>
|
||||||
|
Hesap Oluştur
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ width: '100%' }}>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Controller
|
||||||
|
name="firstName"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="firstName"
|
||||||
|
label="Ad"
|
||||||
|
autoComplete="given-name"
|
||||||
|
autoFocus
|
||||||
|
error={!!errors.firstName}
|
||||||
|
helperText={errors.firstName?.message}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Person />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Controller
|
||||||
|
name="lastName"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="lastName"
|
||||||
|
label="Soyad"
|
||||||
|
autoComplete="family-name"
|
||||||
|
error={!!errors.lastName}
|
||||||
|
helperText={errors.lastName?.message}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Person />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="email"
|
||||||
|
label="E-posta Adresi"
|
||||||
|
autoComplete="email"
|
||||||
|
error={!!errors.email}
|
||||||
|
helperText={errors.email?.message}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Email />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
label="Şifre"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={!!errors.password}
|
||||||
|
helperText={errors.password?.message}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Lock />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
aria-label="toggle password visibility"
|
||||||
|
onClick={handleClickShowPassword}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="confirmPassword"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
label="Şifre Onayı"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
id="confirmPassword"
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={!!errors.confirmPassword}
|
||||||
|
helperText={errors.confirmPassword?.message}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Lock />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
aria-label="toggle confirm password visibility"
|
||||||
|
onClick={handleClickShowConfirmPassword}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
sx={{ mt: 3, mb: 2, py: 1.5 }}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Hesap Oluşturuluyor...' : 'Hesap Oluştur'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
startIcon={<Google />}
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
py: 1.5,
|
||||||
|
borderColor: '#db4437',
|
||||||
|
color: '#db4437',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: '#c23321',
|
||||||
|
backgroundColor: 'rgba(219, 68, 55, 0.04)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Google ile Kayıt Ol
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Zaten hesabınız var mı?{' '}
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
variant="body2"
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
sx={{ textDecoration: 'none', fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
Giriş Yap
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Terms */}
|
||||||
|
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
By creating an account, you agree to our{' '}
|
||||||
|
<Link href="#" sx={{ textDecoration: 'none' }}>
|
||||||
|
Terms of Service
|
||||||
|
</Link>{' '}
|
||||||
|
and{' '}
|
||||||
|
<Link href="#" sx={{ textDecoration: 'none' }}>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegisterPage;
|
||||||
2
frontend/src/pages/Auth/index.ts
Normal file
2
frontend/src/pages/Auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as LoginPage } from './LoginPage';
|
||||||
|
export { default as RegisterPage } from './RegisterPage';
|
||||||
331
frontend/src/pages/Dashboard/DashboardPage.tsx
Normal file
331
frontend/src/pages/Dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardActions,
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemIcon,
|
||||||
|
Avatar,
|
||||||
|
LinearProgress,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add,
|
||||||
|
List as ListIcon,
|
||||||
|
ShoppingCart,
|
||||||
|
CheckCircle,
|
||||||
|
PendingActions,
|
||||||
|
Share,
|
||||||
|
TrendingUp,
|
||||||
|
Refresh,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { dashboardAPI, listsAPI } from '../../services/api';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { ListsResponse } from '../../types';
|
||||||
|
|
||||||
|
const DashboardPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Fetch dashboard stats
|
||||||
|
const { data: statsData, isLoading: statsLoading, refetch: refetchStats } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'stats'],
|
||||||
|
queryFn: () => dashboardAPI.getStats(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch recent lists
|
||||||
|
const { data: listsData, isLoading: listsLoading } = useQuery<ListsResponse>({
|
||||||
|
queryKey: ['lists', 'recent'],
|
||||||
|
queryFn: () => listsAPI.getLists({ limit: 5, sortBy: 'updatedAt', sortOrder: 'desc' }).then(response => response.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch recent activity
|
||||||
|
const { data: activityData, isLoading: activityLoading } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'activity'],
|
||||||
|
queryFn: () => dashboardAPI.getRecentActivity({ limit: 10 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = statsData?.data?.data;
|
||||||
|
const recentLists = listsData?.data?.lists || [];
|
||||||
|
const recentActivity = activityData?.data?.data?.activities || [];
|
||||||
|
|
||||||
|
const completionRate = stats ? Math.round((stats.completedItems / stats.totalItems) * 100) || 0 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
{/* Welcome Section */}
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Tekrar hoş geldin, {user?.firstName}! 👋
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Bugün alışveriş listelerinizde neler oluyor, işte özet.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr 1fr' }, gap: 3 }}>
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<Box>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<ListIcon sx={{ fontSize: 40, color: 'primary.main', mr: 2 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography color="textSecondary" gutterBottom>
|
||||||
|
Toplam Liste
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4">
|
||||||
|
{statsLoading ? '-' : stats?.totalLists || 0}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<ShoppingCart sx={{ fontSize: 40, color: 'info.main', mr: 2 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography color="textSecondary" gutterBottom>
|
||||||
|
Toplam Ürün
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4">
|
||||||
|
{statsLoading ? '-' : stats?.totalItems || 0}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<CheckCircle sx={{ fontSize: 40, color: 'success.main', mr: 2 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography color="textSecondary" gutterBottom>
|
||||||
|
Tamamlanan
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4">
|
||||||
|
{statsLoading ? '-' : stats?.completedItems || 0}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<PendingActions sx={{ fontSize: 40, color: 'warning.main', mr: 2 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography color="textSecondary" gutterBottom>
|
||||||
|
Bekleyen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4">
|
||||||
|
{statsLoading ? '-' : stats?.pendingItems || 0}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3, mt: 3 }}>
|
||||||
|
{/* Completion Rate */}
|
||||||
|
<Box>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
<Typography variant="h6">Alışveriş İlerlemesi</Typography>
|
||||||
|
<IconButton onClick={() => refetchStats()}>
|
||||||
|
<Refresh />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||||
|
<TrendingUp sx={{ mr: 1, color: 'success.main' }} />
|
||||||
|
<Typography variant="h4" color="success.main">
|
||||||
|
{completionRate}%
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
|
||||||
|
tamamlanma oranı
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={completionRate}
|
||||||
|
sx={{ height: 8, borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
{stats?.totalItems || 0} üründen {stats?.completedItems || 0} tanesi tamamlandı
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<Box>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Hızlı İşlemler
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => navigate('/lists?action=create')}
|
||||||
|
>
|
||||||
|
Yeni Liste Oluştur
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ListIcon />}
|
||||||
|
onClick={() => navigate('/lists')}
|
||||||
|
>
|
||||||
|
Tüm Listeler
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ShoppingCart />}
|
||||||
|
onClick={() => navigate('/products')}
|
||||||
|
>
|
||||||
|
Ürünlere Gözat
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3, mt: 3 }}>
|
||||||
|
{/* Recent Lists */}
|
||||||
|
<Box>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Son Listeler
|
||||||
|
</Typography>
|
||||||
|
{listsLoading ? (
|
||||||
|
<Typography>Yükleniyor...</Typography>
|
||||||
|
) : recentLists.length === 0 ? (
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Henüz liste yok. İlk alışveriş listenizi oluşturun!
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List>
|
||||||
|
{recentLists.map((list: any) => (
|
||||||
|
<ListItem
|
||||||
|
key={list.id}
|
||||||
|
onClick={() => navigate(`/lists/${list.id}`)}
|
||||||
|
sx={{ px: 0, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Avatar sx={{ bgcolor: list.color || 'primary.main', width: 32, height: 32 }}>
|
||||||
|
{list.isShared ? <Share /> : <ListIcon />}
|
||||||
|
</Avatar>
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={list.name}
|
||||||
|
secondary={
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: '0.875rem', color: 'rgba(0, 0, 0, 0.6)' }}>
|
||||||
|
{list._count?.items || 0} ürün
|
||||||
|
</span>
|
||||||
|
{list.isShared && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
backgroundColor: '#1976d2',
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: 500
|
||||||
|
}}>
|
||||||
|
Paylaşılan
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardActions>
|
||||||
|
<Button size="small" onClick={() => navigate('/lists')}>
|
||||||
|
Tüm Listeleri Görüntüle
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<Box>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Son Aktiviteler
|
||||||
|
</Typography>
|
||||||
|
{activityLoading ? (
|
||||||
|
<Typography>Yükleniyor...</Typography>
|
||||||
|
) : recentActivity.length === 0 ? (
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Son aktivite yok.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List>
|
||||||
|
{recentActivity.slice(0, 5).map((activity: any) => (
|
||||||
|
<ListItem key={activity.id} sx={{ px: 0 }}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Avatar sx={{ width: 32, height: 32 }}>
|
||||||
|
{activity.user?.firstName?.[0] || '?'}
|
||||||
|
</Avatar>
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={activity.description}
|
||||||
|
secondary={formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardActions>
|
||||||
|
<Button size="small" onClick={() => navigate('/activity')}>
|
||||||
|
Tüm Aktiviteleri Görüntüle
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
1
frontend/src/pages/Dashboard/index.ts
Normal file
1
frontend/src/pages/Dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as DashboardPage } from './DashboardPage';
|
||||||
1570
frontend/src/pages/Lists/ListDetailPage.tsx
Normal file
1570
frontend/src/pages/Lists/ListDetailPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
249
frontend/src/pages/Lists/ListEditPage.tsx
Normal file
249
frontend/src/pages/Lists/ListEditPage.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
FormControlLabel,
|
||||||
|
Switch,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ArrowBack, Save } from '@mui/icons-material';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import { listsAPI } from '../../services/api';
|
||||||
|
import { UpdateListForm } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
// Validation schema
|
||||||
|
const schema: yup.ObjectSchema<UpdateListForm> = yup.object({
|
||||||
|
name: yup.string().optional().test('min-length', 'Liste adı en az 1 karakter olmalıdır', function(value) {
|
||||||
|
if (value === undefined || value === null) return true; // optional field
|
||||||
|
return value.length >= 1 && value.length <= 100;
|
||||||
|
}),
|
||||||
|
description: yup.string().optional().max(500, 'Açıklama en fazla 500 karakter olabilir'),
|
||||||
|
color: yup.string().optional().matches(/^#[0-9A-F]{6}$/i, 'Geçerli bir hex renk kodu girin'),
|
||||||
|
isShared: yup.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color options
|
||||||
|
const colors = [
|
||||||
|
'#FF5722', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
|
||||||
|
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
|
||||||
|
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ListEditPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { listId } = useParams<{ listId: string }>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch list data
|
||||||
|
const { data: listData, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['list', listId],
|
||||||
|
queryFn: () => listsAPI.getList(listId!),
|
||||||
|
enabled: !!listId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update list mutation
|
||||||
|
const updateListMutation = useMutation({
|
||||||
|
mutationFn: (data: UpdateListForm) => listsAPI.updateList(listId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['list', listId] });
|
||||||
|
toast.success('Liste başarıyla güncellendi!');
|
||||||
|
navigate(`/lists/${listId}`);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Liste güncellenemedi');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isDirty },
|
||||||
|
} = useForm<UpdateListForm>({
|
||||||
|
resolver: yupResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
color: '#FF5722',
|
||||||
|
isShared: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form when data is loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (listData?.data?.data?.list) {
|
||||||
|
const list = listData.data.data.list;
|
||||||
|
reset({
|
||||||
|
name: list.name,
|
||||||
|
description: list.description || '',
|
||||||
|
color: list.color,
|
||||||
|
isShared: list.isShared,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [listData, reset]);
|
||||||
|
|
||||||
|
const handleUpdateList = (data: UpdateListForm) => {
|
||||||
|
updateListMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
Liste yüklenirken bir hata oluştu
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
startIcon={<ArrowBack />}
|
||||||
|
onClick={() => navigate('/lists')}
|
||||||
|
>
|
||||||
|
Listelere Dön
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = listData?.data?.data?.list;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||||
|
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<ArrowBack />}
|
||||||
|
onClick={() => navigate(`/lists/${listId}`)}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
Geri
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h4" component="h1">
|
||||||
|
Liste Düzenle
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<form onSubmit={handleSubmit(handleUpdateList)}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Liste Adı"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.name}
|
||||||
|
helperText={errors.name?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="description"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Açıklama (isteğe bağlı)"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.description}
|
||||||
|
helperText={errors.description?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
Renk
|
||||||
|
</Typography>
|
||||||
|
<Controller
|
||||||
|
name="color"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{colors.map((color) => (
|
||||||
|
<Box
|
||||||
|
key={color}
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
bgcolor: color,
|
||||||
|
borderRadius: '50%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: field.value === color ? '3px solid #000' : '2px solid transparent',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.1)',
|
||||||
|
},
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onClick={() => field.onChange(color)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="isShared"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Paylaşımlı Liste"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => navigate(`/lists/${listId}`)}
|
||||||
|
>
|
||||||
|
İptal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Save />}
|
||||||
|
disabled={!isDirty || updateListMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateListMutation.isPending ? 'Kaydediliyor...' : 'Kaydet'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListEditPage;
|
||||||
682
frontend/src/pages/Lists/ListsPage.tsx
Normal file
682
frontend/src/pages/Lists/ListsPage.tsx
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardActions,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Fab,
|
||||||
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
|
Chip,
|
||||||
|
Avatar,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add,
|
||||||
|
Search,
|
||||||
|
MoreVert,
|
||||||
|
Share,
|
||||||
|
Edit,
|
||||||
|
Delete,
|
||||||
|
List as ListIcon,
|
||||||
|
People,
|
||||||
|
ShoppingCart,
|
||||||
|
FilterList,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import { listsAPI, itemsAPI } from '../../services/api';
|
||||||
|
import { CreateListForm, ShoppingList, ListsResponse } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
const createListSchema = yup.object().shape({
|
||||||
|
name: yup.string().required('Liste adı gerekli').min(2, 'Ad en az 2 karakter olmalı'),
|
||||||
|
description: yup.string().optional(),
|
||||||
|
color: yup.string().optional(),
|
||||||
|
isShared: yup.boolean().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ListsPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState(searchParams.get('search') || '');
|
||||||
|
const [filterShared, setFilterShared] = useState<boolean | undefined>(
|
||||||
|
searchParams.get('shared') ? searchParams.get('shared') === 'true' : undefined
|
||||||
|
);
|
||||||
|
const [filterCompleted, setFilterCompleted] = useState<boolean | undefined>(
|
||||||
|
searchParams.get('completed') ? searchParams.get('completed') === 'true' : false
|
||||||
|
);
|
||||||
|
const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || 'updatedAt');
|
||||||
|
const [sortOrder, setSortOrder] = useState(searchParams.get('sortOrder') || 'desc');
|
||||||
|
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(searchParams.get('action') === 'create');
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
||||||
|
const [selectedList, setSelectedList] = useState<ShoppingList | null>(null);
|
||||||
|
|
||||||
|
// Fetch lists
|
||||||
|
const { data: listsData, isLoading, error } = useQuery<ListsResponse>({
|
||||||
|
queryKey: ['lists', { search: searchTerm, isShared: filterShared, sortBy, sortOrder }],
|
||||||
|
queryFn: () => {
|
||||||
|
console.log('🔍 Fetching lists with params:', { search: searchTerm, isShared: filterShared, sortBy, sortOrder });
|
||||||
|
return listsAPI.getLists({
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
isShared: filterShared,
|
||||||
|
sortBy: sortBy as any,
|
||||||
|
sortOrder: sortOrder as any,
|
||||||
|
limit: 50,
|
||||||
|
}).then(response => {
|
||||||
|
console.log('📋 Lists API response:', response.data);
|
||||||
|
return response.data;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create list mutation
|
||||||
|
const createListMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateListForm) => listsAPI.createList(data),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||||
|
toast.success('Liste başarıyla oluşturuldu!');
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
reset();
|
||||||
|
// Yeni oluşturulan listeye otomatik yönlendir
|
||||||
|
const newListId = response.data.data.list.id;
|
||||||
|
navigate(`/lists/${newListId}`);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Liste oluşturulamadı');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete list mutation
|
||||||
|
const deleteListMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => listsAPI.deleteList(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||||
|
toast.success('Liste başarıyla silindi!');
|
||||||
|
handleMenuClose();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Liste silinemedi');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add sample data mutation
|
||||||
|
const addSampleDataMutation = useMutation({
|
||||||
|
mutationFn: async (listId: string) => {
|
||||||
|
const sampleItems = [
|
||||||
|
{ name: 'Ekmek', quantity: 2, unit: 'adet', priority: 'MEDIUM' as const, notes: 'Tam buğday ekmeği' },
|
||||||
|
{ name: 'Süt', quantity: 1, unit: 'litre', priority: 'HIGH' as const, notes: '3.5% yağlı' },
|
||||||
|
{ name: 'Yumurta', quantity: 12, unit: 'adet', priority: 'MEDIUM' as const, notes: 'Organik' },
|
||||||
|
{ name: 'Domates', quantity: 1, unit: 'kg', priority: 'LOW' as const, notes: 'Taze' },
|
||||||
|
{ name: 'Soğan', quantity: 500, unit: 'gram', priority: 'LOW' as const, notes: 'Kuru soğan' },
|
||||||
|
{ name: 'Peynir', quantity: 250, unit: 'gram', priority: 'MEDIUM' as const, notes: 'Beyaz peynir' },
|
||||||
|
{ name: 'Zeytin', quantity: 200, unit: 'gram', priority: 'LOW' as const, notes: 'Siyah zeytin' },
|
||||||
|
{ name: 'Çay', quantity: 1, unit: 'paket', priority: 'MEDIUM' as const, notes: 'Bergamot aromalı' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add each sample item to the list
|
||||||
|
for (const item of sampleItems) {
|
||||||
|
await itemsAPI.createItem(listId, item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['lists'] });
|
||||||
|
toast.success('Örnek ürünler başarıyla eklendi!');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Örnek ürünler eklenemedi');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<CreateListForm>({
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
color: '#FF5722',
|
||||||
|
isShared: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const lists = listsData?.data?.lists || [];
|
||||||
|
|
||||||
|
// Filter lists based on completion status
|
||||||
|
const filteredLists = lists.filter((list: ShoppingList) => {
|
||||||
|
if (filterCompleted === undefined) return true;
|
||||||
|
|
||||||
|
const totalItems = list._count?.items || 0;
|
||||||
|
const completedItems = list._count?.completedItems || 0;
|
||||||
|
const isCompleted = totalItems > 0 && completedItems === totalItems;
|
||||||
|
|
||||||
|
return filterCompleted ? isCompleted : !isCompleted;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug logs
|
||||||
|
console.log('🔍 listsData:', listsData);
|
||||||
|
console.log('📋 lists array:', lists);
|
||||||
|
console.log('🎯 filteredLists:', filteredLists);
|
||||||
|
console.log('📊 lists length:', lists.length);
|
||||||
|
console.log('📊 filteredLists length:', filteredLists.length);
|
||||||
|
console.log('🎯 isLoading:', isLoading);
|
||||||
|
console.log('❌ error:', error);
|
||||||
|
|
||||||
|
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, list: ShoppingList) => {
|
||||||
|
setMenuAnchor(event.currentTarget);
|
||||||
|
setSelectedList(list);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuClose = () => {
|
||||||
|
setMenuAnchor(null);
|
||||||
|
setSelectedList(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateList = (data: CreateListForm) => {
|
||||||
|
createListMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteList = () => {
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
setMenuAnchor(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
if (selectedList) {
|
||||||
|
deleteListMutation.mutate(selectedList.id);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setSelectedList(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelDelete = () => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setSelectedList(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSampleData = (listId: string) => {
|
||||||
|
addSampleDataMutation.mutate(listId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSearchParams = (key: string, value: string | undefined) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
setSearchParams(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#FF5722', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
|
||||||
|
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
|
||||||
|
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Render function for lists grid
|
||||||
|
const renderListsGrid = () => {
|
||||||
|
console.log('🎨 Render condition check:', { isLoading, listsLength: filteredLists.length, filteredLists });
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
console.log('🔄 Rendering loading state');
|
||||||
|
return <Typography>Yükleniyor...</Typography>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredLists.length === 0) {
|
||||||
|
console.log('📭 Rendering empty state');
|
||||||
|
return (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
|
<ListIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
Liste bulunamadı
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{searchTerm || filterCompleted !== undefined ? 'Arama kriterlerinizi ayarlamayı deneyin' : 'Başlamak için ilk alışveriş listenizi oluşturun'}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
>
|
||||||
|
İlk Listenizi Oluşturun
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📋 Rendering lists grid with', filteredLists.length, 'lists');
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 3 }}>
|
||||||
|
{filteredLists.map((list: ShoppingList) => (
|
||||||
|
<Box key={list.id}>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { boxShadow: 4 },
|
||||||
|
}}
|
||||||
|
onClick={() => navigate(`/lists/${list.id}`)}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
bgcolor: list.color || '#FF5722',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
mr: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{list.isShared ? <Share /> : <ListIcon />}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
<Typography variant="h6" noWrap>
|
||||||
|
{list.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{formatDistanceToNow(new Date(list.updatedAt), { addSuffix: true })}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleMenuOpen(e, list);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreVert />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{list.description && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
{list.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||||
|
{list.isShared && (
|
||||||
|
<Chip
|
||||||
|
icon={<People />}
|
||||||
|
label={`${list._count?.members || 0} üye`}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
icon={<ShoppingCart />}
|
||||||
|
label={`${list._count?.completedItems || 0}/${list._count?.items || 0} ürün`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color={(list._count?.completedItems || 0) === (list._count?.items || 0) && (list._count?.items || 0) > 0 ? "success" : "default"}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardActions>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/lists/${list.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Listeyi Görüntüle
|
||||||
|
</Button>
|
||||||
|
{(list._count?.items || 0) === 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleAddSampleData(list.id);
|
||||||
|
}}
|
||||||
|
disabled={addSampleDataMutation.isPending}
|
||||||
|
>
|
||||||
|
{addSampleDataMutation.isPending ? 'Ekleniyor...' : 'Örnek Ürünler Ekle'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{list.isShared && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<Share />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// TODO: Implement share functionality
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Paylaş
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Alışveriş Listeleri
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
sx={{ display: { xs: 'none', sm: 'flex' } }}
|
||||||
|
>
|
||||||
|
Liste Oluştur
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Box sx={{ flex: '1 1 300px', minWidth: '200px' }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="Listelerde ara..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
updateSearchParams('search', e.target.value || undefined);
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Search />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: '0 0 200px', minWidth: '150px' }}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Paylaşım</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={filterShared === undefined ? 'all' : filterShared ? 'shared' : 'private'}
|
||||||
|
label="Paylaşım"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const shared = value === 'all' ? undefined : value === 'shared';
|
||||||
|
setFilterShared(shared);
|
||||||
|
updateSearchParams('shared', shared === undefined ? undefined : shared.toString());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">Tüm Listeler</MenuItem>
|
||||||
|
<MenuItem value="shared">Paylaşılan Listeler</MenuItem>
|
||||||
|
<MenuItem value="private">Özel Listeler</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: '0 0 200px', minWidth: '150px' }}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Durum</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={filterCompleted === undefined ? 'all' : filterCompleted ? 'completed' : 'ongoing'}
|
||||||
|
label="Durum"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const completed = value === 'all' ? undefined : value === 'completed';
|
||||||
|
setFilterCompleted(completed);
|
||||||
|
updateSearchParams('completed', completed === undefined ? undefined : completed.toString());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">Tüm Listeler</MenuItem>
|
||||||
|
<MenuItem value="completed">Tamamlananlar</MenuItem>
|
||||||
|
<MenuItem value="ongoing">Devam Edenler</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: '0 0 200px', minWidth: '150px' }}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Sırala</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={sortBy}
|
||||||
|
label="Sırala"
|
||||||
|
onChange={(e) => {
|
||||||
|
setSortBy(e.target.value);
|
||||||
|
updateSearchParams('sortBy', e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="name">İsim</MenuItem>
|
||||||
|
<MenuItem value="createdAt">Oluşturulma Tarihi</MenuItem>
|
||||||
|
<MenuItem value="updatedAt">Güncellenme Tarihi</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: '0 0 200px', minWidth: '150px' }}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Sıralama</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={sortOrder}
|
||||||
|
label="Sıralama"
|
||||||
|
onChange={(e) => {
|
||||||
|
setSortOrder(e.target.value);
|
||||||
|
updateSearchParams('sortOrder', e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="asc">Artan</MenuItem>
|
||||||
|
<MenuItem value="desc">Azalan</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Lists Grid */}
|
||||||
|
{renderListsGrid()}
|
||||||
|
|
||||||
|
{/* Floating Action Button for Mobile */}
|
||||||
|
<Fab
|
||||||
|
color="primary"
|
||||||
|
aria-label="add"
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 16,
|
||||||
|
right: 16,
|
||||||
|
display: { xs: 'flex', sm: 'none' },
|
||||||
|
}}
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Add />
|
||||||
|
</Fab>
|
||||||
|
|
||||||
|
{/* Create List Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onClose={() => setCreateDialogOpen(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Yeni Liste Oluştur</DialogTitle>
|
||||||
|
<form onSubmit={handleSubmit(handleCreateList)}>
|
||||||
|
<DialogContent>
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Liste Adı"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.name}
|
||||||
|
helperText={errors.name?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="description"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="dense"
|
||||||
|
label="Açıklama (isteğe bağlı)"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2, mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Renk
|
||||||
|
</Typography>
|
||||||
|
<Controller
|
||||||
|
name="color"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{colors.map((color) => (
|
||||||
|
<Box
|
||||||
|
key={color}
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
bgcolor: color,
|
||||||
|
borderRadius: '50%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: field.value === color ? '3px solid #000' : '2px solid transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => field.onChange(color)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="isShared"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Bu listeyi paylaşımlı yap"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(false)}>İptal</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={createListMutation.isPending}
|
||||||
|
>
|
||||||
|
{createListMutation.isPending ? 'Oluşturuluyor...' : 'Liste Oluştur'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={handleCancelDelete}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Liste Silme Onayı</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
"{selectedList?.name}" listesini silmek istediğinizden emin misiniz?
|
||||||
|
Bu işlem geri alınamaz ve listedeki tüm ürünler de silinecektir.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCancelDelete}>İptal</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
disabled={deleteListMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteListMutation.isPending ? 'Siliniyor...' : 'Sil'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* List Menu */}
|
||||||
|
<Menu
|
||||||
|
anchorEl={menuAnchor}
|
||||||
|
open={Boolean(menuAnchor)}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedList) {
|
||||||
|
navigate(`/lists/${selectedList.id}/edit`);
|
||||||
|
}
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit sx={{ mr: 1 }} />
|
||||||
|
Düzenle
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: Implement share functionality
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Share sx={{ mr: 1 }} />
|
||||||
|
Paylaş
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleDeleteList}
|
||||||
|
sx={{ color: 'error.main' }}
|
||||||
|
>
|
||||||
|
<Delete sx={{ mr: 1 }} />
|
||||||
|
Sil
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListsPage;
|
||||||
3
frontend/src/pages/Lists/index.ts
Normal file
3
frontend/src/pages/Lists/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as ListsPage } from './ListsPage';
|
||||||
|
export { default as ListDetailPage } from './ListDetailPage';
|
||||||
|
export { default as ListEditPage } from './ListEditPage';
|
||||||
809
frontend/src/pages/Products/ProductsPage.tsx
Normal file
809
frontend/src/pages/Products/ProductsPage.tsx
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Fab,
|
||||||
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
Autocomplete,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add,
|
||||||
|
Search,
|
||||||
|
MoreVert,
|
||||||
|
Edit,
|
||||||
|
Delete,
|
||||||
|
Category as CategoryIcon,
|
||||||
|
ShoppingCart,
|
||||||
|
Fastfood,
|
||||||
|
LocalDrink,
|
||||||
|
Cake,
|
||||||
|
LocalGroceryStore,
|
||||||
|
ChildCare,
|
||||||
|
CleaningServices,
|
||||||
|
Face,
|
||||||
|
AcUnit,
|
||||||
|
LocalFlorist,
|
||||||
|
LocalDining,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { productsAPI, categoriesAPI } from '../../services/api';
|
||||||
|
import { CreateProductForm, Product, Category } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
// Icon mapping for categories - now supports emoji icons from database
|
||||||
|
const getCategoryIcon = (iconName?: string) => {
|
||||||
|
// If iconName exists and looks like an emoji (short string), return it directly
|
||||||
|
if (iconName && iconName.length <= 4) {
|
||||||
|
return <span style={{ fontSize: '16px' }}>{iconName}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Material-UI icons for backward compatibility
|
||||||
|
const iconMap: { [key: string]: React.ReactElement } = {
|
||||||
|
'Fastfood': <Fastfood />,
|
||||||
|
'LocalDrink': <LocalDrink />,
|
||||||
|
'Cake': <Cake />,
|
||||||
|
'LocalGroceryStore': <LocalGroceryStore />,
|
||||||
|
'ChildCare': <ChildCare />,
|
||||||
|
'CleaningServices': <CleaningServices />,
|
||||||
|
'Face': <Face />,
|
||||||
|
'AcUnit': <AcUnit />,
|
||||||
|
'LocalFlorist': <LocalFlorist />,
|
||||||
|
'LocalDining': <LocalDining />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return iconMap[iconName || ''] || <CategoryIcon />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductsPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState(searchParams.get('search') || '');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState(searchParams.get('categoryId') || '');
|
||||||
|
const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || 'name');
|
||||||
|
const [sortOrder, setSortOrder] = useState(searchParams.get('sortOrder') || 'asc');
|
||||||
|
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(searchParams.get('action') === 'create');
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||||
|
|
||||||
|
// Fetch products
|
||||||
|
const { data: productsData, isLoading } = useQuery({
|
||||||
|
queryKey: ['products', { search: searchTerm, categoryId: selectedCategory, sortBy, sortOrder }],
|
||||||
|
queryFn: () => productsAPI.getProducts({
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
categoryId: selectedCategory || undefined,
|
||||||
|
sortBy: sortBy as any,
|
||||||
|
sortOrder: sortOrder as any,
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch categories
|
||||||
|
const { data: categoriesData } = useQuery({
|
||||||
|
queryKey: ['categories'],
|
||||||
|
queryFn: () => categoriesAPI.getCategories({ limit: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create product mutation
|
||||||
|
const createProductMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateProductForm) => productsAPI.createProduct(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||||
|
toast.success('Ürün başarıyla oluşturuldu!');
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
reset();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Ürün oluşturulamadı');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update product mutation
|
||||||
|
const updateProductMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: CreateProductForm }) =>
|
||||||
|
productsAPI.updateProduct(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||||
|
toast.success('Ürün başarıyla güncellendi!');
|
||||||
|
resetEdit();
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setSelectedProduct(null);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Ürün güncellenirken hata oluştu');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete product mutation
|
||||||
|
const deleteProductMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => productsAPI.deleteProduct(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||||
|
toast.success('Ürün başarıyla silindi!');
|
||||||
|
handleMenuClose();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Ürün silinemedi');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<CreateProductForm>({
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
categoryId: '',
|
||||||
|
barcode: '',
|
||||||
|
brand: '',
|
||||||
|
averagePrice: 0,
|
||||||
|
},
|
||||||
|
mode: 'onChange',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control: editControl, handleSubmit: handleEditSubmit, reset: resetEdit, formState: { errors: editErrors } } = useForm<CreateProductForm>({
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
categoryId: '',
|
||||||
|
averagePrice: 0,
|
||||||
|
brand: '',
|
||||||
|
barcode: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const products = productsData?.data?.data?.products || [];
|
||||||
|
const categories = categoriesData?.data?.data?.categories || [];
|
||||||
|
|
||||||
|
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, product: Product) => {
|
||||||
|
setMenuAnchor(event.currentTarget);
|
||||||
|
setSelectedProduct(product);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuClose = () => {
|
||||||
|
setMenuAnchor(null);
|
||||||
|
setSelectedProduct(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateProduct = (data: CreateProductForm) => {
|
||||||
|
// averagePrice'ı price olarak gönder
|
||||||
|
const cleanData = {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || undefined,
|
||||||
|
categoryId: data.categoryId,
|
||||||
|
brand: data.brand || undefined,
|
||||||
|
barcode: data.barcode || undefined,
|
||||||
|
price: data.averagePrice || undefined,
|
||||||
|
};
|
||||||
|
createProductMutation.mutate(cleanData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditProduct = async (data: CreateProductForm) => {
|
||||||
|
if (!selectedProduct) return;
|
||||||
|
|
||||||
|
const productData: any = {};
|
||||||
|
|
||||||
|
if (data.name) productData.name = data.name;
|
||||||
|
if (data.barcode) productData.barcode = data.barcode;
|
||||||
|
if (data.categoryId) productData.categoryId = data.categoryId;
|
||||||
|
if (data.description !== undefined) productData.description = data.description;
|
||||||
|
if (data.averagePrice !== undefined) productData.averagePrice = data.averagePrice;
|
||||||
|
if (data.brand !== undefined) productData.brand = data.brand;
|
||||||
|
|
||||||
|
updateProductMutation.mutate({
|
||||||
|
id: selectedProduct.id,
|
||||||
|
data: productData,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (product: Product) => {
|
||||||
|
setSelectedProduct(product);
|
||||||
|
resetEdit({
|
||||||
|
name: product.name,
|
||||||
|
description: product.description || '',
|
||||||
|
categoryId: product.categoryId || '',
|
||||||
|
averagePrice: product.averagePrice || 0,
|
||||||
|
brand: product.brand || '',
|
||||||
|
barcode: product.barcode || '',
|
||||||
|
});
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProduct = () => {
|
||||||
|
if (selectedProduct) {
|
||||||
|
deleteProductMutation.mutate(selectedProduct.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSearchParams = (key: string, value: string | undefined) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
setSearchParams(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Ürünler
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
sx={{ display: { xs: 'none', sm: 'flex' } }}
|
||||||
|
>
|
||||||
|
Ürün Ekle
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 2, alignItems: 'center' }}>
|
||||||
|
<Box>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="Ürün ara..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
updateSearchParams('search', e.target.value || undefined);
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Search />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Kategori</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedCategory}
|
||||||
|
label="Kategori"
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedCategory(e.target.value);
|
||||||
|
updateSearchParams('categoryId', e.target.value || undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Tüm Kategoriler</MenuItem>
|
||||||
|
{categories && categories.map((category: Category) => (
|
||||||
|
<MenuItem key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Sırala</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={sortBy}
|
||||||
|
label="Sırala"
|
||||||
|
onChange={(e) => {
|
||||||
|
setSortBy(e.target.value);
|
||||||
|
updateSearchParams('sortBy', e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="name">İsim</MenuItem>
|
||||||
|
<MenuItem value="createdAt">Oluşturma Tarihi</MenuItem>
|
||||||
|
<MenuItem value="category.name">Kategori</MenuItem>
|
||||||
|
<MenuItem value="brand">Marka</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Sıralama</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={sortOrder}
|
||||||
|
label="Sıralama"
|
||||||
|
onChange={(e) => {
|
||||||
|
setSortOrder(e.target.value);
|
||||||
|
updateSearchParams('sortOrder', e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="asc">Artan</MenuItem>
|
||||||
|
<MenuItem value="desc">Azalan</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Products Grid */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Typography>Yükleniyor...</Typography>
|
||||||
|
) : products.length === 0 ? (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
|
<ShoppingCart sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
Ürün bulunamadı
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{searchTerm ? 'Arama kriterlerinizi ayarlamayı deneyin' : 'Başlamak için ilk ürününüzü ekleyin'}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
>
|
||||||
|
İlk Ürününüzü Ekleyin
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 3 }}>
|
||||||
|
{products.map((product: Product) => (
|
||||||
|
<Box key={product.id}>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { boxShadow: 4 },
|
||||||
|
}}
|
||||||
|
onClick={() => navigate(`/products/${product.id}`)}
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||||
|
<Typography variant="h6" noWrap sx={{ flexGrow: 1 }}>
|
||||||
|
{product.name}
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleMenuOpen(e, product);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreVert />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{product.brand && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
{product.brand}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{product.description && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{product.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||||
|
{product.category && (
|
||||||
|
<Chip
|
||||||
|
icon={getCategoryIcon(product.category.icon)}
|
||||||
|
label={product.category.name}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: product.category.color ? `${product.category.color}20` : undefined,
|
||||||
|
borderColor: product.category.color || undefined,
|
||||||
|
color: product.category.color || undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{formatDistanceToNow(new Date(product.createdAt), { addSuffix: true })} eklendi
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Floating Action Button for Mobile */}
|
||||||
|
<Fab
|
||||||
|
color="primary"
|
||||||
|
aria-label="add"
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 16,
|
||||||
|
right: 16,
|
||||||
|
display: { xs: 'flex', sm: 'none' },
|
||||||
|
}}
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Add />
|
||||||
|
</Fab>
|
||||||
|
|
||||||
|
{/* Create Product Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onClose={() => setCreateDialogOpen(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Yeni Ürün Ekle</DialogTitle>
|
||||||
|
<form onSubmit={handleSubmit(handleCreateProduct)}>
|
||||||
|
<DialogContent>
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
control={control}
|
||||||
|
rules={{
|
||||||
|
required: 'Ürün adı zorunludur',
|
||||||
|
minLength: {
|
||||||
|
value: 2,
|
||||||
|
message: 'Ürün adı en az 2 karakter olmalıdır'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Ürün Adı"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.name}
|
||||||
|
helperText={errors.name?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="description"
|
||||||
|
control={control}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="dense"
|
||||||
|
label="Açıklama (isteğe bağlı)"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="categoryId"
|
||||||
|
control={control}
|
||||||
|
rules={{
|
||||||
|
required: 'Kategori seçimi zorunludur'
|
||||||
|
}}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<Autocomplete
|
||||||
|
options={categories || []}
|
||||||
|
getOptionLabel={(option: Category) => option.name}
|
||||||
|
value={categories?.find((c: Category) => c.id === field.value) || null}
|
||||||
|
onChange={(_, value) => field.onChange(value?.id || '')}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Kategori"
|
||||||
|
fullWidth
|
||||||
|
margin="dense"
|
||||||
|
error={!!errors.categoryId}
|
||||||
|
helperText={errors.categoryId?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="averagePrice"
|
||||||
|
control={control}
|
||||||
|
rules={{
|
||||||
|
required: 'Fiyat zorunludur',
|
||||||
|
min: {
|
||||||
|
value: 0.01,
|
||||||
|
message: 'Fiyat 0\'dan büyük olmalıdır'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="dense"
|
||||||
|
label="Fiyat (₺)"
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
inputProps={{ step: "0.01", min: "0" }}
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.averagePrice}
|
||||||
|
helperText={errors.averagePrice?.message}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="brand"
|
||||||
|
control={control}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="dense"
|
||||||
|
label="Marka (isteğe bağlı)"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="barcode"
|
||||||
|
control={control}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="dense"
|
||||||
|
label="Barkod (isteğe bağlı)"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(false)}>İptal</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={createProductMutation.isPending}
|
||||||
|
>
|
||||||
|
{createProductMutation.isPending ? 'Oluşturuluyor...' : 'Ürün Oluştur'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Product Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onClose={() => setEditDialogOpen(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Ürün Düzenle</DialogTitle>
|
||||||
|
<form onSubmit={handleEditSubmit(handleEditProduct)}>
|
||||||
|
<DialogContent>
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
control={editControl}
|
||||||
|
rules={{
|
||||||
|
minLength: {
|
||||||
|
value: 2,
|
||||||
|
message: 'Ürün adı en az 2 karakter olmalıdır'
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 100,
|
||||||
|
message: 'Ürün adı en fazla 100 karakter olabilir'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Ürün Adı"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
error={!!editErrors.name}
|
||||||
|
helperText={editErrors.name?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="description"
|
||||||
|
control={editControl}
|
||||||
|
rules={{
|
||||||
|
maxLength: {
|
||||||
|
value: 500,
|
||||||
|
message: 'Açıklama en fazla 500 karakter olabilir'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="dense"
|
||||||
|
label="Açıklama"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
error={!!editErrors.description}
|
||||||
|
helperText={editErrors.description?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="categoryId"
|
||||||
|
control={editControl}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<Autocomplete
|
||||||
|
{...field}
|
||||||
|
options={categories}
|
||||||
|
getOptionLabel={(option: Category) => option.name}
|
||||||
|
value={categories.find(cat => cat.id === field.value) || null}
|
||||||
|
onChange={(_, value: Category | null) => field.onChange(value?.id || '')}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
margin="dense"
|
||||||
|
label="Kategori"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
error={!!editErrors.categoryId}
|
||||||
|
helperText={editErrors.categoryId?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="averagePrice"
|
||||||
|
control={editControl}
|
||||||
|
rules={{
|
||||||
|
min: {
|
||||||
|
value: 0,
|
||||||
|
message: 'Fiyat 0 veya daha büyük olmalıdır'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="dense"
|
||||||
|
label="Fiyat (₺)"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
type="number"
|
||||||
|
inputProps={{ step: 0.01, min: 0 }}
|
||||||
|
error={!!editErrors.averagePrice}
|
||||||
|
helperText={editErrors.averagePrice?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="brand"
|
||||||
|
control={editControl}
|
||||||
|
rules={{
|
||||||
|
maxLength: {
|
||||||
|
value: 100,
|
||||||
|
message: 'Marka en fazla 100 karakter olabilir'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="dense"
|
||||||
|
label="Marka"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
error={!!editErrors.brand}
|
||||||
|
helperText={editErrors.brand?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="barcode"
|
||||||
|
control={editControl}
|
||||||
|
rules={{
|
||||||
|
minLength: {
|
||||||
|
value: 8,
|
||||||
|
message: 'Barkod en az 8 karakter olmalıdır'
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 20,
|
||||||
|
message: 'Barkod en fazla 20 karakter olabilir'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="dense"
|
||||||
|
label="Barkod"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
error={!!editErrors.barcode}
|
||||||
|
helperText={editErrors.barcode?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setEditDialogOpen(false)}>
|
||||||
|
İptal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={updateProductMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateProductMutation.isPending ? 'Güncelleniyor...' : 'Ürün Güncelle'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Product Menu */}
|
||||||
|
<Menu
|
||||||
|
anchorEl={menuAnchor}
|
||||||
|
open={Boolean(menuAnchor)}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedProduct) {
|
||||||
|
openEditDialog(selectedProduct);
|
||||||
|
}
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit sx={{ mr: 1 }} />
|
||||||
|
Düzenle
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleDeleteProduct}
|
||||||
|
sx={{ color: 'error.main' }}
|
||||||
|
>
|
||||||
|
<Delete sx={{ mr: 1 }} />
|
||||||
|
Sil
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductsPage;
|
||||||
1
frontend/src/pages/Products/index.ts
Normal file
1
frontend/src/pages/Products/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as ProductsPage } from './ProductsPage';
|
||||||
632
frontend/src/pages/Profile/ProfilePage.tsx
Normal file
632
frontend/src/pages/Profile/ProfilePage.tsx
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
Avatar,
|
||||||
|
Divider,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
Paper,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Edit,
|
||||||
|
Save,
|
||||||
|
Cancel,
|
||||||
|
Person,
|
||||||
|
Security,
|
||||||
|
Notifications,
|
||||||
|
Delete,
|
||||||
|
Visibility,
|
||||||
|
VisibilityOff,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { usersAPI, notificationsAPI } from '../../services/api';
|
||||||
|
import { User, UserSettings } from '../../types';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
const profileSchema = yup.object({
|
||||||
|
firstName: yup.string().required('Ad gereklidir').min(2, 'Ad en az 2 karakter olmalıdır'),
|
||||||
|
lastName: yup.string().required('Soyad gereklidir').min(2, 'Soyad en az 2 karakter olmalıdır'),
|
||||||
|
email: yup.string().required('E-posta gereklidir').email('Geçersiz e-posta formatı'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordSchema = yup.object({
|
||||||
|
currentPassword: yup.string().required('Mevcut şifre gereklidir'),
|
||||||
|
newPassword: yup.string().required('Yeni şifre gereklidir').min(6, 'Şifre en az 6 karakter olmalıdır'),
|
||||||
|
confirmPassword: yup.string()
|
||||||
|
.required('Şifre onayı gereklidir')
|
||||||
|
.oneOf([yup.ref('newPassword')], 'Şifreler eşleşmelidir'),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TabPanelProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
index: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabPanel(props: TabPanelProps) {
|
||||||
|
const { children, value, index, ...other } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={value !== index}
|
||||||
|
id={`profile-tabpanel-${index}`}
|
||||||
|
aria-labelledby={`profile-tab-${index}`}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfilePage: React.FC = () => {
|
||||||
|
const { user, updateProfile, changePassword } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [tabValue, setTabValue] = useState(0);
|
||||||
|
const [editingProfile, setEditingProfile] = useState(false);
|
||||||
|
const [changePasswordDialogOpen, setChangePasswordDialogOpen] = useState(false);
|
||||||
|
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||||
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
|
// Fetch user settings
|
||||||
|
const { data: settingsData } = useQuery({
|
||||||
|
queryKey: ['userSettings'],
|
||||||
|
queryFn: () => usersAPI.getUserSettings(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch user activities - temporarily disabled
|
||||||
|
// const { data: activitiesData } = useQuery({
|
||||||
|
// queryKey: ['userActivities'],
|
||||||
|
// queryFn: () => usersAPI.getUserActivities({ limit: 10 }),
|
||||||
|
// });
|
||||||
|
const activitiesData = { data: [] };
|
||||||
|
|
||||||
|
// Update profile mutation
|
||||||
|
const updateProfileMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<User>) => updateProfile(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Profil başarıyla güncellendi!');
|
||||||
|
setEditingProfile(false);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Profil güncellenemedi');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change password mutation
|
||||||
|
const changePasswordMutation = useMutation({
|
||||||
|
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
|
||||||
|
changePassword(data.currentPassword, data.newPassword),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Şifre başarıyla değiştirildi!');
|
||||||
|
setChangePasswordDialogOpen(false);
|
||||||
|
passwordReset();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Şifre değiştirilemedi');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update settings mutation with optimistic cache sync to avoid flicker
|
||||||
|
const updateSettingsMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<UserSettings>) => usersAPI.updateUserSettings(data),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const serverSettings = (res?.data?.data?.settings ?? {}) as Partial<UserSettings>;
|
||||||
|
// Update local state to the authoritative server value
|
||||||
|
setLocalSettings(serverSettings);
|
||||||
|
// Update the react-query cache so refetch won't revert UI
|
||||||
|
queryClient.setQueryData(['userSettings'], (prev: any) => {
|
||||||
|
if (!prev) return res;
|
||||||
|
try {
|
||||||
|
const next = {
|
||||||
|
...prev,
|
||||||
|
data: {
|
||||||
|
...prev.data,
|
||||||
|
data: {
|
||||||
|
...(prev?.data?.data || {}),
|
||||||
|
settings: serverSettings,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
} catch {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toast.success('Ayarlar başarıyla güncellendi!');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Ayarlar güncellenemedi');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
control: profileControl,
|
||||||
|
handleSubmit: handleProfileSubmit,
|
||||||
|
reset: profileReset,
|
||||||
|
formState: { errors: profileErrors },
|
||||||
|
} = useForm({
|
||||||
|
resolver: yupResolver(profileSchema),
|
||||||
|
defaultValues: {
|
||||||
|
firstName: user?.firstName || '',
|
||||||
|
lastName: user?.lastName || '',
|
||||||
|
email: user?.email || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
control: passwordControl,
|
||||||
|
handleSubmit: handlePasswordSubmit,
|
||||||
|
reset: passwordReset,
|
||||||
|
formState: { errors: passwordErrors },
|
||||||
|
} = useForm({
|
||||||
|
resolver: yupResolver(passwordSchema),
|
||||||
|
defaultValues: {
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backend returns { success, data: { settings } }
|
||||||
|
const settings = (settingsData?.data?.data?.settings ?? {}) as Partial<UserSettings>;
|
||||||
|
const [localSettings, setLocalSettings] = useState<Partial<UserSettings>>({});
|
||||||
|
const activities = activitiesData?.data || [];
|
||||||
|
|
||||||
|
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||||
|
setTabValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateProfile = (data: any) => {
|
||||||
|
updateProfileMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = (data: any) => {
|
||||||
|
changePasswordMutation.mutate({
|
||||||
|
currentPassword: data.currentPassword,
|
||||||
|
newPassword: data.newPassword,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSettingChange = async (key: keyof UserSettings, value: boolean) => {
|
||||||
|
const previous = localSettings[key];
|
||||||
|
setLocalSettings((prev) => ({ ...prev, [key]: value }));
|
||||||
|
try {
|
||||||
|
await updateSettingsMutation.mutateAsync({ [key]: value } as Partial<UserSettings>);
|
||||||
|
} catch (error) {
|
||||||
|
// Revert optimistic update on error
|
||||||
|
setLocalSettings((prev) => ({ ...prev, [key]: previous as boolean }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Avoid overriding optimistic state while a mutation is pending
|
||||||
|
if (!updateSettingsMutation.isPending) {
|
||||||
|
setLocalSettings(settings);
|
||||||
|
}
|
||||||
|
}, [settings, updateSettingsMutation.isPending]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
profileReset({
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user, profileReset]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 4 }}>
|
||||||
|
<Typography>Yükleniyor...</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 4 }}>
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
mr: 3,
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
fontSize: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.firstName?.[0] || ''}{user.lastName?.[0] || ''}
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
{user.firstName || ''} {user.lastName || ''}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
{user.email}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Üye olma tarihi: {new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Paper sx={{ mb: 3 }}>
|
||||||
|
<Tabs
|
||||||
|
value={tabValue}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
aria-label="profile tabs"
|
||||||
|
variant="fullWidth"
|
||||||
|
>
|
||||||
|
<Tab icon={<Person />} label="Profil" />
|
||||||
|
<Tab icon={<Security />} label="Güvenlik" />
|
||||||
|
<Tab icon={<Notifications />} label="Bildirimler" />
|
||||||
|
</Tabs>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Profile Tab */}
|
||||||
|
<TabPanel value={tabValue} index={0}>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '2fr 1fr' }, gap: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||||
|
<Typography variant="h6">Kişisel Bilgiler</Typography>
|
||||||
|
{!editingProfile ? (
|
||||||
|
<Button
|
||||||
|
startIcon={<Edit />}
|
||||||
|
onClick={() => setEditingProfile(true)}
|
||||||
|
>
|
||||||
|
Düzenle
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
startIcon={<Cancel />}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingProfile(false);
|
||||||
|
profileReset();
|
||||||
|
}}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
İptal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Save />}
|
||||||
|
onClick={handleProfileSubmit(handleUpdateProfile)}
|
||||||
|
disabled={updateProfileMutation.isPending}
|
||||||
|
>
|
||||||
|
Kaydet
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<form onSubmit={handleProfileSubmit(handleUpdateProfile)}>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Controller
|
||||||
|
name="firstName"
|
||||||
|
control={profileControl}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Ad"
|
||||||
|
fullWidth
|
||||||
|
disabled={!editingProfile}
|
||||||
|
error={!!profileErrors.firstName}
|
||||||
|
helperText={profileErrors.firstName?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Controller
|
||||||
|
name="lastName"
|
||||||
|
control={profileControl}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Soyad"
|
||||||
|
fullWidth
|
||||||
|
disabled={!editingProfile}
|
||||||
|
error={!!profileErrors.lastName}
|
||||||
|
helperText={profileErrors.lastName?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: '1 / -1' }}>
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={profileControl}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="E-posta"
|
||||||
|
fullWidth
|
||||||
|
disabled={!editingProfile}
|
||||||
|
error={!!profileErrors.email}
|
||||||
|
helperText={profileErrors.email?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Son Aktiviteler
|
||||||
|
</Typography>
|
||||||
|
<List>
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Son aktivite bulunmuyor
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
activities.map((activity: any, index: number) => (
|
||||||
|
<ListItem key={index} divider={index < activities.length - 1}>
|
||||||
|
<ListItemText
|
||||||
|
primary={activity.description}
|
||||||
|
secondary={formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Security Tab */}
|
||||||
|
<TabPanel value={tabValue} index={1}>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Şifre
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Güçlü bir şifre kullanarak hesabınızı güvende tutun
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setChangePasswordDialogOpen(true)}
|
||||||
|
>
|
||||||
|
Şifre Değiştir
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Hesap Durumu
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Typography variant="body2" sx={{ mr: 2 }}>
|
||||||
|
Hesap Durumu:
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={user.isActive ? 'Aktif' : 'Pasif'}
|
||||||
|
color={user.isActive ? 'success' : 'error'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Notifications Tab */}
|
||||||
|
<TabPanel value={tabValue} index={2}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Bildirim Tercihleri
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Hangi bildirimleri almak istediğinizi seçin
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<List>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="E-posta Bildirimleri"
|
||||||
|
secondary="E-posta yoluyla bildirim alın"
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(localSettings.emailNotifications)}
|
||||||
|
onChange={(e) => handleSettingChange('emailNotifications', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Anlık Bildirimler"
|
||||||
|
secondary="Cihazınızda anlık bildirimler alın"
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(localSettings.pushNotifications)}
|
||||||
|
onChange={(e) => handleSettingChange('pushNotifications', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Liste Güncellemeleri"
|
||||||
|
secondary="Paylaşılan listeler güncellendiğinde bildirim alın"
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(localSettings.itemUpdateNotifications)}
|
||||||
|
onChange={(e) => handleSettingChange('itemUpdateNotifications', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Pazarlama E-postaları kaldırıldı */}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Change Password Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={changePasswordDialogOpen}
|
||||||
|
onClose={() => setChangePasswordDialogOpen(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Şifre Değiştir</DialogTitle>
|
||||||
|
<form onSubmit={handlePasswordSubmit(handleChangePassword)}>
|
||||||
|
<DialogContent>
|
||||||
|
<Controller
|
||||||
|
name="currentPassword"
|
||||||
|
control={passwordControl}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="dense"
|
||||||
|
label="Mevcut Şifre"
|
||||||
|
type={showCurrentPassword ? 'text' : 'password'}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
error={!!passwordErrors.currentPassword}
|
||||||
|
helperText={passwordErrors.currentPassword?.message}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{showCurrentPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="newPassword"
|
||||||
|
control={passwordControl}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="dense"
|
||||||
|
label="Yeni Şifre"
|
||||||
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
error={!!passwordErrors.newPassword}
|
||||||
|
helperText={passwordErrors.newPassword?.message}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{showNewPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="confirmPassword"
|
||||||
|
control={passwordControl}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
margin="dense"
|
||||||
|
label="Yeni Şifre Tekrar"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
error={!!passwordErrors.confirmPassword}
|
||||||
|
helperText={passwordErrors.confirmPassword?.message}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setChangePasswordDialogOpen(false)}>
|
||||||
|
İptal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={changePasswordMutation.isPending}
|
||||||
|
>
|
||||||
|
{changePasswordMutation.isPending ? 'Değiştiriliyor...' : 'Şifre Değiştir'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfilePage;
|
||||||
1
frontend/src/pages/Profile/index.ts
Normal file
1
frontend/src/pages/Profile/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as ProfilePage } from './ProfilePage';
|
||||||
1
frontend/src/react-app-env.d.ts
vendored
Normal file
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
||||||
312
frontend/src/services/api.ts
Normal file
312
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
PaginatedResponse,
|
||||||
|
ListsResponse,
|
||||||
|
UsersResponse,
|
||||||
|
User,
|
||||||
|
ShoppingList,
|
||||||
|
ListItem,
|
||||||
|
Product,
|
||||||
|
Category,
|
||||||
|
Notification,
|
||||||
|
Activity,
|
||||||
|
DashboardStats,
|
||||||
|
AdminStats,
|
||||||
|
LoginForm,
|
||||||
|
RegisterForm,
|
||||||
|
CreateListForm,
|
||||||
|
UpdateListForm,
|
||||||
|
CreateItemForm,
|
||||||
|
UpdateItemForm,
|
||||||
|
CreateProductForm,
|
||||||
|
CreateCategoryForm,
|
||||||
|
ListFilters,
|
||||||
|
ItemFilters,
|
||||||
|
ProductFilters,
|
||||||
|
UserSettings,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// Dinamik API URL belirleme
|
||||||
|
const getApiBaseUrl = (): string => {
|
||||||
|
// Önce environment variable'ı kontrol et
|
||||||
|
if (process.env.REACT_APP_API_URL) {
|
||||||
|
return process.env.REACT_APP_API_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eğer localhost'ta çalışıyorsa localhost:7001 kullan
|
||||||
|
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||||
|
return 'http://localhost:7001/api';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uzak erişimde aynı host'u kullan ama port 7001
|
||||||
|
return `http://${window.location.hostname}:7001/api`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create axios instance
|
||||||
|
const api: AxiosInstance = axios.create({
|
||||||
|
baseURL: getApiBaseUrl(),
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor to add auth token
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor to handle errors
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
export const authAPI = {
|
||||||
|
login: (data: LoginForm): Promise<AxiosResponse<ApiResponse<{ user: User; token: string }>>> =>
|
||||||
|
api.post('/auth/login', data),
|
||||||
|
|
||||||
|
register: (data: RegisterForm): Promise<AxiosResponse<ApiResponse<{ user: User; token: string }>>> =>
|
||||||
|
api.post('/auth/register', data),
|
||||||
|
|
||||||
|
getProfile: (): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||||
|
api.get('/auth/profile'),
|
||||||
|
|
||||||
|
updateProfile: (data: Partial<User>): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||||
|
api.put('/auth/profile', data),
|
||||||
|
|
||||||
|
changePassword: (data: { currentPassword: string; newPassword: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.put('/auth/change-password', data),
|
||||||
|
|
||||||
|
forgotPassword: (email: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.post('/auth/forgot-password', { email }),
|
||||||
|
|
||||||
|
resetPassword: (data: { token: string; password: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.post('/auth/reset-password', data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Users API
|
||||||
|
export const usersAPI = {
|
||||||
|
getUsers: (params?: { page?: number; limit?: number; search?: string; status?: 'active' | 'inactive' }): Promise<AxiosResponse<UsersResponse>> =>
|
||||||
|
api.get('/users', { params }),
|
||||||
|
|
||||||
|
getUser: (id: string): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||||
|
api.get(`/users/${id}`),
|
||||||
|
|
||||||
|
updateUser: (id: string, data: Partial<User>): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||||
|
api.put(`/users/${id}`, data),
|
||||||
|
|
||||||
|
deleteUser: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.delete(`/users/${id}`),
|
||||||
|
|
||||||
|
updateUserStatus: (id: string, isActive: boolean): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||||
|
api.put(`/users/${id}/status`, { isActive }),
|
||||||
|
|
||||||
|
setUserAdmin: (id: string, isAdmin: boolean): Promise<AxiosResponse<ApiResponse<{ user: User }>>> =>
|
||||||
|
api.put(`/users/${id}/admin`, { isAdmin }),
|
||||||
|
|
||||||
|
resetUserPassword: (id: string, newPassword: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.put(`/users/${id}/password`, { newPassword }),
|
||||||
|
|
||||||
|
getUserSettings: (): Promise<AxiosResponse<ApiResponse<{ settings: UserSettings }>>> =>
|
||||||
|
api.get('/users/settings'),
|
||||||
|
|
||||||
|
updateUserSettings: (data: Partial<UserSettings>): Promise<AxiosResponse<ApiResponse<{ settings: UserSettings }>>> =>
|
||||||
|
api.put('/users/settings', data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lists API
|
||||||
|
export const listsAPI = {
|
||||||
|
getLists: (params?: ListFilters & { page?: number; limit?: number }): Promise<AxiosResponse<ListsResponse>> =>
|
||||||
|
api.get('/lists', { params }),
|
||||||
|
|
||||||
|
getList: (id: string): Promise<AxiosResponse<ApiResponse<{ list: ShoppingList }>>> =>
|
||||||
|
api.get(`/lists/${id}`),
|
||||||
|
|
||||||
|
createList: (data: CreateListForm): Promise<AxiosResponse<ApiResponse<{ list: ShoppingList }>>> =>
|
||||||
|
api.post('/lists', data),
|
||||||
|
|
||||||
|
updateList: (id: string, data: UpdateListForm): Promise<AxiosResponse<ApiResponse<{ list: ShoppingList }>>> =>
|
||||||
|
api.put(`/lists/${id}`, data),
|
||||||
|
|
||||||
|
deleteList: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.delete(`/lists/${id}`),
|
||||||
|
|
||||||
|
getListMembers: (id: string): Promise<AxiosResponse<ApiResponse<{ members: any[] }>>> =>
|
||||||
|
api.get(`/lists/${id}/members`),
|
||||||
|
|
||||||
|
addListMember: (id: string, data: { email: string; role: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.post(`/lists/${id}/members`, data),
|
||||||
|
|
||||||
|
updateListMember: (id: string, memberId: string, data: { role: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.put(`/lists/${id}/members/${memberId}`, data),
|
||||||
|
|
||||||
|
removeListMember: (id: string, memberId: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.delete(`/lists/${id}/members/${memberId}`),
|
||||||
|
|
||||||
|
shareList: (id: string, data: { emails: string[]; message?: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.post(`/lists/${id}/share`, data),
|
||||||
|
|
||||||
|
duplicateList: (id: string, data: { name: string; includeItems: boolean }): Promise<AxiosResponse<ApiResponse<{ list: ShoppingList }>>> =>
|
||||||
|
api.post(`/lists/${id}/duplicate`, data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Items API
|
||||||
|
export const itemsAPI = {
|
||||||
|
getListItems: (listId: string, params?: ItemFilters & { page?: number; limit?: number }): Promise<AxiosResponse<PaginatedResponse<ListItem>>> =>
|
||||||
|
api.get(`/items/${listId}`, { params }),
|
||||||
|
|
||||||
|
getItem: (id: string): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||||
|
api.get(`/items/${id}`),
|
||||||
|
|
||||||
|
createItem: (listId: string, data: CreateItemForm): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||||
|
api.post(`/items/${listId}`, data),
|
||||||
|
|
||||||
|
updateItem: (listId: string, itemId: string, data: UpdateItemForm): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||||
|
api.put(`/items/${listId}/${itemId}`, data),
|
||||||
|
|
||||||
|
deleteItem: (listId: string, itemId: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.delete(`/items/${listId}/${itemId}`),
|
||||||
|
|
||||||
|
bulkUpdateItems: (data: { itemIds: string[]; updates: Partial<ListItem> }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.put('/items/bulk', data),
|
||||||
|
|
||||||
|
bulkDeleteItems: (itemIds: string[]): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.delete('/items/bulk', { data: { itemIds } }),
|
||||||
|
|
||||||
|
purchaseItem: (id: string, data?: { actualPrice?: number }): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||||
|
api.put(`/items/${id}/purchase`, data),
|
||||||
|
|
||||||
|
unpurchaseItem: (id: string): Promise<AxiosResponse<ApiResponse<{ item: ListItem }>>> =>
|
||||||
|
api.put(`/items/${id}/unpurchase`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Products API
|
||||||
|
export const productsAPI = {
|
||||||
|
getProducts: (params?: ProductFilters & { page?: number; limit?: number }): Promise<AxiosResponse<ApiResponse<{ products: Product[] }>>> =>
|
||||||
|
api.get('/products', { params }),
|
||||||
|
|
||||||
|
getProduct: (id: string): Promise<AxiosResponse<ApiResponse<{ product: Product }>>> =>
|
||||||
|
api.get(`/products/${id}`),
|
||||||
|
|
||||||
|
createProduct: (data: CreateProductForm): Promise<AxiosResponse<ApiResponse<{ product: Product }>>> =>
|
||||||
|
api.post('/products', data),
|
||||||
|
|
||||||
|
updateProduct: (productId: string, data: Partial<CreateProductForm>): Promise<AxiosResponse<ApiResponse<{ product: Product }>>> =>
|
||||||
|
api.put(`/products/${productId}`, data),
|
||||||
|
|
||||||
|
deleteProduct: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.delete(`/products/${id}`),
|
||||||
|
|
||||||
|
searchProducts: (query: string): Promise<AxiosResponse<ApiResponse<{ products: Product[] }>>> =>
|
||||||
|
api.get(`/products/search?q=${encodeURIComponent(query)}`),
|
||||||
|
|
||||||
|
getProductByBarcode: (barcode: string): Promise<AxiosResponse<ApiResponse<{ product: Product }>>> =>
|
||||||
|
api.get(`/products/barcode/${barcode}`),
|
||||||
|
|
||||||
|
addPriceHistory: (id: string, data: { price: number; store?: string; location?: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.post(`/products/${id}/price-history`, data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Categories API
|
||||||
|
export const categoriesAPI = {
|
||||||
|
getCategories: (params?: { page?: number; limit?: number; search?: string; parentId?: string; includeInactive?: boolean }): Promise<AxiosResponse<ApiResponse<{ categories: Category[] }>>> =>
|
||||||
|
api.get('/categories', { params }),
|
||||||
|
|
||||||
|
getCategory: (id: string): Promise<AxiosResponse<ApiResponse<{ category: Category }>>> =>
|
||||||
|
api.get(`/categories/${id}`),
|
||||||
|
|
||||||
|
createCategory: (data: CreateCategoryForm): Promise<AxiosResponse<ApiResponse<{ category: Category }>>> =>
|
||||||
|
api.post('/categories', data),
|
||||||
|
|
||||||
|
updateCategory: (id: string, data: Partial<CreateCategoryForm>): Promise<AxiosResponse<ApiResponse<{ category: Category }>>> =>
|
||||||
|
api.put(`/categories/${id}`, data),
|
||||||
|
|
||||||
|
deleteCategory: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.delete(`/categories/${id}`),
|
||||||
|
|
||||||
|
getCategoryTree: (): Promise<AxiosResponse<ApiResponse<{ categories: Category[] }>>> =>
|
||||||
|
api.get('/categories/tree'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notifications API
|
||||||
|
export const notificationsAPI = {
|
||||||
|
getNotifications: (params?: { page?: number; limit?: number; isRead?: boolean; type?: string }): Promise<AxiosResponse<PaginatedResponse<Notification>>> =>
|
||||||
|
api.get('/notifications', { params }),
|
||||||
|
|
||||||
|
markAsRead: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.put(`/notifications/${id}/read`),
|
||||||
|
|
||||||
|
markAllAsRead: (): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.put('/notifications/read-all'),
|
||||||
|
|
||||||
|
deleteNotification: (id: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.delete(`/notifications/${id}`),
|
||||||
|
|
||||||
|
clearAllRead: (): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.delete('/notifications/clear-read'),
|
||||||
|
|
||||||
|
getUnreadCount: (): Promise<AxiosResponse<ApiResponse<{ count: number }>>> =>
|
||||||
|
api.get('/notifications/unread-count'),
|
||||||
|
|
||||||
|
updateSettings: (data: any): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.put('/notifications/settings', data),
|
||||||
|
|
||||||
|
registerDevice: (data: { token: string; platform: string }): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.post('/notifications/device', data),
|
||||||
|
|
||||||
|
unregisterDevice: (token: string): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.delete(`/notifications/device/${token}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dashboard API
|
||||||
|
export const dashboardAPI = {
|
||||||
|
getStats: (): Promise<AxiosResponse<ApiResponse<DashboardStats>>> =>
|
||||||
|
api.get('/dashboard/stats'),
|
||||||
|
|
||||||
|
getRecentActivity: (params?: { limit?: number }): Promise<AxiosResponse<ApiResponse<{ activities: Activity[] }>>> =>
|
||||||
|
api.get('/dashboard/activity', { params }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin API
|
||||||
|
export const adminAPI = {
|
||||||
|
getStats: (): Promise<AxiosResponse<ApiResponse<AdminStats>>> =>
|
||||||
|
api.get('/admin/dashboard'),
|
||||||
|
|
||||||
|
getRecentActivity: (params?: { page?: number; limit?: number; type?: string; userId?: string }): Promise<AxiosResponse<PaginatedResponse<Activity>>> =>
|
||||||
|
api.get('/admin/activities', { params }),
|
||||||
|
|
||||||
|
getSystemSettings: (): Promise<AxiosResponse<ApiResponse<{ settings: any }>>> =>
|
||||||
|
api.get('/admin/settings'),
|
||||||
|
|
||||||
|
updateSystemSettings: (data: any): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.put('/admin/settings', data),
|
||||||
|
|
||||||
|
getSystemStatus: (): Promise<AxiosResponse<ApiResponse<{ status: any }>>> =>
|
||||||
|
api.get('/admin/status'),
|
||||||
|
|
||||||
|
backupDatabase: (): Promise<AxiosResponse<ApiResponse<{}>>> =>
|
||||||
|
api.post('/admin/backup'),
|
||||||
|
|
||||||
|
getSystemLogs: (params?: { level?: string; limit?: number }): Promise<AxiosResponse<ApiResponse<{ logs: any[] }>>> =>
|
||||||
|
api.get('/admin/logs', { params }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
372
frontend/src/types/index.ts
Normal file
372
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
// User types
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
role: 'USER' | 'ADMIN';
|
||||||
|
isActive: boolean;
|
||||||
|
avatar?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
settings?: UserSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSettings {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
emailNotifications: boolean;
|
||||||
|
pushNotifications: boolean;
|
||||||
|
listInviteNotifications: boolean;
|
||||||
|
itemUpdateNotifications: boolean;
|
||||||
|
priceAlertNotifications: boolean;
|
||||||
|
theme: 'light' | 'dark' | 'system';
|
||||||
|
language: string;
|
||||||
|
currency: string;
|
||||||
|
timezone: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List types
|
||||||
|
export interface ShoppingList {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
isShared: boolean;
|
||||||
|
ownerId: string;
|
||||||
|
owner: User;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
members: ListMember[];
|
||||||
|
items: ListItem[];
|
||||||
|
_count?: {
|
||||||
|
items: number;
|
||||||
|
members: number;
|
||||||
|
completedItems: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListMember {
|
||||||
|
id: string;
|
||||||
|
listId: string;
|
||||||
|
userId: string;
|
||||||
|
user: User;
|
||||||
|
role: 'OWNER' | 'EDITOR' | 'VIEWER';
|
||||||
|
joinedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListItem {
|
||||||
|
id: string;
|
||||||
|
listId: string;
|
||||||
|
list?: ShoppingList;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
quantity: number;
|
||||||
|
unit?: string;
|
||||||
|
|
||||||
|
actualPrice?: number;
|
||||||
|
isPurchased: boolean;
|
||||||
|
purchasedAt?: string;
|
||||||
|
purchasedBy?: string;
|
||||||
|
purchaser?: User;
|
||||||
|
productId?: string;
|
||||||
|
product?: Product;
|
||||||
|
categoryId?: string;
|
||||||
|
category?: Category;
|
||||||
|
priority: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||||
|
notes?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
creator: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product types
|
||||||
|
export interface Product {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
barcode?: string;
|
||||||
|
brand?: string;
|
||||||
|
unit?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
category?: Category;
|
||||||
|
averagePrice?: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
priceHistory: PriceHistory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceHistory {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
product?: Product;
|
||||||
|
price: number;
|
||||||
|
store?: string;
|
||||||
|
location?: string;
|
||||||
|
userId?: string;
|
||||||
|
user?: User;
|
||||||
|
recordedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category types
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
parentId?: string;
|
||||||
|
parent?: Category;
|
||||||
|
children?: Category[];
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification types
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
user?: User;
|
||||||
|
type: 'LIST_INVITE' | 'LIST_UPDATE' | 'ITEM_UPDATE' | 'PRICE_ALERT' | 'SYSTEM';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
data?: any;
|
||||||
|
isRead: boolean;
|
||||||
|
readAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity types
|
||||||
|
export interface Activity {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
user: User;
|
||||||
|
type: 'LIST_CREATED' | 'LIST_UPDATED' | 'LIST_DELETED' | 'ITEM_ADDED' | 'ITEM_UPDATED' | 'ITEM_DELETED' | 'MEMBER_ADDED' | 'MEMBER_REMOVED';
|
||||||
|
description: string;
|
||||||
|
metadata?: any;
|
||||||
|
listId?: string;
|
||||||
|
list?: ShoppingList;
|
||||||
|
itemId?: string;
|
||||||
|
item?: ListItem;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response types
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: T[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
hasPrev: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListsResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data: {
|
||||||
|
lists: ShoppingList[];
|
||||||
|
pagination: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalCount: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
hasPrev: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsersResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data: {
|
||||||
|
users: User[];
|
||||||
|
pagination: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalCount: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
hasPrev: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form types
|
||||||
|
export interface LoginForm {
|
||||||
|
login: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterForm {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateListForm {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
isShared: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateListForm {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
isShared?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateItemForm {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
quantity: number;
|
||||||
|
unit?: string;
|
||||||
|
|
||||||
|
productId?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
priority: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateItemForm {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
unit?: string;
|
||||||
|
|
||||||
|
actualPrice?: number;
|
||||||
|
isPurchased?: boolean;
|
||||||
|
categoryId?: string;
|
||||||
|
priority?: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProductForm {
|
||||||
|
name: string;
|
||||||
|
categoryId: string;
|
||||||
|
barcode?: string;
|
||||||
|
description?: string;
|
||||||
|
brand?: string;
|
||||||
|
unit?: string;
|
||||||
|
averagePrice?: number;
|
||||||
|
price?: number;
|
||||||
|
location?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCategoryForm {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter and search types
|
||||||
|
export interface ListFilters {
|
||||||
|
search?: string;
|
||||||
|
isShared?: boolean;
|
||||||
|
ownerId?: string;
|
||||||
|
sortBy?: 'name' | 'createdAt' | 'updatedAt';
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemFilters {
|
||||||
|
search?: string;
|
||||||
|
purchased?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
priority?: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||||
|
sortBy?: 'name' | 'createdAt' | 'priority';
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductFilters {
|
||||||
|
search?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
brand?: string;
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
|
sortBy?: 'name' | 'averagePrice' | 'createdAt';
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard types
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalLists: number;
|
||||||
|
totalItems: number;
|
||||||
|
completedItems: number;
|
||||||
|
pendingItems: number;
|
||||||
|
sharedLists: number;
|
||||||
|
recentActivity: Activity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin types
|
||||||
|
export interface AdminStats {
|
||||||
|
users: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
new: number;
|
||||||
|
};
|
||||||
|
lists: {
|
||||||
|
total: number;
|
||||||
|
shared: number;
|
||||||
|
private: number;
|
||||||
|
};
|
||||||
|
products: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
};
|
||||||
|
categories: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
};
|
||||||
|
notifications: {
|
||||||
|
total: number;
|
||||||
|
unread: number;
|
||||||
|
};
|
||||||
|
activities: {
|
||||||
|
total: number;
|
||||||
|
today: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Socket event types
|
||||||
|
export interface SocketEvents {
|
||||||
|
// List events
|
||||||
|
'list-updated': { list: ShoppingList };
|
||||||
|
'list-deleted': { listId: string };
|
||||||
|
'list-member-added': { list: ShoppingList; member: ListMember };
|
||||||
|
'list-member-removed': { list: ShoppingList; memberId: string };
|
||||||
|
'list-invitation': { list: ShoppingList; inviter: User };
|
||||||
|
|
||||||
|
// Item events
|
||||||
|
'list-item-added': { list: ShoppingList; item: ListItem };
|
||||||
|
'list-item-updated': { list: ShoppingList; item: ListItem };
|
||||||
|
'list-item-removed': { list: ShoppingList; item: ListItem };
|
||||||
|
|
||||||
|
// Notification events
|
||||||
|
'notification': Notification;
|
||||||
|
|
||||||
|
// Error events
|
||||||
|
'error': { message: string; code?: string };
|
||||||
|
}
|
||||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
login.json
Normal file
1
login.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"email":"admin@hmarket.com","password":"admin123"}
|
||||||
20
start-dev.bat
Normal file
20
start-dev.bat
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
@echo off
|
||||||
|
echo HMarket Development Server Başlatılıyor...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Mevcut terminalleri kapat
|
||||||
|
taskkill /f /im node.exe 2>nul
|
||||||
|
timeout /t 2 /nobreak >nul
|
||||||
|
|
||||||
|
echo Backend servisi başlatılıyor...
|
||||||
|
start "HMarket Backend" cmd /k "cd /d backend && npm start"
|
||||||
|
|
||||||
|
echo Frontend servisi başlatılıyor...
|
||||||
|
start "HMarket Frontend" cmd /k "cd /d frontend && npm start"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Her iki servis de başlatıldı!
|
||||||
|
echo Backend: http://localhost:7001
|
||||||
|
echo Frontend: http://localhost:7000
|
||||||
|
echo.
|
||||||
|
echo Servisleri durdurmak için bu pencereyi kapatabilirsiniz.
|
||||||
9
stop-dev.bat
Normal file
9
stop-dev.bat
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@echo off
|
||||||
|
echo HMarket Development Server Durduruluyor...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Node.js işlemlerini durdur
|
||||||
|
taskkill /f /im node.exe 2>nul
|
||||||
|
|
||||||
|
echo Tüm Node.js işlemleri durduruldu.
|
||||||
|
echo.
|
||||||
4
test-login.json
Normal file
4
test-login.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"login": "ahmet@test.com",
|
||||||
|
"password": "test123"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user