Security: - requireAdmin() session check added to every admin-only server action (previously relied only on middleware path matching, which Next.js Server Actions don't reliably respect) - Real Prisma + bcrypt admin auth, replacing hardcoded credentials; split into an Edge-safe auth.config.ts (used by proxy.ts) and the full Prisma-backed auth.ts (route handler, server actions, server components) - Removed hardcoded fallback secret on the Instagram sync cron endpoint - Honeypot field + per-IP rate limiting on contact/business-submission forms and the analytics events endpoint Features: - AI trip planner (/plan-olustur, /plan/[id]) backed by DeepSeek, grounded to only recommend isLocalApproved listings, with a deterministic link-injection fallback for anything the model doesn't format as markdown - Interactive Leaflet/OpenStreetMap view on category listing pages - Telegram notifications for new contact messages and business submissions SEO: - Brand-consistent favicon/apple-icon/PWA icons and default Open Graph/ Twitter share images, generated via next/og (replacing default Next.js placeholders) - BreadcrumbList structured data on category and listing detail pages - Fixed two remaining raw <img> tags to use next/image Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
770 lines
24 KiB
TypeScript
770 lines
24 KiB
TypeScript
"use server"
|
||
|
||
import { redirect } from "next/navigation"
|
||
|
||
import { mockDb } from '@/lib/mockDb'
|
||
import { uploadToOpeninary } from '@/lib/openinary'
|
||
import { requireAdmin } from '@/lib/auth'
|
||
import { HONEYPOT_FIELD_NAME } from '@/components/HoneypotField'
|
||
import { checkRateLimit } from '@/lib/rateLimit'
|
||
import { sendTelegramMessage, formatContactMessageNotification, formatBusinessSubmissionNotification } from '@/lib/telegram'
|
||
import { headers } from 'next/headers'
|
||
import { revalidatePath } from 'next/cache'
|
||
|
||
async function getClientIp() {
|
||
const h = await headers()
|
||
return h.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||
}
|
||
|
||
export async function submitBusinessAction(formData: FormData) {
|
||
if (formData.get(HONEYPOT_FIELD_NAME)) {
|
||
// Bot trap tripped — pretend success so it doesn't learn to skip this field.
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
const ip = await getClientIp()
|
||
if (!checkRateLimit(`submit-business:${ip}`, 5, 10 * 60_000)) {
|
||
return { error: 'Çok fazla başvuru gönderildi. Lütfen daha sonra tekrar deneyin.' }
|
||
}
|
||
|
||
const businessName = formData.get('businessName') as string
|
||
const categoryId = formData.get('categoryId') as string
|
||
const neighborhoodId = formData.get('neighborhoodId') as string
|
||
const address = formData.get('address') as string
|
||
const phone = formData.get('phone') as string
|
||
const whatsapp = formData.get('whatsapp') as string
|
||
const description = formData.get('description') as string
|
||
const contactName = formData.get('contactName') as string
|
||
const contactEmail = formData.get('contactEmail') as string
|
||
const imageFile = formData.get('imageFile') as File | null
|
||
|
||
if (!businessName || !categoryId || !neighborhoodId || !address || !description || !contactName || !contactEmail) {
|
||
return { error: 'Lütfen tüm zorunlu alanları doldurun.' }
|
||
}
|
||
|
||
let imageUrl = null
|
||
if (imageFile && imageFile.size > 0) {
|
||
try {
|
||
imageUrl = await uploadToOpeninary(imageFile, 'submissions')
|
||
} catch (e: any) {
|
||
console.error('Openinary upload error:', e)
|
||
return { error: `Görsel yüklenemedi: ${e.message}` }
|
||
}
|
||
}
|
||
|
||
await mockDb.createSubmission({
|
||
businessName,
|
||
categoryId,
|
||
neighborhoodId,
|
||
address,
|
||
phone,
|
||
whatsapp,
|
||
description,
|
||
contactName,
|
||
contactEmail,
|
||
imageUrl
|
||
})
|
||
|
||
const [categories, neighborhoods] = await Promise.all([mockDb.getCategories(), mockDb.getNeighborhoods()])
|
||
await sendTelegramMessage(
|
||
formatBusinessSubmissionNotification({
|
||
businessName,
|
||
categoryName: categories.find(c => c.id === categoryId)?.nameTr || categoryId,
|
||
neighborhoodName: neighborhoods.find(n => n.id === neighborhoodId)?.nameTr || neighborhoodId,
|
||
address,
|
||
phone,
|
||
whatsapp,
|
||
description,
|
||
contactName,
|
||
contactEmail,
|
||
ip,
|
||
})
|
||
)
|
||
|
||
revalidatePath('/admin/submissions')
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
export async function submitContactMessageAction(formData: FormData) {
|
||
if (formData.get(HONEYPOT_FIELD_NAME)) {
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
const ip = await getClientIp()
|
||
if (!checkRateLimit(`submit-contact:${ip}`, 5, 10 * 60_000)) {
|
||
return { error: 'Çok fazla mesaj gönderildi. Lütfen daha sonra tekrar deneyin.' }
|
||
}
|
||
|
||
const name = formData.get('name') as string
|
||
const email = formData.get('email') as string
|
||
const subject = formData.get('subject') as string
|
||
const message = formData.get('message') as string
|
||
|
||
if (!name || !email || !subject || !message) {
|
||
return { error: 'Lütfen tüm alanları doldurun.' }
|
||
}
|
||
|
||
await mockDb.createMessage({
|
||
name,
|
||
email,
|
||
subject,
|
||
message
|
||
})
|
||
|
||
await sendTelegramMessage(formatContactMessageNotification({ name, email, subject, message, ip }))
|
||
|
||
revalidatePath('/admin/messages')
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
export async function approveSubmissionAction(id: string) {
|
||
await requireAdmin()
|
||
const submission = await mockDb.getSubmissionById(id)
|
||
if (!submission) return { error: 'Başvuru bulunamadı' }
|
||
|
||
const slug = submission.businessName.toLowerCase()
|
||
.replace(/[^a-z0-9\s-]/g, '')
|
||
.replace(/\s+/g, '-')
|
||
|
||
await mockDb.createListing({
|
||
slug,
|
||
categoryId: submission.categoryId,
|
||
neighborhoodId: submission.neighborhoodId,
|
||
city: 'marmaris',
|
||
nameTr: submission.businessName,
|
||
nameEn: submission.businessName,
|
||
nameRu: submission.businessName,
|
||
descriptionTr: submission.description,
|
||
descriptionEn: submission.description,
|
||
descriptionRu: submission.description,
|
||
address: submission.address,
|
||
phone: submission.phone,
|
||
whatsapp: submission.whatsapp,
|
||
priceRange: 2,
|
||
rating: 5.0,
|
||
isLocalApproved: false,
|
||
isFeatured: false,
|
||
hasWidgetInstalled: false,
|
||
images: submission.imageUrl ? [submission.imageUrl] : []
|
||
})
|
||
|
||
await mockDb.updateSubmissionStatus(id, 'APPROVED')
|
||
|
||
revalidatePath('/admin/submissions')
|
||
revalidatePath('/restaurants')
|
||
revalidatePath('/aparts')
|
||
revalidatePath('/businesses')
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
export async function rejectSubmissionAction(id: string) {
|
||
await requireAdmin()
|
||
await mockDb.updateSubmissionStatus(id, 'REJECTED')
|
||
revalidatePath('/admin/submissions')
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
export async function deleteListingAction(id: string) {
|
||
await requireAdmin()
|
||
await mockDb.deleteListing(id)
|
||
revalidatePath('/admin/listings')
|
||
revalidatePath('/restaurants')
|
||
revalidatePath('/aparts')
|
||
revalidatePath('/businesses')
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
export async function restoreListingAction(id: string) {
|
||
await requireAdmin()
|
||
await mockDb.restoreListing(id)
|
||
revalidatePath('/admin/trash')
|
||
revalidatePath('/admin/listings')
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
export async function hardDeleteListingAction(id: string) {
|
||
await requireAdmin()
|
||
try {
|
||
await mockDb.hardDeleteListing(id)
|
||
revalidatePath('/admin/trash')
|
||
return { success: true, error: undefined }
|
||
} catch (err: any) {
|
||
if (err.message?.includes('RESTRICT') || err.message?.includes('Foreign key constraint')) {
|
||
return { success: false, error: 'Silme başarısız: Bu mekana bağlı etkinlik (Event) veya kayıtlar var.' }
|
||
}
|
||
return { success: false, error: err.message || 'Bilinmeyen bir hata oluştu.' }
|
||
}
|
||
}
|
||
|
||
export async function markMessageReadAction(id: string) {
|
||
await requireAdmin()
|
||
await mockDb.markMessageAsRead(id)
|
||
revalidatePath('/admin/messages')
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
export async function createOrUpdateListingAction(formData: FormData) {
|
||
await requireAdmin()
|
||
const id = formData.get('id') as string | null
|
||
const slug = formData.get('slug') as string
|
||
const categoryId = formData.get('categoryId') as string
|
||
const neighborhoodId = formData.get('neighborhoodId') as string
|
||
|
||
const nameTr = formData.get('nameTr') as string
|
||
const nameEn = formData.get('nameEn') as string
|
||
const nameRu = formData.get('nameRu') as string
|
||
|
||
const descriptionTr = formData.get('descriptionTr') as string
|
||
const descriptionEn = formData.get('descriptionEn') as string
|
||
const descriptionRu = formData.get('descriptionRu') as string
|
||
|
||
const address = formData.get('address') as string
|
||
|
||
if (!slug || !categoryId || !neighborhoodId || !nameTr || !address) {
|
||
return { success: false, error: 'Lütfen zorunlu alanları (Slug, Kategori, Mahalle, İsim, Adres) doldurun.' }
|
||
}
|
||
const phone = formData.get('phone') as string || null
|
||
const whatsapp = formData.get('whatsapp') as string || null
|
||
const website = formData.get('website') as string || null
|
||
const instagram = formData.get('instagram') as string || null
|
||
|
||
const priceRange = parseInt(formData.get('priceRange') as string)
|
||
const rating = formData.get('rating') ? parseFloat(formData.get('rating') as string) : null
|
||
const isLocalApproved = formData.get('isLocalApproved') === 'true'
|
||
const isFeatured = formData.get('isFeatured') === 'true'
|
||
|
||
const latitude = formData.get('latitude') ? parseFloat(formData.get('latitude') as string) : null
|
||
const longitude = formData.get('longitude') ? parseFloat(formData.get('longitude') as string) : null
|
||
|
||
const openingHoursStr = formData.get('openingHours') as string
|
||
const openingHours = openingHoursStr ? { all: openingHoursStr } : null
|
||
|
||
// Handle images
|
||
const images: string[] = []
|
||
|
||
const imageFile1 = formData.get('imageFile1') as File | null
|
||
const imageUrl1 = formData.get('imageUrl1') as string | null
|
||
const imageFile2 = formData.get('imageFile2') as File | null
|
||
const imageUrl2 = formData.get('imageUrl2') as string | null
|
||
|
||
// Process first image
|
||
if (imageFile1 && imageFile1.size > 0) {
|
||
try {
|
||
const url = await uploadToOpeninary(imageFile1, `listings/${slug}`)
|
||
images.push(url)
|
||
} catch (e: any) {
|
||
console.error('Image 1 upload error:', e)
|
||
return { success: false, error: `1. Görsel yüklenemedi: ${e.message}` }
|
||
}
|
||
} else if (imageUrl1) {
|
||
images.push(imageUrl1)
|
||
}
|
||
|
||
// Process second image
|
||
if (imageFile2 && imageFile2.size > 0) {
|
||
try {
|
||
const url = await uploadToOpeninary(imageFile2, `listings/${slug}`)
|
||
images.push(url)
|
||
} catch (e: any) {
|
||
console.error('Image 2 upload error:', e)
|
||
return { success: false, error: `2. Görsel yüklenemedi: ${e.message}` }
|
||
}
|
||
} else if (imageUrl2) {
|
||
images.push(imageUrl2)
|
||
}
|
||
|
||
const data = {
|
||
slug,
|
||
categoryId,
|
||
neighborhoodId,
|
||
city: 'marmaris',
|
||
nameTr,
|
||
nameEn,
|
||
nameRu,
|
||
descriptionTr,
|
||
descriptionEn,
|
||
descriptionRu,
|
||
address,
|
||
phone,
|
||
whatsapp,
|
||
website,
|
||
instagram,
|
||
priceRange,
|
||
rating,
|
||
isLocalApproved,
|
||
isFeatured,
|
||
latitude,
|
||
longitude,
|
||
openingHours,
|
||
images,
|
||
hasWidgetInstalled: false
|
||
}
|
||
|
||
try {
|
||
if (id && id !== 'new') {
|
||
await mockDb.updateListing(id, data)
|
||
} else {
|
||
await mockDb.createListing(data)
|
||
}
|
||
revalidatePath('/admin/listings')
|
||
revalidatePath('/restaurants')
|
||
revalidatePath('/aparts')
|
||
revalidatePath('/businesses')
|
||
return { success: true, error: undefined }
|
||
} catch (err: any) {
|
||
return { success: false, error: err.message || 'Mekan kaydedilemedi.' }
|
||
}
|
||
}
|
||
|
||
// Blog Actions
|
||
export async function deleteBlogPostAction(id: string) {
|
||
await requireAdmin()
|
||
await mockDb.deleteBlogPost(id)
|
||
revalidatePath('/admin/blog')
|
||
revalidatePath('/blog')
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
export async function createOrUpdateBlogPostAction(formData: FormData) {
|
||
await requireAdmin()
|
||
const id = formData.get('id') as string | null
|
||
const slug = formData.get('slug') as string
|
||
const titleTr = formData.get('titleTr') as string
|
||
const titleEn = formData.get('titleEn') as string
|
||
const titleRu = formData.get('titleRu') as string
|
||
const contentTr = formData.get('contentTr') as string
|
||
const contentEn = formData.get('contentEn') as string
|
||
const contentRu = formData.get('contentRu') as string
|
||
|
||
if (!slug || !titleTr) {
|
||
return { success: false, error: 'Lütfen zorunlu alanları (Slug, Başlık) doldurun.' }
|
||
}
|
||
|
||
const tagsStr = formData.get('tags') as string || ''
|
||
const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean)
|
||
|
||
const relatedListingIdsStr = formData.get('relatedListingIds') as string || ''
|
||
const relatedListingIds = relatedListingIdsStr.split(',').map(i => i.trim()).filter(Boolean)
|
||
|
||
const isPublished = formData.get('isPublished') === 'true'
|
||
const publishedAt = isPublished ? new Date() : null
|
||
|
||
// Handle Cover Image
|
||
const coverImageFile = formData.get('coverImageFile') as File | null
|
||
let coverImage = formData.get('coverImageUrl') as string | null || null
|
||
|
||
if (coverImageFile && coverImageFile.size > 0) {
|
||
try {
|
||
coverImage = await uploadToOpeninary(coverImageFile, `blog/${slug}`)
|
||
} catch (e: any) {
|
||
console.error('Blog cover upload error:', e)
|
||
return { success: false, error: `Kapak görseli yüklenemedi: ${e.message}` }
|
||
}
|
||
}
|
||
|
||
const data = {
|
||
slug,
|
||
titleTr,
|
||
titleEn,
|
||
titleRu,
|
||
contentTr,
|
||
contentEn,
|
||
contentRu,
|
||
coverImage,
|
||
tags,
|
||
relatedListingIds,
|
||
publishedAt
|
||
}
|
||
|
||
try {
|
||
if (id && id !== 'new') {
|
||
await mockDb.updateBlogPost(id, data)
|
||
} else {
|
||
await mockDb.createBlogPost(data)
|
||
}
|
||
revalidatePath('/admin/blog')
|
||
revalidatePath('/blog')
|
||
revalidatePath(`/blog/${slug}`)
|
||
return { success: true, error: undefined }
|
||
} catch (err: any) {
|
||
return { success: false, error: err.message || 'Yazı kaydedilemedi.' }
|
||
}
|
||
}
|
||
|
||
// Collections Actions
|
||
export async function deleteCollectionAction(id: string) {
|
||
await requireAdmin()
|
||
await mockDb.deleteCollection(id)
|
||
revalidatePath('/admin/collections')
|
||
revalidatePath('/collections')
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
export async function createOrUpdateCollectionAction(formData: FormData) {
|
||
await requireAdmin()
|
||
const id = formData.get('id') as string | null
|
||
const slug = formData.get('slug') as string
|
||
const titleTr = formData.get('titleTr') as string
|
||
const titleEn = formData.get('titleEn') as string
|
||
const titleRu = formData.get('titleRu') as string
|
||
const descriptionTr = formData.get('descriptionTr') as string
|
||
const descriptionEn = formData.get('descriptionEn') as string
|
||
const descriptionRu = formData.get('descriptionRu') as string
|
||
|
||
if (!slug || !titleTr) {
|
||
return { success: false, error: 'Lütfen zorunlu alanları (Slug, Başlık) doldurun.' }
|
||
}
|
||
|
||
const listingIdsStr = formData.get('listingIds') as string || ''
|
||
const listingIds = listingIdsStr.split(',').map(i => i.trim()).filter(Boolean)
|
||
|
||
// Handle Cover Image
|
||
const coverImageFile = formData.get('coverImageFile') as File | null
|
||
let coverImage = formData.get('coverImageUrl') as string | null || null
|
||
|
||
if (coverImageFile && coverImageFile.size > 0) {
|
||
try {
|
||
coverImage = await uploadToOpeninary(coverImageFile, `collections/${slug}`)
|
||
} catch (e: any) {
|
||
console.error('Collection cover upload error:', e)
|
||
return { success: false, error: `Kapak görseli yüklenemedi: ${e.message}` }
|
||
}
|
||
}
|
||
|
||
const data = {
|
||
slug,
|
||
titleTr,
|
||
titleEn,
|
||
titleRu,
|
||
descriptionTr,
|
||
descriptionEn,
|
||
descriptionRu,
|
||
coverImage,
|
||
listingIds
|
||
}
|
||
|
||
try {
|
||
if (id && id !== 'new') {
|
||
await mockDb.updateCollection(id, data)
|
||
} else {
|
||
await mockDb.createCollection(data)
|
||
}
|
||
revalidatePath('/admin/collections')
|
||
revalidatePath('/collections')
|
||
revalidatePath(`/collection/${slug}`)
|
||
return { success: true, error: undefined }
|
||
} catch (err: any) {
|
||
return { success: false, error: err.message || 'Koleksiyon kaydedilemedi.' }
|
||
}
|
||
}
|
||
|
||
// Events Actions (Phase 3)
|
||
export async function deleteEventAction(id: string) {
|
||
await requireAdmin()
|
||
await mockDb.deleteEvent(id)
|
||
revalidatePath('/admin/events')
|
||
revalidatePath('/events')
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
export async function createOrUpdateEventAction(formData: FormData) {
|
||
await requireAdmin()
|
||
const id = formData.get('id') as string | null
|
||
const slug = formData.get('slug') as string
|
||
const listingId = formData.get('listingId') as string | null || null
|
||
const titleTr = formData.get('titleTr') as string
|
||
const titleEn = formData.get('titleEn') as string
|
||
const titleRu = formData.get('titleRu') as string
|
||
const descriptionTr = formData.get('descriptionTr') as string
|
||
const descriptionEn = formData.get('descriptionEn') as string
|
||
const descriptionRu = formData.get('descriptionRu') as string
|
||
|
||
const startDateStr = formData.get('startDate') as string
|
||
const endDateStr = formData.get('endDate') as string | null
|
||
|
||
if (!slug || !titleTr || !startDateStr) {
|
||
return { success: false, error: 'Lütfen zorunlu alanları (Slug, Başlık, Başlangıç Tarihi) doldurun.' }
|
||
}
|
||
|
||
const isSponsored = formData.get('isSponsored') === 'true'
|
||
|
||
const startDate = new Date(startDateStr)
|
||
const endDate = endDateStr ? new Date(endDateStr) : null
|
||
|
||
// Handle Cover Image
|
||
const coverImageFile = formData.get('coverImageFile') as File | null
|
||
let coverImage = formData.get('coverImageUrl') as string | null || null
|
||
|
||
if (coverImageFile && coverImageFile.size > 0) {
|
||
try {
|
||
coverImage = await uploadToOpeninary(coverImageFile, `events/${slug}`)
|
||
} catch (e: any) {
|
||
console.error('Event cover upload error:', e)
|
||
return { success: false, error: `Kapak görseli yüklenemedi: ${e.message}` }
|
||
}
|
||
}
|
||
|
||
const data = {
|
||
slug,
|
||
listingId,
|
||
titleTr,
|
||
titleEn,
|
||
titleRu,
|
||
descriptionTr,
|
||
descriptionEn,
|
||
descriptionRu,
|
||
startDate,
|
||
endDate,
|
||
coverImage,
|
||
isSponsored
|
||
}
|
||
|
||
try {
|
||
if (id && id !== 'new') {
|
||
await mockDb.updateEvent(id, data)
|
||
} else {
|
||
await mockDb.createEvent(data)
|
||
}
|
||
revalidatePath('/admin/events')
|
||
revalidatePath('/events')
|
||
revalidatePath(`/event/${slug}`)
|
||
return { success: true, error: undefined }
|
||
} catch (err: any) {
|
||
return { success: false, error: err.message || 'Etkinlik kaydedilemedi.' }
|
||
}
|
||
}
|
||
// Neighborhood Actions
|
||
export async function deleteNeighborhoodAction(id: string) {
|
||
await requireAdmin()
|
||
try {
|
||
const listings = await mockDb.getListings({ neighborhoodId: id })
|
||
if (listings && listings.length > 0) {
|
||
return { success: false, error: `Silme başarısız: Bu mahalleye kayıtlı ${listings.length} adet mekan var.` }
|
||
}
|
||
|
||
await mockDb.deleteNeighborhood(id)
|
||
revalidatePath('/admin/neighborhoods')
|
||
return { success: true, error: undefined }
|
||
} catch (err: any) {
|
||
if (err.message?.includes('RESTRICT') || err.message?.includes('Foreign key constraint')) {
|
||
return { success: false, error: 'Silme başarısız: Bu mahalleye bağlı gizli (silinmiş veya arşivlenmiş) mekanlar veya etkinlikler var.' }
|
||
}
|
||
return { success: false, error: err.message || 'Bilinmeyen bir hata oluştu.' }
|
||
}
|
||
}
|
||
|
||
export async function createOrUpdateNeighborhoodAction(formData: FormData) {
|
||
await requireAdmin()
|
||
const id = formData.get('id') as string | null
|
||
const slug = formData.get('slug') as string
|
||
const nameTr = formData.get('nameTr') as string
|
||
const nameEn = formData.get('nameEn') as string
|
||
const nameRu = formData.get('nameRu') as string
|
||
|
||
if (!slug || !nameTr || !nameEn || !nameRu) {
|
||
return { error: 'Zorunlu alanları doldurun.' }
|
||
}
|
||
|
||
const data = {
|
||
slug,
|
||
nameTr,
|
||
nameEn,
|
||
nameRu,
|
||
}
|
||
|
||
try {
|
||
if (id && id !== 'new') {
|
||
await mockDb.updateNeighborhood(id, data)
|
||
} else {
|
||
await mockDb.createNeighborhood(data)
|
||
}
|
||
revalidatePath('/admin/neighborhoods')
|
||
return { success: true, error: undefined }
|
||
} catch (err: any) {
|
||
return { success: false, error: err.message || 'Mahalle kaydedilemedi.' }
|
||
}
|
||
}
|
||
|
||
// AI Itinerary Planner Actions (Phase 3)
|
||
import crypto from 'crypto'
|
||
import { generateItineraryContent, type ItineraryCandidate } from '@/lib/deepseek'
|
||
|
||
const MAX_ITINERARY_CANDIDATES = 30
|
||
|
||
export async function generateItineraryAction(days: number, style: string, neighborhoodSlugs: string[], locale: string) {
|
||
const sorted = [...neighborhoodSlugs].sort()
|
||
const paramsHash = crypto.createHash('md5').update(JSON.stringify({ days, style, neighborhoodSlugs: sorted, locale })).digest('hex')
|
||
|
||
const existing = await mockDb.getItineraryByHash(paramsHash)
|
||
if (existing) {
|
||
return { success: true, id: existing.id }
|
||
}
|
||
|
||
// Cache hit above is free; only a genuinely new combination reaches the
|
||
// paid DeepSeek call, so rate-limit per IP to bound abuse cost.
|
||
const ip = await getClientIp()
|
||
if (!checkRateLimit(`itinerary:${ip}`, 10, 60 * 60_000)) {
|
||
return { success: false, error: 'Çok fazla plan oluşturuldu. Lütfen bir süre sonra tekrar deneyin.' }
|
||
}
|
||
|
||
const allListings = await mockDb.getListings()
|
||
const matchingListings = allListings.filter(l =>
|
||
!l.deletedAt && l.isLocalApproved &&
|
||
(sorted.length === 0 ||
|
||
(l.neighborhood && sorted.includes(l.neighborhood.slug)))
|
||
)
|
||
const pool = (matchingListings.length > 0 ? matchingListings : allListings.filter(l => !l.deletedAt && l.isLocalApproved))
|
||
.sort((a, b) => Number(b.isFeatured) - Number(a.isFeatured))
|
||
.slice(0, MAX_ITINERARY_CANDIDATES)
|
||
|
||
if (pool.length === 0) {
|
||
return { success: false, error: 'Seçtiğiniz bölgelerde yerel onaylı mekan bulunamadı.' }
|
||
}
|
||
|
||
const nameKey = locale === 'en' ? 'nameEn' : locale === 'ru' ? 'nameRu' : 'nameTr'
|
||
const descKey = locale === 'en' ? 'descriptionEn' : locale === 'ru' ? 'descriptionRu' : 'descriptionTr'
|
||
|
||
const candidates: ItineraryCandidate[] = pool.map(l => ({
|
||
id: l.id,
|
||
name: (l as any)[nameKey] || l.nameTr,
|
||
description: (l as any)[descKey] || l.descriptionTr,
|
||
category: l.category?.nameTr || '',
|
||
categorySlug: l.category?.slug || 'isletme',
|
||
neighborhood: l.neighborhood?.nameTr || '',
|
||
address: l.address,
|
||
priceRange: l.priceRange,
|
||
rating: l.rating ?? null,
|
||
slug: l.slug,
|
||
isFeatured: l.isFeatured,
|
||
}))
|
||
|
||
let content: string
|
||
try {
|
||
content = await generateItineraryContent({ days, style, locale, candidates })
|
||
} catch (e: any) {
|
||
console.error('DeepSeek itinerary generation error:', e)
|
||
return { success: false, error: 'Planınız oluşturulamadı, lütfen birazdan tekrar deneyin.' }
|
||
}
|
||
|
||
const newItin = await mockDb.createItinerary({
|
||
paramsHash,
|
||
params: { days, style, neighborhoodSlugs: sorted, locale },
|
||
content,
|
||
listingIds: pool.map(l => l.id)
|
||
})
|
||
|
||
return { success: true, id: newItin.id }
|
||
}
|
||
|
||
|
||
|
||
export async function createOrUpdateCategoryAction(formData: FormData) {
|
||
await requireAdmin()
|
||
const id = formData.get('id') as string
|
||
const slug = formData.get('slug') as string
|
||
const nameTr = formData.get('nameTr') as string
|
||
const nameEn = formData.get('nameEn') as string
|
||
const nameRu = formData.get('nameRu') as string
|
||
|
||
if (!slug || !nameTr || !nameEn || !nameRu) {
|
||
throw new Error('Eksik alanlar var')
|
||
}
|
||
|
||
if (id && id !== 'new') {
|
||
await mockDb.updateCategory(id, { slug, nameTr, nameEn, nameRu })
|
||
} else {
|
||
await mockDb.createCategory({ slug, nameTr, nameEn, nameRu })
|
||
}
|
||
|
||
revalidatePath('/admin/categories')
|
||
redirect('/tr/admin/categories')
|
||
}
|
||
|
||
export async function deleteCategoryAction(formData: FormData) {
|
||
await requireAdmin()
|
||
const id = formData.get('id') as string
|
||
if (!id) return
|
||
await mockDb.deleteCategory(id)
|
||
revalidatePath('/admin/categories')
|
||
}
|
||
|
||
export async function deleteSubmissionAction(id: string) {
|
||
await requireAdmin()
|
||
await mockDb.deleteSubmission(id)
|
||
revalidatePath('/admin/submissions')
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
export async function deleteMessageAction(id: string) {
|
||
await requireAdmin()
|
||
await mockDb.deleteMessage(id)
|
||
revalidatePath('/admin/messages')
|
||
return { success: true, error: undefined }
|
||
}
|
||
|
||
export async function searchListingsAction(query: string) {
|
||
if (!query || query.length < 2) return []
|
||
|
||
const allListings = await mockDb.getListings()
|
||
const q = query.toLowerCase()
|
||
|
||
const results = allListings.filter(l =>
|
||
!l.deletedAt && (
|
||
l.nameTr.toLowerCase().includes(q) ||
|
||
l.nameEn?.toLowerCase().includes(q) ||
|
||
l.nameRu?.toLowerCase().includes(q)
|
||
)
|
||
).slice(0, 5) // Return max 5 suggestions
|
||
|
||
return results.map(l => ({
|
||
id: l.id,
|
||
nameTr: l.nameTr,
|
||
nameEn: l.nameEn,
|
||
nameRu: l.nameRu,
|
||
slug: l.slug,
|
||
categorySlug: l.category?.slug || 'restoran'
|
||
}))
|
||
}
|
||
|
||
|
||
export async function saveWidgetPartnerAction(formData: FormData) {
|
||
await requireAdmin()
|
||
const id = formData.get('id') as string | null
|
||
const name = formData.get('name') as string
|
||
const url = formData.get('url') as string
|
||
const neighborhoodSlug = formData.get('neighborhoodSlug') as string | null
|
||
const isActive = formData.get('isActive') === 'true'
|
||
const isHidden = formData.get('isHidden') === 'true'
|
||
|
||
if (!name || !url) {
|
||
return { error: 'Lütfen zorunlu alanları doldurun.' }
|
||
}
|
||
|
||
const data = {
|
||
name,
|
||
url,
|
||
neighborhoodSlug: neighborhoodSlug || null,
|
||
isActive,
|
||
isHidden
|
||
}
|
||
|
||
try {
|
||
if (id && id !== 'new') {
|
||
await mockDb.updateWidgetPartner(id, data)
|
||
} else {
|
||
await mockDb.createWidgetPartner(data)
|
||
}
|
||
revalidatePath('/admin/widget-partners')
|
||
return { success: true, error: undefined }
|
||
} catch (error: any) {
|
||
return { error: error.message || 'Bir hata oluştu.' }
|
||
}
|
||
}
|
||
|
||
export async function deleteWidgetPartnerAction(id: string) {
|
||
await requireAdmin()
|
||
await mockDb.deleteWidgetPartner(id)
|
||
revalidatePath('/admin/widget-partners')
|
||
return { success: true, error: undefined }
|
||
}
|