hMarket Trae ilk versiyon

This commit is contained in:
hOLOlu
2026-02-03 01:22:08 +03:00
commit 2b861156fe
74 changed files with 42127 additions and 0 deletions

42
.gitignore vendored Normal file
View 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
View 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
View 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ıı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
View 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

File diff suppressed because it is too large Load Diff

59
backend/package.json Normal file
View 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"
}
}

View File

@@ -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;

View 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"

View 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
View 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();
});

View 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
};

View 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;

View 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
};

View 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
View 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
View 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;

View 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;

View 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
View 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
View 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;

View 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;

View 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
View 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
View 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 };

View 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);
}
};

View 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;

View 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 };

View 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
};

View 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
View 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ıı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
View 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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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

File diff suppressed because it is too large Load Diff

59
frontend/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View 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"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

202
frontend/src/App.tsx Normal file
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,3 @@
export { default as ShoppingListItem } from './ShoppingListItem';
export { default as ShoppingListHeader } from './ShoppingListHeader';
export { default as ShoppingListFilters } from './ShoppingListFilters';

View 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>;
};

View 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
View 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
View 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>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { default as AdminPage } from './AdminPage';

View 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;

View 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;

View File

@@ -0,0 +1,2 @@
export { default as LoginPage } from './LoginPage';
export { default as RegisterPage } from './RegisterPage';

View 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;

View File

@@ -0,0 +1 @@
export { default as DashboardPage } from './DashboardPage';

File diff suppressed because it is too large Load Diff

View 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;

View 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;

View File

@@ -0,0 +1,3 @@
export { default as ListsPage } from './ListsPage';
export { default as ListDetailPage } from './ListDetailPage';
export { default as ListEditPage } from './ListEditPage';

View 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;

View File

@@ -0,0 +1 @@
export { default as ProductsPage } from './ProductsPage';

View 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;

View File

@@ -0,0 +1 @@
export { default as ProfilePage } from './ProfilePage';

1
frontend/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
{"email":"admin@hmarket.com","password":"admin123"}

20
start-dev.bat Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"login": "ahmet@test.com",
"password": "test123"
}