Support Ticket System — Implementation Plan
DerinFor agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a ticket-based support system between customers and admins with categories, priorities, file attachments, email + in-app notifications, and resource linking.
Architecture: Monolithic — fully integrated into existing Next.js 16 app. Prisma models for data, Next.js Route Handlers for API, server components for pages, client components for interactive UI. Notification via 30s polling + email. Follows existing auth, i18n, and styling patterns exactly.
Tech Stack: Next.js 16 (App Router), React 19, TypeScript 5.9, Prisma 7 (PostgreSQL), iron-session, next-intl, @react-email/components, Nodemailer, Tailwind CSS v4, Lucide React.
Spec: docs/superpowers/specs/2026-04-01-support-ticket-system-design.md
File Structure
Bölüm başlığı “File Structure”New Files to Create
Bölüm başlığı “New Files to Create”prisma/└── schema.prisma (MODIFY — add 5 new models)
lib/├── ticket-notifications.ts (CREATE — notification service)├── ticket-number.ts (CREATE — TKT-XXXXX generator)├── ticket-file-storage.ts (CREATE — ticket attachment storage)└── mail/ ├── i18n.ts (MODIFY — add ticket email translations) └── templates/ ├── index.ts (MODIFY — register ticket templates) └── ticket-notification.tsx (CREATE — ticket email template)
app/api/├── admin/│ ├── tickets/│ │ ├── route.ts (CREATE — GET list + POST create)│ │ ├── stats/route.ts (CREATE — GET dashboard stats)│ │ └── [id]/│ │ ├── route.ts (CREATE — GET detail + PATCH update)│ │ ├── messages/route.ts (CREATE — POST send message)│ │ ├── close/route.ts (CREATE — POST close ticket)│ │ └── attachments/[attachmentId]/route.ts (CREATE — GET download)│ ├── ticket-categories/│ │ ├── route.ts (CREATE — GET list + POST create)│ │ └── [id]/route.ts (CREATE — PATCH update + DELETE soft-delete)│ └── notifications/│ ├── route.ts (CREATE — GET list)│ ├── [id]/read/route.ts (CREATE — PATCH mark read)│ └── read-all/route.ts (CREATE — PATCH mark all read)├── customer/│ ├── tickets/│ │ ├── route.ts (CREATE — GET list + POST create)│ │ └── [id]/│ │ ├── route.ts (CREATE — GET detail)│ │ ├── messages/route.ts (CREATE — POST send message)│ │ ├── close/route.ts (CREATE — POST close ticket)│ │ └── attachments/[attachmentId]/route.ts (CREATE — GET download)│ └── notifications/│ ├── route.ts (CREATE — GET list)│ ├── [id]/read/route.ts (CREATE — PATCH mark read)│ └── read-all/route.ts (CREATE — PATCH mark all read)└── upload/ └── ticket-attachment/route.ts (CREATE — POST upload)
app/[locale]/├── admin/(panel)/│ ├── tickets/│ │ ├── page.tsx (CREATE — ticket list)│ │ └── [id]/page.tsx (CREATE — ticket detail)│ └── ticket-categories/│ └── page.tsx (CREATE — category CRUD)└── customer/(portal)/ └── tickets/ ├── page.tsx (CREATE — ticket list) ├── new/page.tsx (CREATE — new ticket form) └── [id]/page.tsx (CREATE — ticket detail)
components/├── tickets/│ ├── TicketStatusBadge.tsx (CREATE)│ ├── TicketPriorityBadge.tsx (CREATE)│ ├── TicketMessageBubble.tsx (CREATE)│ ├── TicketAttachmentUpload.tsx (CREATE)│ └── TicketRelationSelect.tsx (CREATE)├── notifications/│ ├── NotificationBell.tsx (CREATE)│ ├── NotificationDropdown.tsx (CREATE)│ └── NotificationItem.tsx (CREATE)├── admin/│ ├── AdminSidebar.tsx (MODIFY — add ticket menu items)│ └── AdminHeader.tsx (MODIFY — add NotificationBell)└── customer/ └── CustomerSidebar.tsx (MODIFY — add ticket menu item)
messages/├── en.json (MODIFY — add ticket translations)└── tr.json (MODIFY — add ticket translations)Task 1: Prisma Schema — Add Ticket Models
Bölüm başlığı “Task 1: Prisma Schema — Add Ticket Models”Files:
-
Modify:
prisma/schema.prisma -
Step 1: Add TicketCategory model to schema
Add after the ApiChangelog model at the end of prisma/schema.prisma:
model TicketCategory { id String @id @default(cuid()) name Json // {"tr": "Teknik Destek", "en": "Technical Support", ...} slug String @unique color String? // Badge color hex: "#ef4444" sortOrder Int @default(0) isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
tickets Ticket[]}- Step 2: Add Ticket model
model Ticket { id String @id @default(cuid()) ticketNumber String @unique subject String status String @default("open") priority String @default("normal")
customerId String createdById String createdByType String
categoryId String? relatedFileId String? relatedInvoiceId String? relatedDevRequestId String?
lastMessageAt DateTime @default(now()) closedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
messages TicketMessage[] notifications TicketNotification[]
customer Customer @relation(fields: [customerId], references: [id]) category TicketCategory? @relation(fields: [categoryId], references: [id]) relatedFile File? @relation(fields: [relatedFileId], references: [id]) relatedInvoice Invoice? @relation(fields: [relatedInvoiceId], references: [id]) relatedDevRequest DevelopmentRequest? @relation(fields: [relatedDevRequestId], references: [id])}- Step 3: Add TicketMessage model
model TicketMessage { id String @id @default(cuid()) ticketId String content String senderType String senderId String isInternal Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) attachments TicketAttachment[]}- Step 4: Add TicketAttachment model
model TicketAttachment { id String @id @default(cuid()) messageId String fileName String filePath String fileSize Int mimeType String createdAt DateTime @default(now())
message TicketMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)}- Step 5: Add TicketNotification model
model TicketNotification { id String @id @default(cuid()) ticketId String recipientType String recipientId String type String title String body String isRead Boolean @default(false) createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)}- Step 6: Add reverse relations to existing models
Add to the Customer model (after existing relations):
tickets Ticket[]Add to the File model (after existing relations):
tickets Ticket[]Add to the Invoice model (after existing relations):
tickets Ticket[]Add to the DevelopmentRequest model (after existing relations):
tickets Ticket[]- Step 7: Run migration
cd /var/www/vhosts/ecutuningportal.com/httpdocsnpx prisma migrate dev --name add_ticket_systemExpected: Migration created and applied successfully.
- Step 8: Verify Prisma client generation
npx prisma generateExpected: Prisma Client generated successfully
- Step 9: Commit
git add prisma/git commit -m "feat(db): ticket sistemi için Prisma modelleri eklendi"Task 2: Ticket Number Generator + Ticket File Storage
Bölüm başlığı “Task 2: Ticket Number Generator + Ticket File Storage”Files:
-
Create:
lib/ticket-number.ts -
Create:
lib/ticket-file-storage.ts -
Step 1: Create ticket number generator
Create lib/ticket-number.ts:
import prisma from '@/lib/prisma';
export async function generateTicketNumber(): Promise<string> { const lastTicket = await prisma.ticket.findFirst({ orderBy: { createdAt: 'desc' }, select: { ticketNumber: true }, });
let nextNum = 1; if (lastTicket?.ticketNumber) { const match = lastTicket.ticketNumber.match(/TKT-(\d+)/); if (match) { nextNum = parseInt(match[1], 10) + 1; } }
return `TKT-${String(nextNum).padStart(5, '0')}`;}- Step 2: Create ticket file storage utility
Create lib/ticket-file-storage.ts:
import path from 'path';import fs from 'fs/promises';import { createReadStream } from 'fs';import { Readable } from 'stream';
const TICKET_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'tickets');
const ALLOWED_MIME_TYPES = [ 'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'application/pdf', 'application/octet-stream', 'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function validateTicketFile(file: File): string | null { if (file.size > MAX_FILE_SIZE) { return 'File size exceeds 10MB limit'; } if (!ALLOWED_MIME_TYPES.includes(file.type) && !file.type.startsWith('image/')) { return 'File type not allowed'; } return null;}
function sanitizeFileName(name: string): string { return name.replace(/[^a-zA-Z0-9._-]/g, '_');}
export async function saveTicketAttachment( file: File, ticketId: string, messageId: string): Promise<{ filePath: string; fileName: string; fileSize: number; mimeType: string }> { const dir = path.join(TICKET_UPLOAD_DIR, ticketId, messageId); await fs.mkdir(dir, { recursive: true });
const safeName = sanitizeFileName(file.name); const uniqueName = `${Date.now()}_${safeName}`; const filePath = path.join(dir, uniqueName);
const buffer = Buffer.from(await file.arrayBuffer()); await fs.writeFile(filePath, buffer);
return { filePath, fileName: file.name, fileSize: file.size, mimeType: file.type, };}
export function getTicketAttachmentStream(filePath: string): Readable { const resolved = path.resolve(filePath); if (!resolved.startsWith(path.resolve(TICKET_UPLOAD_DIR))) { throw new Error('Path traversal detected'); } return createReadStream(resolved);}- Step 3: Verify build
npx next build 2>&1 | tail -5Expected: No TypeScript errors related to new files.
- Step 4: Commit
git add lib/ticket-number.ts lib/ticket-file-storage.tsgit commit -m "feat: ticket numarası üreteci ve dosya depolama yardımcıları eklendi"Task 3: Ticket Notification Service
Bölüm başlığı “Task 3: Ticket Notification Service”Files:
-
Create:
lib/ticket-notifications.ts -
Step 1: Create notification service
Create lib/ticket-notifications.ts:
import prisma from '@/lib/prisma';import { sendMail } from '@/lib/mail/service';
type NotificationType = 'new_ticket' | 'new_message' | 'status_changed' | 'ticket_closed';
interface NotifyParams { ticketId: string; ticketNumber: string; ticketSubject: string; type: NotificationType; messagePreview?: string; newStatus?: string;}
async function createNotification( params: NotifyParams & { recipientType: string; recipientId: string; title: string; body: string }) { await prisma.ticketNotification.create({ data: { ticketId: params.ticketId, recipientType: params.recipientType, recipientId: params.recipientId, type: params.type, title: params.title, body: params.body, }, });}
export async function notifyNewTicket(params: NotifyParams & { customerId: string; createdByType: string }) { if (params.createdByType === 'customer') { // Notify all admins const admins = await prisma.adminUser.findMany({ select: { id: true, email: true } }); for (const admin of admins) { await createNotification({ ...params, recipientType: 'admin', recipientId: admin.id, title: `Yeni destek talebi: ${params.ticketNumber}`, body: params.ticketSubject, }); } } else { // Admin created on behalf — notify customer const customer = await prisma.customer.findUnique({ where: { id: params.customerId }, select: { id: true, email: true, locale: true }, }); if (customer) { await createNotification({ ...params, recipientType: 'customer', recipientId: customer.id, title: `Yeni destek talebi: ${params.ticketNumber}`, body: params.ticketSubject, }); sendMail({ to: customer.email, template: 'ticket-new', data: { locale: customer.locale, ticketNumber: params.ticketNumber, subject: params.ticketSubject, portalUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/${customer.locale}/customer/tickets`, }, }).catch(console.error); } }}
export async function notifyNewMessage( params: NotifyParams & { senderType: string; customerId: string; isInternal: boolean }) { if (params.isInternal) return; // Never notify customer for internal notes
if (params.senderType === 'customer') { // Notify all admins const admins = await prisma.adminUser.findMany({ select: { id: true, email: true } }); for (const admin of admins) { await createNotification({ ...params, recipientType: 'admin', recipientId: admin.id, title: `Yeni yanıt: ${params.ticketNumber}`, body: params.messagePreview || params.ticketSubject, }); } } else { // Admin replied — notify customer const customer = await prisma.customer.findUnique({ where: { id: params.customerId }, select: { id: true, email: true, locale: true }, }); if (customer) { await createNotification({ ...params, recipientType: 'customer', recipientId: customer.id, title: `Yeni yanıt: ${params.ticketNumber}`, body: params.messagePreview || params.ticketSubject, }); sendMail({ to: customer.email, template: 'ticket-reply', data: { locale: customer.locale, ticketNumber: params.ticketNumber, subject: params.ticketSubject, messagePreview: params.messagePreview || '', portalUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/${customer.locale}/customer/tickets`, }, }).catch(console.error); } }}
export async function notifyStatusChange(params: NotifyParams & { customerId: string }) { const customer = await prisma.customer.findUnique({ where: { id: params.customerId }, select: { id: true, email: true, locale: true }, }); if (!customer) return;
await createNotification({ ...params, recipientType: 'customer', recipientId: customer.id, title: `Durum güncellendi: ${params.ticketNumber}`, body: `${params.ticketSubject} — ${params.newStatus}`, });
sendMail({ to: customer.email, template: 'ticket-status-changed', data: { locale: customer.locale, ticketNumber: params.ticketNumber, subject: params.ticketSubject, newStatus: params.newStatus || '', portalUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/${customer.locale}/customer/tickets`, }, }).catch(console.error);}
export async function notifyTicketClosed( params: NotifyParams & { closedByType: string; customerId: string }) { if (params.closedByType === 'admin') { // Notify customer const customer = await prisma.customer.findUnique({ where: { id: params.customerId }, select: { id: true, email: true, locale: true }, }); if (customer) { await createNotification({ ...params, recipientType: 'customer', recipientId: customer.id, title: `Talep kapatıldı: ${params.ticketNumber}`, body: params.ticketSubject, }); sendMail({ to: customer.email, template: 'ticket-closed', data: { locale: customer.locale, ticketNumber: params.ticketNumber, subject: params.ticketSubject, portalUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/${customer.locale}/customer/tickets`, }, }).catch(console.error); } } else { // Customer closed — notify all admins const admins = await prisma.adminUser.findMany({ select: { id: true, email: true } }); for (const admin of admins) { await createNotification({ ...params, recipientType: 'admin', recipientId: admin.id, title: `Talep kapatıldı: ${params.ticketNumber}`, body: params.ticketSubject, }); } }}- Step 2: Commit
git add lib/ticket-notifications.tsgit commit -m "feat: ticket bildirim servisi eklendi"Task 4: Email Templates for Tickets
Bölüm başlığı “Task 4: Email Templates for Tickets”Files:
-
Create:
lib/mail/templates/ticket-notification.tsx -
Modify:
lib/mail/templates/index.ts -
Modify:
lib/mail/i18n.ts -
Modify:
lib/mail/service.ts -
Step 1: Add ticket email translations to i18n.ts
Open lib/mail/i18n.ts and add these template translations to the translations object for both en and tr locales (and other supported locales as needed):
// Add to the translations map under each locale:
// English'ticket-new': { subject: 'New Support Ticket: {ticketNumber}', heading: 'Support Ticket Created', message: 'Your support ticket <strong>{ticketNumber}</strong> has been created.', subject_label: 'Subject', button: 'View Ticket',},'ticket-reply': { subject: 'New Reply: {ticketNumber}', heading: 'New Reply on Your Ticket', message: 'There is a new reply on your support ticket <strong>{ticketNumber}</strong>.', subject_label: 'Subject', preview_label: 'Message', button: 'View Ticket',},'ticket-status-changed': { subject: 'Status Updated: {ticketNumber}', heading: 'Ticket Status Updated', message: 'The status of your support ticket <strong>{ticketNumber}</strong> has been updated.', subject_label: 'Subject', status_label: 'New Status', button: 'View Ticket',},'ticket-closed': { subject: 'Ticket Closed: {ticketNumber}', heading: 'Support Ticket Closed', message: 'Your support ticket <strong>{ticketNumber}</strong> has been closed.', subject_label: 'Subject', button: 'View Ticket',},
// Turkish'ticket-new': { subject: 'Yeni Destek Talebi: {ticketNumber}', heading: 'Destek Talebi Oluşturuldu', message: '<strong>{ticketNumber}</strong> numaralı destek talebiniz oluşturuldu.', subject_label: 'Konu', button: 'Talebi Görüntüle',},'ticket-reply': { subject: 'Yeni Yanıt: {ticketNumber}', heading: 'Destek Talebinize Yeni Yanıt', message: '<strong>{ticketNumber}</strong> numaralı destek talebinize yeni bir yanıt verildi.', subject_label: 'Konu', preview_label: 'Mesaj', button: 'Talebi Görüntüle',},'ticket-status-changed': { subject: 'Durum Güncellendi: {ticketNumber}', heading: 'Talep Durumu Güncellendi', message: '<strong>{ticketNumber}</strong> numaralı destek talebinizin durumu güncellendi.', subject_label: 'Konu', status_label: 'Yeni Durum', button: 'Talebi Görüntüle',},'ticket-closed': { subject: 'Talep Kapatıldı: {ticketNumber}', heading: 'Destek Talebi Kapatıldı', message: '<strong>{ticketNumber}</strong> numaralı destek talebiniz kapatıldı.', subject_label: 'Konu', button: 'Talebi Görüntüle',},- Step 2: Register ticket templates in the template dispatcher
Open lib/mail/templates/index.ts and add ticket templates to the renderTemplate function. These use the existing NotificationEmail component — no custom template needed:
The NotificationEmail component already handles _label suffix keys to build a detail table and renders a CTA button via portalUrl. Ticket templates fit this pattern exactly. Just register the four template names (ticket-new, ticket-reply, ticket-status-changed, ticket-closed) so they route to NotificationEmail.
- Step 3: Add ticket template types to service.ts
Open lib/mail/service.ts and add the ticket template types to the TemplatePropsMap type:
'ticket-new': { locale: string; ticketNumber: string; subject: string; portalUrl: string;};'ticket-reply': { locale: string; ticketNumber: string; subject: string; messagePreview: string; portalUrl: string;};'ticket-status-changed': { locale: string; ticketNumber: string; subject: string; newStatus: string; portalUrl: string;};'ticket-closed': { locale: string; ticketNumber: string; subject: string; portalUrl: string;};- Step 4: Verify build
npx next build 2>&1 | tail -5- Step 5: Commit
git add lib/mail/git commit -m "feat(mail): ticket email template'leri ve çevirileri eklendi"Task 5: Admin Ticket Categories API
Bölüm başlığı “Task 5: Admin Ticket Categories API”Files:
-
Create:
app/api/admin/ticket-categories/route.ts -
Create:
app/api/admin/ticket-categories/[id]/route.ts -
Step 1: Create categories list + create route
Create app/api/admin/ticket-categories/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { requireAdmin } from '@/lib/admin-session';
export async function GET() { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const categories = await prisma.ticketCategory.findMany({ orderBy: { sortOrder: 'asc' }, include: { _count: { select: { tickets: true } } }, });
return NextResponse.json({ success: true, categories });}
export async function POST(req: NextRequest) { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const body = await req.json(); const { name, slug, color, sortOrder } = body;
if (!name || !slug) { return NextResponse.json({ error: 'Name and slug are required' }, { status: 400 }); }
const existing = await prisma.ticketCategory.findUnique({ where: { slug } }); if (existing) { return NextResponse.json({ error: 'Slug already exists' }, { status: 409 }); }
const category = await prisma.ticketCategory.create({ data: { name, slug, color: color || null, sortOrder: sortOrder ?? 0, }, });
return NextResponse.json({ success: true, category }, { status: 201 });}- Step 2: Create category update + delete route
Create app/api/admin/ticket-categories/[id]/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { requireAdmin } from '@/lib/admin-session';
export async function PATCH( req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { id } = await params; const body = await req.json(); const { name, slug, color, sortOrder, isActive } = body;
const category = await prisma.ticketCategory.findUnique({ where: { id } }); if (!category) { return NextResponse.json({ error: 'Category not found' }, { status: 404 }); }
if (slug && slug !== category.slug) { const existing = await prisma.ticketCategory.findUnique({ where: { slug } }); if (existing) { return NextResponse.json({ error: 'Slug already exists' }, { status: 409 }); } }
const updated = await prisma.ticketCategory.update({ where: { id }, data: { ...(name !== undefined && { name }), ...(slug !== undefined && { slug }), ...(color !== undefined && { color }), ...(sortOrder !== undefined && { sortOrder }), ...(isActive !== undefined && { isActive }), }, });
return NextResponse.json({ success: true, category: updated });}
export async function DELETE( _req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { id } = await params;
await prisma.ticketCategory.update({ where: { id }, data: { isActive: false }, });
return NextResponse.json({ success: true });}- Step 3: Commit
git add app/api/admin/ticket-categories/git commit -m "feat(api): admin ticket kategori CRUD API eklendi"Task 6: Admin Tickets API
Bölüm başlığı “Task 6: Admin Tickets API”Files:
-
Create:
app/api/admin/tickets/route.ts -
Create:
app/api/admin/tickets/stats/route.ts -
Create:
app/api/admin/tickets/[id]/route.ts -
Create:
app/api/admin/tickets/[id]/messages/route.ts -
Create:
app/api/admin/tickets/[id]/close/route.ts -
Create:
app/api/admin/tickets/[id]/attachments/[attachmentId]/route.ts -
Step 1: Create admin tickets list + create route
Create app/api/admin/tickets/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { requireAdmin } from '@/lib/admin-session';import { generateTicketNumber } from '@/lib/ticket-number';import { notifyNewTicket } from '@/lib/ticket-notifications';
export async function GET(req: NextRequest) { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { searchParams } = new URL(req.url); const status = searchParams.get('status'); const priority = searchParams.get('priority'); const categoryId = searchParams.get('categoryId'); const customerId = searchParams.get('customerId'); const search = searchParams.get('search');
const where: Record<string, unknown> = {}; if (status) where.status = status; if (priority) where.priority = priority; if (categoryId) where.categoryId = categoryId; if (customerId) where.customerId = customerId; if (search) { where.OR = [ { subject: { contains: search, mode: 'insensitive' } }, { ticketNumber: { contains: search, mode: 'insensitive' } }, ]; }
const tickets = await prisma.ticket.findMany({ where, include: { customer: { select: { id: true, name: true, email: true, company: true } }, category: { select: { id: true, name: true, color: true } }, _count: { select: { messages: true } }, }, orderBy: { lastMessageAt: 'desc' }, });
return NextResponse.json({ success: true, tickets });}
export async function POST(req: NextRequest) { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const body = await req.json(); const { customerId, subject, content, categoryId, priority, relatedFileId, relatedInvoiceId, relatedDevRequestId } = body;
if (!customerId || !subject || !content) { return NextResponse.json({ error: 'customerId, subject, and content are required' }, { status: 400 }); }
const customer = await prisma.customer.findUnique({ where: { id: customerId } }); if (!customer) { return NextResponse.json({ error: 'Customer not found' }, { status: 404 }); }
const ticketNumber = await generateTicketNumber();
const ticket = await prisma.ticket.create({ data: { ticketNumber, subject, status: 'awaiting_customer', priority: priority || 'normal', customerId, createdById: admin.adminId, createdByType: 'admin', categoryId: categoryId || null, relatedFileId: relatedFileId || null, relatedInvoiceId: relatedInvoiceId || null, relatedDevRequestId: relatedDevRequestId || null, messages: { create: { content, senderType: 'admin', senderId: admin.adminId, }, }, }, include: { customer: { select: { id: true, name: true, email: true } }, messages: true, }, });
notifyNewTicket({ ticketId: ticket.id, ticketNumber, ticketSubject: subject, type: 'new_ticket', customerId, createdByType: 'admin', }).catch(console.error);
return NextResponse.json({ success: true, ticket }, { status: 201 });}- Step 2: Create admin ticket stats route
Create app/api/admin/tickets/stats/route.ts:
import { NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { requireAdmin } from '@/lib/admin-session';
export async function GET() { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const [open, awaitingAdmin, awaitingCustomer, resolved, closed, total] = await Promise.all([ prisma.ticket.count({ where: { status: 'open' } }), prisma.ticket.count({ where: { status: 'awaiting_admin' } }), prisma.ticket.count({ where: { status: 'awaiting_customer' } }), prisma.ticket.count({ where: { status: 'resolved' } }), prisma.ticket.count({ where: { status: 'closed' } }), prisma.ticket.count(), ]);
return NextResponse.json({ success: true, stats: { open, awaitingAdmin, awaitingCustomer, resolved, closed, total }, });}- Step 3: Create admin ticket detail + update route
Create app/api/admin/tickets/[id]/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { requireAdmin } from '@/lib/admin-session';import { notifyStatusChange } from '@/lib/ticket-notifications';
export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { id } = await params;
const ticket = await prisma.ticket.findUnique({ where: { id }, include: { customer: { select: { id: true, name: true, email: true, company: true } }, category: { select: { id: true, name: true, color: true, slug: true } }, relatedFile: { select: { id: true, originalName: true } }, relatedInvoice: { select: { id: true, invoiceNumber: true, title: true } }, relatedDevRequest: { select: { id: true, title: true } }, messages: { include: { attachments: true, }, orderBy: { createdAt: 'asc' }, }, }, });
if (!ticket) { return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); }
return NextResponse.json({ success: true, ticket });}
export async function PATCH( req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { id } = await params; const body = await req.json(); const { status, priority, categoryId } = body;
const ticket = await prisma.ticket.findUnique({ where: { id } }); if (!ticket) { return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); }
const data: Record<string, unknown> = {}; if (status !== undefined) data.status = status; if (priority !== undefined) data.priority = priority; if (categoryId !== undefined) data.categoryId = categoryId; if (status === 'closed' || status === 'resolved') data.closedAt = new Date();
const updated = await prisma.ticket.update({ where: { id }, data });
if (status && status !== ticket.status) { notifyStatusChange({ ticketId: ticket.id, ticketNumber: ticket.ticketNumber, ticketSubject: ticket.subject, type: 'status_changed', customerId: ticket.customerId, newStatus: status, }).catch(console.error); }
return NextResponse.json({ success: true, ticket: updated });}- Step 4: Create admin ticket messages route
Create app/api/admin/tickets/[id]/messages/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { requireAdmin } from '@/lib/admin-session';import { notifyNewMessage } from '@/lib/ticket-notifications';
export async function POST( req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { id } = await params; const body = await req.json(); const { content, isInternal, attachmentIds } = body;
if (!content || content.trim().length === 0) { return NextResponse.json({ error: 'Content is required' }, { status: 400 }); }
if (content.length > 5000) { return NextResponse.json({ error: 'Content exceeds 5000 character limit' }, { status: 400 }); }
const ticket = await prisma.ticket.findUnique({ where: { id } }); if (!ticket) { return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); }
const message = await prisma.ticketMessage.create({ data: { ticketId: id, content: content.trim(), senderType: 'admin', senderId: admin.adminId, isInternal: isInternal || false, ...(attachmentIds?.length && { attachments: { connect: attachmentIds.map((aid: string) => ({ id: aid })), }, }), }, include: { attachments: true }, });
// Update ticket status and lastMessageAt const newStatus = isInternal ? ticket.status : 'awaiting_customer'; await prisma.ticket.update({ where: { id }, data: { lastMessageAt: new Date(), ...((!isInternal && ticket.status !== 'closed') && { status: newStatus }), }, });
notifyNewMessage({ ticketId: ticket.id, ticketNumber: ticket.ticketNumber, ticketSubject: ticket.subject, type: 'new_message', senderType: 'admin', customerId: ticket.customerId, isInternal: isInternal || false, messagePreview: content.trim().substring(0, 100), }).catch(console.error);
return NextResponse.json({ success: true, message }, { status: 201 });}- Step 5: Create admin ticket close route
Create app/api/admin/tickets/[id]/close/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { requireAdmin } from '@/lib/admin-session';import { notifyTicketClosed } from '@/lib/ticket-notifications';
export async function POST( _req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { id } = await params;
const ticket = await prisma.ticket.findUnique({ where: { id } }); if (!ticket) { return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); }
if (ticket.status === 'closed') { return NextResponse.json({ error: 'Ticket is already closed' }, { status: 400 }); }
await prisma.ticket.update({ where: { id }, data: { status: 'closed', closedAt: new Date() }, });
notifyTicketClosed({ ticketId: ticket.id, ticketNumber: ticket.ticketNumber, ticketSubject: ticket.subject, type: 'ticket_closed', closedByType: 'admin', customerId: ticket.customerId, }).catch(console.error);
return NextResponse.json({ success: true });}- Step 6: Create admin ticket attachment download route
Create app/api/admin/tickets/[id]/attachments/[attachmentId]/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { requireAdmin } from '@/lib/admin-session';import { getTicketAttachmentStream } from '@/lib/ticket-file-storage';import { Readable } from 'stream';
export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string; attachmentId: string }> }) { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { id, attachmentId } = await params;
const attachment = await prisma.ticketAttachment.findUnique({ where: { id: attachmentId }, include: { message: { select: { ticketId: true } } }, });
if (!attachment || attachment.message.ticketId !== id) { return NextResponse.json({ error: 'Attachment not found' }, { status: 404 }); }
const nodeStream = getTicketAttachmentStream(attachment.filePath); const webStream = Readable.toWeb(nodeStream) as ReadableStream;
return new NextResponse(webStream, { headers: { 'Content-Type': attachment.mimeType, 'Content-Disposition': `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, 'Content-Length': String(attachment.fileSize), }, });}- Step 7: Commit
git add app/api/admin/tickets/git commit -m "feat(api): admin ticket API route'ları eklendi (CRUD, mesaj, kapatma, ek indirme)"Task 7: Customer Tickets API
Bölüm başlığı “Task 7: Customer Tickets API”Files:
-
Create:
app/api/customer/tickets/route.ts -
Create:
app/api/customer/tickets/[id]/route.ts -
Create:
app/api/customer/tickets/[id]/messages/route.ts -
Create:
app/api/customer/tickets/[id]/close/route.ts -
Create:
app/api/customer/tickets/[id]/attachments/[attachmentId]/route.ts -
Step 1: Create customer tickets list + create route
Create app/api/customer/tickets/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { validateCustomerSession } from '@/lib/customer-session';import { generateTicketNumber } from '@/lib/ticket-number';import { notifyNewTicket } from '@/lib/ticket-notifications';import { rateLimit } from '@/lib/rate-limit';import { headers } from 'next/headers';
export async function GET() { const session = await validateCustomerSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const tickets = await prisma.ticket.findMany({ where: { customerId: session.customerId }, include: { category: { select: { id: true, name: true, color: true } }, _count: { select: { messages: { where: { isInternal: false } } } }, }, orderBy: { lastMessageAt: 'desc' }, });
return NextResponse.json({ success: true, tickets });}
export async function POST(req: NextRequest) { const session = await validateCustomerSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const headersList = await headers(); const ip = headersList.get('x-forwarded-for') || 'unknown'; const { allowed } = rateLimit(`ticket_create:${ip}`, { maxRequests: 10, windowMs: 60 * 60 * 1000 }); if (!allowed) { return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 }); }
const body = await req.json(); const { subject, content, categoryId, priority, relatedFileId, relatedInvoiceId, relatedDevRequestId } = body;
if (!subject || !content) { return NextResponse.json({ error: 'Subject and content are required' }, { status: 400 }); }
if (subject.length > 200) { return NextResponse.json({ error: 'Subject exceeds 200 character limit' }, { status: 400 }); }
if (content.length > 5000) { return NextResponse.json({ error: 'Content exceeds 5000 character limit' }, { status: 400 }); }
// Verify related resources belong to this customer if (relatedFileId) { const file = await prisma.file.findUnique({ where: { id: relatedFileId } }); if (!file || file.customerId !== session.customerId) { return NextResponse.json({ error: 'Invalid related file' }, { status: 400 }); } } if (relatedInvoiceId) { const invoice = await prisma.invoice.findUnique({ where: { id: relatedInvoiceId } }); if (!invoice || invoice.customerId !== session.customerId) { return NextResponse.json({ error: 'Invalid related invoice' }, { status: 400 }); } } if (relatedDevRequestId) { const devReq = await prisma.developmentRequest.findUnique({ where: { id: relatedDevRequestId } }); if (!devReq || devReq.customerId !== session.customerId) { return NextResponse.json({ error: 'Invalid related development request' }, { status: 400 }); } }
const ticketNumber = await generateTicketNumber();
const ticket = await prisma.ticket.create({ data: { ticketNumber, subject, priority: priority || 'normal', customerId: session.customerId, createdById: session.customerId, createdByType: 'customer', categoryId: categoryId || null, relatedFileId: relatedFileId || null, relatedInvoiceId: relatedInvoiceId || null, relatedDevRequestId: relatedDevRequestId || null, messages: { create: { content: content.trim(), senderType: 'customer', senderId: session.customerId, }, }, }, });
notifyNewTicket({ ticketId: ticket.id, ticketNumber, ticketSubject: subject, type: 'new_ticket', customerId: session.customerId, createdByType: 'customer', }).catch(console.error);
return NextResponse.json({ success: true, ticket }, { status: 201 });}- Step 2: Create customer ticket detail route
Create app/api/customer/tickets/[id]/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { validateCustomerSession } from '@/lib/customer-session';
export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const session = await validateCustomerSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { id } = await params;
const ticket = await prisma.ticket.findUnique({ where: { id }, include: { category: { select: { id: true, name: true, color: true } }, relatedFile: { select: { id: true, originalName: true } }, relatedInvoice: { select: { id: true, invoiceNumber: true, title: true } }, relatedDevRequest: { select: { id: true, title: true } }, messages: { where: { isInternal: false }, include: { attachments: true }, orderBy: { createdAt: 'asc' }, }, }, });
if (!ticket || ticket.customerId !== session.customerId) { return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); }
return NextResponse.json({ success: true, ticket });}- Step 3: Create customer ticket messages route
Create app/api/customer/tickets/[id]/messages/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { validateCustomerSession } from '@/lib/customer-session';import { notifyNewMessage } from '@/lib/ticket-notifications';import { rateLimit } from '@/lib/rate-limit';import { headers } from 'next/headers';
export async function POST( req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const session = await validateCustomerSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const headersList = await headers(); const ip = headersList.get('x-forwarded-for') || 'unknown'; const { allowed } = rateLimit(`ticket_message:${ip}`, { maxRequests: 5, windowMs: 60 * 1000 }); if (!allowed) { return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 }); }
const { id } = await params; const body = await req.json(); const { content, attachmentIds } = body;
if (!content || content.trim().length === 0) { return NextResponse.json({ error: 'Content is required' }, { status: 400 }); }
if (content.length > 5000) { return NextResponse.json({ error: 'Content exceeds 5000 character limit' }, { status: 400 }); }
const ticket = await prisma.ticket.findUnique({ where: { id } }); if (!ticket || ticket.customerId !== session.customerId) { return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); }
if (ticket.status === 'closed') { return NextResponse.json({ error: 'Cannot reply to a closed ticket' }, { status: 400 }); }
const message = await prisma.ticketMessage.create({ data: { ticketId: id, content: content.trim(), senderType: 'customer', senderId: session.customerId, ...(attachmentIds?.length && { attachments: { connect: attachmentIds.map((aid: string) => ({ id: aid })), }, }), }, include: { attachments: true }, });
await prisma.ticket.update({ where: { id }, data: { lastMessageAt: new Date(), status: 'awaiting_admin', }, });
notifyNewMessage({ ticketId: ticket.id, ticketNumber: ticket.ticketNumber, ticketSubject: ticket.subject, type: 'new_message', senderType: 'customer', customerId: session.customerId, isInternal: false, messagePreview: content.trim().substring(0, 100), }).catch(console.error);
return NextResponse.json({ success: true, message }, { status: 201 });}- Step 4: Create customer ticket close route
Create app/api/customer/tickets/[id]/close/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { validateCustomerSession } from '@/lib/customer-session';import { notifyTicketClosed } from '@/lib/ticket-notifications';
export async function POST( _req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const session = await validateCustomerSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { id } = await params;
const ticket = await prisma.ticket.findUnique({ where: { id } }); if (!ticket || ticket.customerId !== session.customerId) { return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); }
if (ticket.status === 'closed') { return NextResponse.json({ error: 'Ticket is already closed' }, { status: 400 }); }
await prisma.ticket.update({ where: { id }, data: { status: 'closed', closedAt: new Date() }, });
notifyTicketClosed({ ticketId: ticket.id, ticketNumber: ticket.ticketNumber, ticketSubject: ticket.subject, type: 'ticket_closed', closedByType: 'customer', customerId: session.customerId, }).catch(console.error);
return NextResponse.json({ success: true });}- Step 5: Create customer ticket attachment download route
Create app/api/customer/tickets/[id]/attachments/[attachmentId]/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { validateCustomerSession } from '@/lib/customer-session';import { getTicketAttachmentStream } from '@/lib/ticket-file-storage';import { Readable } from 'stream';
export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string; attachmentId: string }> }) { const session = await validateCustomerSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { id, attachmentId } = await params;
const ticket = await prisma.ticket.findUnique({ where: { id } }); if (!ticket || ticket.customerId !== session.customerId) { return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); }
const attachment = await prisma.ticketAttachment.findUnique({ where: { id: attachmentId }, include: { message: { select: { ticketId: true, isInternal: true } } }, });
if (!attachment || attachment.message.ticketId !== id || attachment.message.isInternal) { return NextResponse.json({ error: 'Attachment not found' }, { status: 404 }); }
const nodeStream = getTicketAttachmentStream(attachment.filePath); const webStream = Readable.toWeb(nodeStream) as ReadableStream;
return new NextResponse(webStream, { headers: { 'Content-Type': attachment.mimeType, 'Content-Disposition': `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, 'Content-Length': String(attachment.fileSize), }, });}- Step 6: Commit
git add app/api/customer/tickets/git commit -m "feat(api): customer ticket API route'ları eklendi (liste, detay, mesaj, kapatma, ek indirme)"Task 8: Ticket Attachment Upload API
Bölüm başlığı “Task 8: Ticket Attachment Upload API”Files:
-
Create:
app/api/upload/ticket-attachment/route.ts -
Step 1: Create upload route
Create app/api/upload/ticket-attachment/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { requireAdmin } from '@/lib/admin-session';import { validateCustomerSession } from '@/lib/customer-session';import { validateTicketFile, saveTicketAttachment } from '@/lib/ticket-file-storage';
export async function POST(req: NextRequest) { // Allow both admin and customer const admin = await requireAdmin().catch(() => null); const customer = !admin ? await validateCustomerSession() : null;
if (!admin && !customer) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const formData = await req.formData(); const file = formData.get('file') as File | null; const ticketId = formData.get('ticketId') as string | null; const messageId = formData.get('messageId') as string | null;
if (!file || !ticketId) { return NextResponse.json({ error: 'File and ticketId are required' }, { status: 400 }); }
// Verify ticket access const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } }); if (!ticket) { return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); }
if (customer && ticket.customerId !== customer.customerId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); }
const validationError = validateTicketFile(file); if (validationError) { return NextResponse.json({ error: validationError }, { status: 400 }); }
const saved = await saveTicketAttachment(file, ticketId, messageId || 'pending');
const attachment = await prisma.ticketAttachment.create({ data: { messageId: messageId || '', fileName: saved.fileName, filePath: saved.filePath, fileSize: saved.fileSize, mimeType: saved.mimeType, }, });
return NextResponse.json({ success: true, attachment }, { status: 201 });}- Step 2: Commit
git add app/api/upload/ticket-attachment/git commit -m "feat(api): ticket dosya yükleme API eklendi"Task 9: Notification API Routes
Bölüm başlığı “Task 9: Notification API Routes”Files:
-
Create:
app/api/admin/notifications/route.ts -
Create:
app/api/admin/notifications/[id]/read/route.ts -
Create:
app/api/admin/notifications/read-all/route.ts -
Create:
app/api/customer/notifications/route.ts -
Create:
app/api/customer/notifications/[id]/read/route.ts -
Create:
app/api/customer/notifications/read-all/route.ts -
Step 1: Create admin notifications list route
Create app/api/admin/notifications/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { requireAdmin } from '@/lib/admin-session';
export async function GET(req: NextRequest) { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { searchParams } = new URL(req.url); const unreadOnly = searchParams.get('unreadOnly') === 'true';
const where: Record<string, unknown> = { recipientType: 'admin', recipientId: admin.adminId, }; if (unreadOnly) where.isRead = false;
const [notifications, unreadCount] = await Promise.all([ prisma.ticketNotification.findMany({ where, include: { ticket: { select: { id: true, ticketNumber: true, subject: true } }, }, orderBy: { createdAt: 'desc' }, take: 20, }), prisma.ticketNotification.count({ where: { recipientType: 'admin', recipientId: admin.adminId, isRead: false, }, }), ]);
return NextResponse.json({ success: true, notifications, unreadCount });}- Step 2: Create admin notification mark-read route
Create app/api/admin/notifications/[id]/read/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { requireAdmin } from '@/lib/admin-session';
export async function PATCH( _req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { id } = await params;
await prisma.ticketNotification.updateMany({ where: { id, recipientType: 'admin', recipientId: admin.adminId }, data: { isRead: true }, });
return NextResponse.json({ success: true });}- Step 3: Create admin notification mark-all-read route
Create app/api/admin/notifications/read-all/route.ts:
import { NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { requireAdmin } from '@/lib/admin-session';
export async function PATCH() { const admin = await requireAdmin(); if (!admin || admin.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
await prisma.ticketNotification.updateMany({ where: { recipientType: 'admin', recipientId: admin.adminId, isRead: false }, data: { isRead: true }, });
return NextResponse.json({ success: true });}- Step 4: Create customer notifications list route
Create app/api/customer/notifications/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { validateCustomerSession } from '@/lib/customer-session';
export async function GET(req: NextRequest) { const session = await validateCustomerSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { searchParams } = new URL(req.url); const unreadOnly = searchParams.get('unreadOnly') === 'true';
const where: Record<string, unknown> = { recipientType: 'customer', recipientId: session.customerId, }; if (unreadOnly) where.isRead = false;
const [notifications, unreadCount] = await Promise.all([ prisma.ticketNotification.findMany({ where, include: { ticket: { select: { id: true, ticketNumber: true, subject: true } }, }, orderBy: { createdAt: 'desc' }, take: 20, }), prisma.ticketNotification.count({ where: { recipientType: 'customer', recipientId: session.customerId, isRead: false, }, }), ]);
return NextResponse.json({ success: true, notifications, unreadCount });}- Step 5: Create customer notification mark-read route
Create app/api/customer/notifications/[id]/read/route.ts:
import { NextRequest, NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { validateCustomerSession } from '@/lib/customer-session';
export async function PATCH( _req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const session = await validateCustomerSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
const { id } = await params;
await prisma.ticketNotification.updateMany({ where: { id, recipientType: 'customer', recipientId: session.customerId }, data: { isRead: true }, });
return NextResponse.json({ success: true });}- Step 6: Create customer notification mark-all-read route
Create app/api/customer/notifications/read-all/route.ts:
import { NextResponse } from 'next/server';import prisma from '@/lib/prisma';import { validateCustomerSession } from '@/lib/customer-session';
export async function PATCH() { const session = await validateCustomerSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
await prisma.ticketNotification.updateMany({ where: { recipientType: 'customer', recipientId: session.customerId, isRead: false }, data: { isRead: true }, });
return NextResponse.json({ success: true });}- Step 7: Commit
git add app/api/admin/notifications/ app/api/customer/notifications/git commit -m "feat(api): admin ve customer bildirim API route'ları eklendi"Task 10: i18n Translations
Bölüm başlığı “Task 10: i18n Translations”Files:
-
Modify:
messages/en.json -
Modify:
messages/tr.json -
Step 1: Add English ticket translations
Add a "Tickets" key to messages/en.json (at the top-level, alongside "CustomerPortal", "Common", etc.):
"Tickets": { "title": "Support Tickets", "subtitle": "SUPPORT SYSTEM", "newTicket": "New Ticket", "allTickets": "All Tickets", "myTickets": "My Tickets", "noTickets": "No tickets found", "subject": "Subject", "subject_placeholder": "Brief description of your issue", "message": "Message", "message_placeholder": "Describe your issue in detail...", "category": "Category", "selectCategory": "Select a category", "priority": "Priority", "status": "Status", "ticketNumber": "Ticket #", "createdAt": "Created", "lastUpdate": "Last Update", "customer": "Customer", "relatedResource": "Related Resource", "relatedFile": "Related File", "relatedInvoice": "Related Invoice", "relatedDevRequest": "Related Dev Request", "selectOptional": "Select (optional)", "send": "Send", "reply": "Reply", "replyPlaceholder": "Type your reply...", "internalNote": "Internal Note", "internalNoteHint": "Only visible to admins", "attachFile": "Attach File", "close": "Close Ticket", "reopen": "Reopen", "closeConfirm": "Are you sure you want to close this ticket?", "createSuccess": "Ticket created successfully", "messageSuccess": "Message sent successfully", "closedSuccess": "Ticket closed", "statusUpdated": "Status updated", "status_open": "Open", "status_awaiting_customer": "Awaiting Your Reply", "status_awaiting_admin": "Awaiting Support", "status_resolved": "Resolved", "status_closed": "Closed", "priority_low": "Low", "priority_normal": "Normal", "priority_high": "High", "priority_urgent": "Urgent", "categories_title": "Ticket Categories", "categories_subtitle": "CATEGORY MANAGEMENT", "category_name": "Category Name", "category_slug": "Slug", "category_color": "Color", "category_sortOrder": "Sort Order", "category_add": "Add Category", "category_edit": "Edit", "category_delete": "Delete", "category_deleteConfirm": "Are you sure you want to delete this category?", "stats_open": "Open", "stats_awaiting": "Awaiting Response", "stats_resolved": "Resolved", "stats_total": "Total", "search_placeholder": "Search tickets...", "filter_all": "All", "filter_status": "Filter by status", "filter_priority": "Filter by priority", "filter_category": "Filter by category", "messageCount": "Messages", "closedTicketNotice": "This ticket is closed. You cannot send new messages.", "backToTickets": "Back to Tickets"}- Step 2: Add Turkish ticket translations
Add a "Tickets" key to messages/tr.json:
"Tickets": { "title": "Destek Talepleri", "subtitle": "DESTEK SİSTEMİ", "newTicket": "Yeni Talep", "allTickets": "Tüm Talepler", "myTickets": "Taleplerim", "noTickets": "Talep bulunamadı", "subject": "Konu", "subject_placeholder": "Sorununuzu kısaca açıklayın", "message": "Mesaj", "message_placeholder": "Sorununuzu detaylı olarak açıklayın...", "category": "Kategori", "selectCategory": "Kategori seçin", "priority": "Öncelik", "status": "Durum", "ticketNumber": "Talep No", "createdAt": "Oluşturulma", "lastUpdate": "Son Güncelleme", "customer": "Müşteri", "relatedResource": "İlişkili Kaynak", "relatedFile": "İlişkili Dosya", "relatedInvoice": "İlişkili Fatura", "relatedDevRequest": "İlişkili Geliştirme Talebi", "selectOptional": "Seçin (opsiyonel)", "send": "Gönder", "reply": "Yanıtla", "replyPlaceholder": "Yanıtınızı yazın...", "internalNote": "Dahili Not", "internalNoteHint": "Sadece yöneticiler görebilir", "attachFile": "Dosya Ekle", "close": "Talebi Kapat", "reopen": "Yeniden Aç", "closeConfirm": "Bu talebi kapatmak istediğinize emin misiniz?", "createSuccess": "Talep başarıyla oluşturuldu", "messageSuccess": "Mesaj gönderildi", "closedSuccess": "Talep kapatıldı", "statusUpdated": "Durum güncellendi", "status_open": "Açık", "status_awaiting_customer": "Yanıtınız Bekleniyor", "status_awaiting_admin": "Destek Yanıtı Bekleniyor", "status_resolved": "Çözüldü", "status_closed": "Kapalı", "priority_low": "Düşük", "priority_normal": "Normal", "priority_high": "Yüksek", "priority_urgent": "Acil", "categories_title": "Talep Kategorileri", "categories_subtitle": "KATEGORİ YÖNETİMİ", "category_name": "Kategori Adı", "category_slug": "Slug", "category_color": "Renk", "category_sortOrder": "Sıralama", "category_add": "Kategori Ekle", "category_edit": "Düzenle", "category_delete": "Sil", "category_deleteConfirm": "Bu kategoriyi silmek istediğinize emin misiniz?", "stats_open": "Açık", "stats_awaiting": "Yanıt Bekleyen", "stats_resolved": "Çözülen", "stats_total": "Toplam", "search_placeholder": "Talep ara...", "filter_all": "Tümü", "filter_status": "Duruma göre filtrele", "filter_priority": "Önceliğe göre filtrele", "filter_category": "Kategoriye göre filtrele", "messageCount": "Mesajlar", "closedTicketNotice": "Bu talep kapatılmıştır. Yeni mesaj gönderemezsiniz.", "backToTickets": "Taleplere Dön"}- Step 3: Add sidebar navigation translations to CustomerPortal
Add to the "CustomerPortal" key in both en.json and tr.json:
English:
"nav_tickets": "Support Tickets","nav_group_support": "Support"Turkish:
"nav_tickets": "Destek Talepleri","nav_group_support": "Destek"- Step 4: Commit
git add messages/en.json messages/tr.jsongit commit -m "feat(i18n): ticket sistemi çevirileri eklendi (en + tr)"Task 11: Shared UI Components — Badges
Bölüm başlığı “Task 11: Shared UI Components — Badges”Files:
-
Create:
components/tickets/TicketStatusBadge.tsx -
Create:
components/tickets/TicketPriorityBadge.tsx -
Step 1: Create TicketStatusBadge
Create components/tickets/TicketStatusBadge.tsx:
'use client';
import { useTranslations } from 'next-intl';
const statusStyles: Record<string, string> = { open: 'text-blue-400 bg-blue-400/10', awaiting_customer: 'text-orange-400 bg-orange-400/10', awaiting_admin: 'text-purple-400 bg-purple-400/10', resolved: 'text-green-400 bg-green-400/10', closed: 'text-gray-400 bg-gray-400/10',};
export default function TicketStatusBadge({ status }: { status: string }) { const t = useTranslations('Tickets');
return ( <span className={`inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium ${statusStyles[status] || statusStyles.open}`} > {t(`status_${status}` as Parameters<typeof t>[0])} </span> );}- Step 2: Create TicketPriorityBadge
Create components/tickets/TicketPriorityBadge.tsx:
'use client';
import { useTranslations } from 'next-intl';
const priorityStyles: Record<string, string> = { low: 'text-gray-400 bg-gray-400/10', normal: 'text-blue-400 bg-blue-400/10', high: 'text-orange-400 bg-orange-400/10', urgent: 'text-red-400 bg-red-400/10 animate-pulse',};
export default function TicketPriorityBadge({ priority }: { priority: string }) { const t = useTranslations('Tickets');
return ( <span className={`inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium ${priorityStyles[priority] || priorityStyles.normal}`} > {t(`priority_${priority}` as Parameters<typeof t>[0])} </span> );}- Step 3: Commit
git add components/tickets/git commit -m "feat(ui): ticket status ve priority badge bileşenleri eklendi"Task 12: Shared UI Components — Message Bubble and Attachment Upload
Bölüm başlığı “Task 12: Shared UI Components — Message Bubble and Attachment Upload”Files:
-
Create:
components/tickets/TicketMessageBubble.tsx -
Create:
components/tickets/TicketAttachmentUpload.tsx -
Step 1: Create TicketMessageBubble
Create components/tickets/TicketMessageBubble.tsx:
'use client';
import { useTranslations } from 'next-intl';import { Paperclip, Lock } from 'lucide-react';
interface Attachment { id: string; fileName: string; fileSize: number; mimeType: string;}
interface MessageProps { content: string; senderType: 'customer' | 'admin'; senderName?: string; isInternal: boolean; createdAt: string; attachments: Attachment[]; ticketId: string; downloadBaseUrl: string;}
function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;}
export default function TicketMessageBubble({ content, senderType, senderName, isInternal, createdAt, attachments, ticketId, downloadBaseUrl,}: MessageProps) { const t = useTranslations('Tickets'); const isAdmin = senderType === 'admin'; const date = new Date(createdAt);
if (isInternal) { return ( <div className="flex justify-start mb-4"> <div className="max-w-[80%] rounded-lg p-4 border border-amber-500/30 bg-amber-500/5"> <div className="flex items-center gap-2 mb-1"> <Lock className="w-3 h-3 text-amber-400" /> <span className="text-[10px] text-amber-400 uppercase tracking-wider font-tech"> {t('internalNote')} </span> {senderName && ( <span className="text-[10px] text-white/40">{senderName}</span> )} </div> <p className="text-sm text-white/70 whitespace-pre-wrap">{content}</p> <p className="text-[10px] text-white/25 mt-2"> {date.toLocaleDateString()} {date.toLocaleTimeString()} </p> </div> </div> ); }
return ( <div className={`flex ${isAdmin ? 'justify-start' : 'justify-end'} mb-4`}> <div className={`max-w-[80%] rounded-lg p-4 ${ isAdmin ? 'bg-white/3 border border-white/6' : 'bg-red-500/10 border border-red-500/20' }`} > {senderName && ( <p className={`text-[10px] uppercase tracking-wider font-tech mb-1 ${isAdmin ? 'text-blue-400' : 'text-red-400'}`}> {senderName} </p> )} <p className="text-sm text-white/80 whitespace-pre-wrap">{content}</p>
{attachments.length > 0 && ( <div className="mt-3 space-y-1"> {attachments.map((att) => ( <a key={att.id} href={`${downloadBaseUrl}/${ticketId}/attachments/${att.id}`} className="flex items-center gap-2 text-xs text-blue-400 hover:text-blue-300 transition-colors" > <Paperclip className="w-3 h-3" /> <span>{att.fileName}</span> <span className="text-white/25">({formatFileSize(att.fileSize)})</span> </a> ))} </div> )}
<p className="text-[10px] text-white/25 mt-2"> {date.toLocaleDateString()} {date.toLocaleTimeString()} </p> </div> </div> );}- Step 2: Create TicketAttachmentUpload
Create components/tickets/TicketAttachmentUpload.tsx:
'use client';
import { useState, useRef } from 'react';import { Paperclip, X, Upload } from 'lucide-react';
interface UploadedFile { file: File; preview?: string;}
interface Props { onFilesChange: (files: File[]) => void; maxFiles?: number;}
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export default function TicketAttachmentUpload({ onFilesChange, maxFiles = 5 }: Props) { const [files, setFiles] = useState<UploadedFile[]>([]); const inputRef = useRef<HTMLInputElement>(null);
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) { const selected = Array.from(e.target.files || []); const valid = selected.filter((f) => f.size <= MAX_FILE_SIZE); const newFiles = [...files, ...valid.map((f) => ({ file: f }))].slice(0, maxFiles); setFiles(newFiles); onFilesChange(newFiles.map((f) => f.file)); if (inputRef.current) inputRef.current.value = ''; }
function removeFile(index: number) { const newFiles = files.filter((_, i) => i !== index); setFiles(newFiles); onFilesChange(newFiles.map((f) => f.file)); }
function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }
return ( <div> <input ref={inputRef} type="file" multiple onChange={handleFileSelect} className="hidden" accept="image/*,.pdf,.bin,.zip,.rar,.7z" />
{files.length > 0 && ( <div className="flex flex-wrap gap-2 mb-2"> {files.map((f, i) => ( <div key={i} className="flex items-center gap-2 px-2 py-1 bg-white/5 border border-white/10 rounded text-xs text-white/60" > <Paperclip className="w-3 h-3" /> <span className="max-w-[120px] truncate">{f.file.name}</span> <span className="text-white/30">({formatSize(f.file.size)})</span> <button type="button" onClick={() => removeFile(i)} className="text-red-400 hover:text-red-300" > <X className="w-3 h-3" /> </button> </div> ))} </div> )}
{files.length < maxFiles && ( <button type="button" onClick={() => inputRef.current?.click()} className="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60 transition-colors" > <Upload className="w-3.5 h-3.5" /> <span>Attach File</span> </button> )} </div> );}- Step 3: Commit
git add components/tickets/git commit -m "feat(ui): ticket mesaj baloncuğu ve dosya yükleme bileşenleri eklendi"Task 13: Notification Bell Component
Bölüm başlığı “Task 13: Notification Bell Component”Files:
-
Create:
components/notifications/NotificationBell.tsx -
Create:
components/notifications/NotificationDropdown.tsx -
Create:
components/notifications/NotificationItem.tsx -
Step 1: Create NotificationItem
Create components/notifications/NotificationItem.tsx:
'use client';
import { Ticket, MessageSquare, RefreshCw, XCircle } from 'lucide-react';
interface Props { id: string; type: string; title: string; body: string; isRead: boolean; createdAt: string; ticketId: string; onNavigate: (ticketId: string) => void; onMarkRead: (id: string) => void;}
const typeIcons: Record<string, typeof Ticket> = { new_ticket: Ticket, new_message: MessageSquare, status_changed: RefreshCw, ticket_closed: XCircle,};
export default function NotificationItem({ id, type, title, body, isRead, createdAt, ticketId, onNavigate, onMarkRead,}: Props) { const Icon = typeIcons[type] || Ticket; const date = new Date(createdAt); const timeAgo = getTimeAgo(date);
function handleClick() { if (!isRead) onMarkRead(id); onNavigate(ticketId); }
return ( <button onClick={handleClick} className={`w-full flex items-start gap-3 p-3 text-left transition-colors hover:bg-white/5 ${ !isRead ? 'bg-white/3' : '' }`} > <div className={`mt-0.5 ${!isRead ? 'text-red-400' : 'text-white/30'}`}> <Icon className="w-4 h-4" /> </div> <div className="flex-1 min-w-0"> <p className={`text-xs truncate ${!isRead ? 'text-white font-medium' : 'text-white/60'}`}> {title} </p> <p className="text-[10px] text-white/30 truncate mt-0.5">{body}</p> <p className="text-[10px] text-white/20 mt-1">{timeAgo}</p> </div> {!isRead && <div className="w-2 h-2 rounded-full bg-red-400 mt-1.5 shrink-0" />} </button> );}
function getTimeAgo(date: Date): string { const seconds = Math.floor((Date.now() - date.getTime()) / 1000); if (seconds < 60) return 'Just now'; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`;}- Step 2: Create NotificationDropdown
Create components/notifications/NotificationDropdown.tsx:
'use client';
import NotificationItem from './NotificationItem';import { CheckCheck } from 'lucide-react';
interface Notification { id: string; type: string; title: string; body: string; isRead: boolean; createdAt: string; ticketId: string; ticket: { id: string; ticketNumber: string; subject: string };}
interface Props { notifications: Notification[]; onNavigate: (ticketId: string) => void; onMarkRead: (id: string) => void; onMarkAllRead: () => void;}
export default function NotificationDropdown({ notifications, onNavigate, onMarkRead, onMarkAllRead,}: Props) { return ( <div className="absolute right-0 top-full mt-2 w-80 bg-[#141414] border border-white/10 rounded-lg shadow-2xl overflow-hidden z-50"> <div className="flex items-center justify-between px-4 py-3 border-b border-white/6"> <h3 className="text-xs font-tech text-white/60 uppercase tracking-wider"> Notifications </h3> <button onClick={onMarkAllRead} className="flex items-center gap-1 text-[10px] text-white/30 hover:text-white/60 transition-colors" > <CheckCheck className="w-3 h-3" /> Mark all read </button> </div>
<div className="max-h-[400px] overflow-y-auto"> {notifications.length === 0 ? ( <div className="p-6 text-center text-xs text-white/30"> No notifications </div> ) : ( notifications.map((n) => ( <NotificationItem key={n.id} id={n.id} type={n.type} title={n.title} body={n.body} isRead={n.isRead} createdAt={n.createdAt} ticketId={n.ticket.id} onNavigate={onNavigate} onMarkRead={onMarkRead} /> )) )} </div> </div> );}- Step 3: Create NotificationBell
Create components/notifications/NotificationBell.tsx:
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';import { Bell } from 'lucide-react';import { useRouter } from '@/i18n/navigation';import NotificationDropdown from './NotificationDropdown';
interface Props { role: 'admin' | 'customer';}
export default function NotificationBell({ role }: Props) { const [isOpen, setIsOpen] = useState(false); const [unreadCount, setUnreadCount] = useState(0); const [notifications, setNotifications] = useState([]); const ref = useRef<HTMLDivElement>(null); const router = useRouter();
const apiBase = role === 'admin' ? '/api/admin/notifications' : '/api/customer/notifications'; const ticketBase = role === 'admin' ? '/admin/tickets' : '/customer/tickets';
const fetchNotifications = useCallback(async () => { try { const res = await fetch(apiBase); if (!res.ok) return; const data = await res.json(); setNotifications(data.notifications || []); setUnreadCount(data.unreadCount || 0); } catch { // Silently fail } }, [apiBase]);
useEffect(() => { fetchNotifications(); const interval = setInterval(fetchNotifications, 30000); return () => clearInterval(interval); }, [fetchNotifications]);
useEffect(() => { function handleClickOutside(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setIsOpen(false); } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []);
async function handleMarkRead(id: string) { await fetch(`${apiBase}/${id}/read`, { method: 'PATCH' }); setNotifications((prev) => prev.map((n: { id: string }) => (n.id === id ? { ...n, isRead: true } : n)) ); setUnreadCount((c) => Math.max(0, c - 1)); }
async function handleMarkAllRead() { await fetch(`${apiBase}/read-all`, { method: 'PATCH' }); setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })) ); setUnreadCount(0); }
function handleNavigate(ticketId: string) { setIsOpen(false); router.push(`${ticketBase}/${ticketId}`); }
return ( <div ref={ref} className="relative"> <button onClick={() => { setIsOpen(!isOpen); if (!isOpen) fetchNotifications(); }} className="relative p-2 text-white/40 hover:text-white/70 transition-colors" > <Bell className="w-5 h-5" /> {unreadCount > 0 && ( <span className="absolute -top-0.5 -right-0.5 flex items-center justify-center w-4 h-4 text-[9px] font-bold text-white bg-red-500 rounded-full animate-pulse"> {unreadCount > 9 ? '9+' : unreadCount} </span> )} </button>
{isOpen && ( <NotificationDropdown notifications={notifications} onNavigate={handleNavigate} onMarkRead={handleMarkRead} onMarkAllRead={handleMarkAllRead} /> )} </div> );}- Step 4: Commit
git add components/notifications/git commit -m "feat(ui): bildirim çanı, dropdown ve bildirim öğesi bileşenleri eklendi"Task 14: Admin Ticket Categories Page
Bölüm başlığı “Task 14: Admin Ticket Categories Page”Files:
-
Create:
app/[locale]/admin/(panel)/ticket-categories/page.tsx -
Step 1: Create ticket categories admin page
Create app/[locale]/admin/(panel)/ticket-categories/page.tsx as a server component that fetches categories and renders an inline CRUD table with client-side modals for add/edit. Follow the same pattern as the admin customers page:
- Use
validateAdminSession()for auth, redirect to/admin/loginif no session. - Fetch categories from
prisma.ticketCategory.findMany({ orderBy: { sortOrder: 'asc' }, include: { _count: { select: { tickets: true } } } }). - Render a table with columns: Name (show locale-appropriate translation), Slug, Color (colored dot), Sort Order, Ticket Count, Active status, Actions (Edit/Delete).
- Include an “Add Category” button that opens a client-side form.
- Use the existing table styling:
bg-white dark:bg-white/2 backdrop-blur-xl border border-gray-200 dark:border-white/6 rounded-[10px]. - Table headers:
text-[10px] text-gray-400 dark:text-white/25 uppercase tracking-[0.15em] font-tech. - Add/Edit form fields: name (JSON input per locale — at minimum
tranden), slug (auto-generated from name), color (hex picker), sortOrder (number). - Delete button calls
DELETE /api/admin/ticket-categories/[id](soft delete). - Create a companion client component
components/admin/TicketCategoryManager.tsxfor the interactive parts (add modal, edit modal, delete confirmation, API calls).
The page code should follow the exact same structure as /app/[locale]/admin/(panel)/customers/page.tsx for consistency.
- Step 2: Verify page renders
npx next build 2>&1 | grep -i error | head -10- Step 3: Commit
git add app/\[locale\]/admin/\(panel\)/ticket-categories/ components/admin/TicketCategoryManager.tsxgit commit -m "feat(admin): ticket kategori yönetim sayfası eklendi"Task 15: Admin Ticket List Page
Bölüm başlığı “Task 15: Admin Ticket List Page”Files:
-
Create:
app/[locale]/admin/(panel)/tickets/page.tsx -
Step 1: Create admin ticket list page
Create app/[locale]/admin/(panel)/tickets/page.tsx:
This is a server component that:
- Calls
validateAdminSession(), redirects if not authenticated. - Fetches tickets via
prisma.ticket.findMany()with customer, category, and message count includes. - Passes data to a client component
components/admin/AdminTicketList.tsxfor interactive filtering/search.
import { validateAdminSession } from '@/lib/admin-session';import { redirect } from 'next/navigation';import prisma from '@/lib/prisma';import AdminTicketList from '@/components/admin/AdminTicketList';
export const dynamic = 'force-dynamic';
export default async function AdminTicketsPage() { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const [tickets, categories] = await Promise.all([ prisma.ticket.findMany({ include: { customer: { select: { id: true, name: true, email: true, company: true } }, category: { select: { id: true, name: true, color: true } }, _count: { select: { messages: true } }, }, orderBy: { lastMessageAt: 'desc' }, }), prisma.ticketCategory.findMany({ where: { isActive: true }, orderBy: { sortOrder: 'asc' }, }), ]);
return ( <div className="space-y-6"> <div> <p className="text-[10px] text-gray-400 dark:text-white/25 uppercase tracking-[0.15em] font-tech"> DESTEK SİSTEMİ </p> <h1 className="text-2xl font-tech text-gray-900 dark:text-white uppercase tracking-wide mt-1"> Destek Talepleri </h1> </div> <AdminTicketList tickets={JSON.parse(JSON.stringify(tickets))} categories={JSON.parse(JSON.stringify(categories))} /> </div> );}- Step 2: Create AdminTicketList client component
Create components/admin/AdminTicketList.tsx — a client component with:
-
Filter bar: status dropdown, priority dropdown, category dropdown, search input. Uses the
Ticketsi18n namespace. -
Table rows with columns: Ticket # (link to detail), Subject, Customer (name + company), Status badge (
TicketStatusBadge), Priority badge (TicketPriorityBadge), Category badge, Message count, Last update (relative time). -
Clicking a row navigates to
/admin/tickets/[id]. -
Use existing table styling from the customers list page.
-
Client-side filtering — filter the
ticketsarray based on selected filters and search term. -
Step 3: Commit
git add app/\[locale\]/admin/\(panel\)/tickets/page.tsx components/admin/AdminTicketList.tsxgit commit -m "feat(admin): ticket liste sayfası ve filtreleme bileşeni eklendi"Task 16: Admin Ticket Detail Page
Bölüm başlığı “Task 16: Admin Ticket Detail Page”Files:
-
Create:
app/[locale]/admin/(panel)/tickets/[id]/page.tsx -
Create:
components/admin/AdminTicketDetail.tsx -
Step 1: Create admin ticket detail server page
Create app/[locale]/admin/(panel)/tickets/[id]/page.tsx:
import { validateAdminSession } from '@/lib/admin-session';import { redirect, notFound } from 'next/navigation';import prisma from '@/lib/prisma';import AdminTicketDetail from '@/components/admin/AdminTicketDetail';
export const dynamic = 'force-dynamic';
export default async function AdminTicketDetailPage({ params,}: { params: Promise<{ id: string }>;}) { const admin = await validateAdminSession(); if (!admin) redirect('/admin/login');
const { id } = await params;
const ticket = await prisma.ticket.findUnique({ where: { id }, include: { customer: { select: { id: true, name: true, email: true, company: true } }, category: { select: { id: true, name: true, color: true, slug: true } }, relatedFile: { select: { id: true, originalName: true } }, relatedInvoice: { select: { id: true, invoiceNumber: true, title: true } }, relatedDevRequest: { select: { id: true, title: true } }, messages: { include: { attachments: true }, orderBy: { createdAt: 'asc' }, }, }, });
if (!ticket) notFound();
const categories = await prisma.ticketCategory.findMany({ where: { isActive: true }, orderBy: { sortOrder: 'asc' }, });
return ( <AdminTicketDetail ticket={JSON.parse(JSON.stringify(ticket))} categories={JSON.parse(JSON.stringify(categories))} adminId={admin.adminId} adminName={admin.name} /> );}- Step 2: Create AdminTicketDetail client component
Create components/admin/AdminTicketDetail.tsx — a client component with:
Header section:
- Back button to
/admin/tickets - Ticket number + subject
- Status badge (editable dropdown), Priority badge (editable dropdown), Category (editable dropdown)
- Customer info card (name, email, company)
- Related resource links (if any)
- Close ticket button
Message thread:
- Render all messages using
TicketMessageBubblecomponent - Admin messages left, customer messages right
- Internal notes with amber styling
- Scroll to bottom on load
Reply form:
- Textarea for message content (max 5000 chars)
- Internal note toggle (
InternalNoteToggle— a simple switch) TicketAttachmentUploadcomponent- Send button
- On submit: POST to
/api/admin/tickets/[id]/messages, then refresh messages
Status/priority/category changes:
- PATCH to
/api/admin/tickets/[id]on change - Optimistic UI update
Styling:
-
Follow existing glass morphism pattern:
bg-white dark:bg-white/2 backdrop-blur-xl border border-gray-200 dark:border-white/6 rounded-[10px] p-6 -
Message area: scrollable container with max height
-
Step 3: Commit
git add app/\[locale\]/admin/\(panel\)/tickets/\[id\]/ components/admin/AdminTicketDetail.tsxgit commit -m "feat(admin): ticket detay sayfası ve mesaj thread'i eklendi"Task 17: Customer Ticket List Page
Bölüm başlığı “Task 17: Customer Ticket List Page”Files:
-
Create:
app/[locale]/customer/(portal)/tickets/page.tsx -
Create:
components/customer/CustomerTicketList.tsx -
Step 1: Create customer ticket list page
Create app/[locale]/customer/(portal)/tickets/page.tsx:
import { validateCustomerSession } from '@/lib/customer-session';import { redirect } from 'next/navigation';import prisma from '@/lib/prisma';import { getTranslations } from 'next-intl/server';import CustomerTicketList from '@/components/customer/CustomerTicketList';import { Link } from '@/i18n/navigation';import { Plus } from 'lucide-react';
export const dynamic = 'force-dynamic';
export default async function CustomerTicketsPage() { const session = await validateCustomerSession(); if (!session) redirect('/customer/login');
const t = await getTranslations('Tickets');
const tickets = await prisma.ticket.findMany({ where: { customerId: session.customerId }, include: { category: { select: { id: true, name: true, color: true } }, _count: { select: { messages: { where: { isInternal: false } } } }, }, orderBy: { lastMessageAt: 'desc' }, });
return ( <div className="space-y-6"> <div className="flex items-center justify-between"> <div> <p className="text-[10px] text-gray-400 dark:text-white/25 uppercase tracking-[0.15em] font-tech"> {t('subtitle')} </p> <h1 className="text-2xl font-tech text-gray-900 dark:text-white uppercase tracking-wide mt-1"> {t('title')} </h1> </div> <Link href="/customer/tickets/new" className="flex items-center gap-2 px-4 py-2 bg-red-500 hover:bg-red-600 text-white text-sm font-medium rounded-lg transition-colors" > <Plus className="w-4 h-4" /> {t('newTicket')} </Link> </div> <CustomerTicketList tickets={JSON.parse(JSON.stringify(tickets))} /> </div> );}- Step 2: Create CustomerTicketList client component
Create components/customer/CustomerTicketList.tsx — a client component with:
-
Table/card list of tickets with columns: Ticket #, Subject, Status badge, Priority badge, Category, Messages count, Last update.
-
Clicking navigates to
/customer/tickets/[id]. -
Simple status filter tabs at the top (All, Open, Awaiting, Resolved, Closed).
-
Use
Ticketsi18n namespace for all labels. -
Empty state: centered message with “No tickets found” text.
-
Step 3: Commit
git add app/\[locale\]/customer/\(portal\)/tickets/page.tsx components/customer/CustomerTicketList.tsxgit commit -m "feat(customer): ticket liste sayfası eklendi"Task 18: Customer New Ticket Page
Bölüm başlığı “Task 18: Customer New Ticket Page”Files:
-
Create:
app/[locale]/customer/(portal)/tickets/new/page.tsx -
Create:
components/customer/NewTicketForm.tsx -
Create:
components/tickets/TicketRelationSelect.tsx -
Step 1: Create new ticket server page
Create app/[locale]/customer/(portal)/tickets/new/page.tsx:
import { validateCustomerSession } from '@/lib/customer-session';import { redirect } from 'next/navigation';import prisma from '@/lib/prisma';import { getTranslations } from 'next-intl/server';import NewTicketForm from '@/components/customer/NewTicketForm';
export const dynamic = 'force-dynamic';
export default async function NewTicketPage() { const session = await validateCustomerSession(); if (!session) redirect('/customer/login');
const t = await getTranslations('Tickets');
const [categories, files, invoices, devRequests] = await Promise.all([ prisma.ticketCategory.findMany({ where: { isActive: true }, orderBy: { sortOrder: 'asc' }, }), prisma.file.findMany({ where: { customerId: session.customerId }, select: { id: true, originalName: true }, orderBy: { createdAt: 'desc' }, take: 50, }), prisma.invoice.findMany({ where: { customerId: session.customerId }, select: { id: true, invoiceNumber: true, title: true }, orderBy: { createdAt: 'desc' }, take: 50, }), prisma.developmentRequest.findMany({ where: { customerId: session.customerId }, select: { id: true, title: true }, orderBy: { createdAt: 'desc' }, take: 50, }), ]);
return ( <div className="space-y-6"> <div> <p className="text-[10px] text-gray-400 dark:text-white/25 uppercase tracking-[0.15em] font-tech"> {t('subtitle')} </p> <h1 className="text-2xl font-tech text-gray-900 dark:text-white uppercase tracking-wide mt-1"> {t('newTicket')} </h1> </div> <NewTicketForm categories={JSON.parse(JSON.stringify(categories))} files={files} invoices={invoices} devRequests={devRequests} /> </div> );}- Step 2: Create NewTicketForm client component
Create components/customer/NewTicketForm.tsx — a client component with:
- Subject input (max 200 chars)
- Category dropdown (from props)
- Priority dropdown (low/normal/high/urgent, default normal)
- Related resource section: three optional dropdowns for File, Invoice, DevRequest (using
TicketRelationSelect) - Message textarea (max 5000 chars)
TicketAttachmentUploadfor file attachments- Submit button
- On submit:
- POST to
/api/customer/ticketswith form data - If attachments exist, upload each via POST to
/api/upload/ticket-attachmentwith the ticketId - On success, redirect to
/customer/tickets/[newTicketId]
- POST to
- Loading state during submission
- Error display
Styling: Glass morphism card (bg-white dark:bg-white/2 backdrop-blur-xl border border-gray-200 dark:border-white/6 rounded-[10px] p-6), consistent with existing form pages.
- Step 3: Create TicketRelationSelect component
Create components/tickets/TicketRelationSelect.tsx:
'use client';
import { useTranslations } from 'next-intl';
interface Option { id: string; label: string;}
interface Props { label: string; options: Option[]; value: string; onChange: (value: string) => void;}
export default function TicketRelationSelect({ label, options, value, onChange }: Props) { const t = useTranslations('Tickets');
return ( <div> <label className="block text-xs text-white/40 mb-1.5 font-tech uppercase tracking-wider"> {label} </label> <select value={value} onChange={(e) => onChange(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/80 focus:outline-none focus:border-red-500/50 transition-colors" > <option value="">{t('selectOptional')}</option> {options.map((opt) => ( <option key={opt.id} value={opt.id}> {opt.label} </option> ))} </select> </div> );}- Step 4: Commit
git add app/\[locale\]/customer/\(portal\)/tickets/new/ components/customer/NewTicketForm.tsx components/tickets/TicketRelationSelect.tsxgit commit -m "feat(customer): yeni ticket oluşturma sayfası ve formu eklendi"Task 19: Customer Ticket Detail Page
Bölüm başlığı “Task 19: Customer Ticket Detail Page”Files:
-
Create:
app/[locale]/customer/(portal)/tickets/[id]/page.tsx -
Create:
components/customer/CustomerTicketDetail.tsx -
Step 1: Create customer ticket detail server page
Create app/[locale]/customer/(portal)/tickets/[id]/page.tsx:
import { validateCustomerSession } from '@/lib/customer-session';import { redirect, notFound } from 'next/navigation';import prisma from '@/lib/prisma';import CustomerTicketDetail from '@/components/customer/CustomerTicketDetail';
export const dynamic = 'force-dynamic';
export default async function CustomerTicketDetailPage({ params,}: { params: Promise<{ id: string }>;}) { const session = await validateCustomerSession(); if (!session) redirect('/customer/login');
const { id } = await params;
const ticket = await prisma.ticket.findUnique({ where: { id }, include: { category: { select: { id: true, name: true, color: true } }, relatedFile: { select: { id: true, originalName: true } }, relatedInvoice: { select: { id: true, invoiceNumber: true, title: true } }, relatedDevRequest: { select: { id: true, title: true } }, messages: { where: { isInternal: false }, include: { attachments: true }, orderBy: { createdAt: 'asc' }, }, }, });
if (!ticket || ticket.customerId !== session.customerId) notFound();
return ( <CustomerTicketDetail ticket={JSON.parse(JSON.stringify(ticket))} customerName={session.name} /> );}- Step 2: Create CustomerTicketDetail client component
Create components/customer/CustomerTicketDetail.tsx — a client component with:
Header:
- Back button to
/customer/tickets - Ticket number + subject
- Status badge (read-only), Priority badge (read-only), Category badge
- Related resource info (if any)
- Close ticket button (if not already closed)
Message thread:
- Render all messages using
TicketMessageBubble(customer messages right, admin messages left) - Auto-scroll to bottom
Reply form (only if ticket is not closed):
- Textarea for reply (max 5000 chars)
TicketAttachmentUploadcomponent- Send button
- On submit: POST to
/api/customer/tickets/[id]/messages, then router.refresh()
Closed ticket notice: If ticket is closed, show a banner instead of the reply form.
- Step 3: Commit
git add app/\[locale\]/customer/\(portal\)/tickets/\[id\]/ components/customer/CustomerTicketDetail.tsxgit commit -m "feat(customer): ticket detay sayfası ve mesaj thread'i eklendi"Task 20: Sidebar and Header Updates
Bölüm başlığı “Task 20: Sidebar and Header Updates”Files:
-
Modify:
components/admin/AdminSidebar.tsx -
Modify:
components/admin/AdminHeader.tsx -
Modify:
components/customer/CustomerSidebar.tsx -
Step 1: Add ticket items to admin sidebar
Open components/admin/AdminSidebar.tsx and add two new entries to the menuItems array (after “Dev İstekleri”):
{ name: 'Destek Talepleri', href: '/admin/tickets', icon: MessageSquareText },{ name: 'Talep Kategorileri', href: '/admin/ticket-categories', icon: Tags },Import the icons at the top:
import { MessageSquareText, Tags } from 'lucide-react';- Step 2: Add NotificationBell to admin header
Open components/admin/AdminHeader.tsx and add the NotificationBell component next to the existing bell icon (or replace the existing contact-submission bell with the new unified one):
import NotificationBell from '@/components/notifications/NotificationBell';Add <NotificationBell role="admin" /> in the header’s right section, alongside the existing elements.
Note: The existing admin header already has a bell icon that counts contactSubmission with status: 'new'. Keep that functionality or integrate it — the ticket notification bell should be a separate bell or combined. Since these are different systems, add the ticket NotificationBell as a second icon next to the existing one.
- Step 3: Add ticket items to customer sidebar
Open components/customer/CustomerSidebar.tsx and add a new nav group or add to mainNav:
// Add to mainNav array (after nav_dev_requests):{ key: 'nav_tickets', href: '/customer/tickets', icon: MessageSquareText },Import the icon:
import { MessageSquareText } from 'lucide-react';- Step 4: Add NotificationBell to customer layout
The customer layout needs the NotificationBell. Since the customer portal uses CustomerPanelContent, find where the header area is and add:
<NotificationBell role="customer" />This should be added in the customer header/toolbar area. If there’s no dedicated customer header component, add it to the CustomerPanelContent or create a minimal CustomerHeader component.
- Step 5: Verify build
npx next build 2>&1 | tail -10Expected: Build succeeds with no errors.
- Step 6: Commit
git add components/admin/AdminSidebar.tsx components/admin/AdminHeader.tsx components/customer/CustomerSidebar.tsxgit commit -m "feat(nav): sidebar'lara ticket menü öğeleri ve header'lara bildirim çanı eklendi"Task 21: Build Verification and Final Smoke Test
Bölüm başlığı “Task 21: Build Verification and Final Smoke Test”- Step 1: Run full build
cd /var/www/vhosts/ecutuningportal.com/httpdocsnpx next buildExpected: Build succeeds with no TypeScript or compilation errors.
- Step 2: Verify database state
npx prisma db push --dry-runExpected: No pending changes.
- Step 3: Restart the application
passenger-config restart-app /var/www/vhosts/ecutuningportal.com/httpdocs- Step 4: Verify all new routes respond
Test key API endpoints:
# Admin tickets endpoint (should return 401 without auth)curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/admin/tickets# Expected: 401
# Customer tickets endpoint (should return 401 without auth)curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/customer/tickets# Expected: 401
# Admin ticket categories (should return 401 without auth)curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/admin/ticket-categories# Expected: 401- Step 5: Final commit if any remaining changes
git status# If any unstaged changes:git add -Agit commit -m "feat: ticket destek sistemi tamamlandı — build ve doğrulama başarılı"