İçeriğe geç

Support Ticket System — Implementation Plan

Derin

For 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


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)

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
Terminal window
cd /var/www/vhosts/ecutuningportal.com/httpdocs
npx prisma migrate dev --name add_ticket_system

Expected: Migration created and applied successfully.

  • Step 8: Verify Prisma client generation
Terminal window
npx prisma generate

Expected: Prisma Client generated successfully

  • Step 9: Commit
Terminal window
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
Terminal window
npx next build 2>&1 | tail -5

Expected: No TypeScript errors related to new files.

  • Step 4: Commit
Terminal window
git add lib/ticket-number.ts lib/ticket-file-storage.ts
git commit -m "feat: ticket numarası üreteci ve dosya depolama yardımcıları eklendi"

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
Terminal window
git add lib/ticket-notifications.ts
git commit -m "feat: ticket bildirim servisi eklendi"

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
Terminal window
npx next build 2>&1 | tail -5
  • Step 5: Commit
Terminal window
git add lib/mail/
git commit -m "feat(mail): ticket email template'leri ve çevirileri eklendi"

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
Terminal window
git add app/api/admin/ticket-categories/
git commit -m "feat(api): admin ticket kategori CRUD API eklendi"

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
Terminal window
git add app/api/admin/tickets/
git commit -m "feat(api): admin ticket API route'ları eklendi (CRUD, mesaj, kapatma, ek indirme)"

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
Terminal window
git add app/api/customer/tickets/
git commit -m "feat(api): customer ticket API route'ları eklendi (liste, detay, mesaj, kapatma, ek indirme)"

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
Terminal window
git add app/api/upload/ticket-attachment/
git commit -m "feat(api): ticket dosya yükleme API eklendi"

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
Terminal window
git add app/api/admin/notifications/ app/api/customer/notifications/
git commit -m "feat(api): admin ve customer bildirim API route'ları eklendi"

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
Terminal window
git add messages/en.json messages/tr.json
git commit -m "feat(i18n): ticket sistemi çevirileri eklendi (en + tr)"

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
Terminal window
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
Terminal window
git add components/tickets/
git commit -m "feat(ui): ticket mesaj baloncuğu ve dosya yükleme bileşenleri eklendi"

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
Terminal window
git add components/notifications/
git commit -m "feat(ui): bildirim çanı, dropdown ve bildirim öğesi bileşenleri eklendi"

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/login if 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 tr and en), 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.tsx for 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
Terminal window
npx next build 2>&1 | grep -i error | head -10
  • Step 3: Commit
Terminal window
git add app/\[locale\]/admin/\(panel\)/ticket-categories/ components/admin/TicketCategoryManager.tsx
git commit -m "feat(admin): ticket kategori yönetim sayfası eklendi"

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.tsx for 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 Tickets i18n 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 tickets array based on selected filters and search term.

  • Step 3: Commit

Terminal window
git add app/\[locale\]/admin/\(panel\)/tickets/page.tsx components/admin/AdminTicketList.tsx
git commit -m "feat(admin): ticket liste sayfası ve filtreleme bileşeni eklendi"

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 TicketMessageBubble component
  • 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)
  • TicketAttachmentUpload component
  • 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

Terminal window
git add app/\[locale\]/admin/\(panel\)/tickets/\[id\]/ components/admin/AdminTicketDetail.tsx
git commit -m "feat(admin): ticket detay sayfası ve mesaj thread'i eklendi"

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 Tickets i18n namespace for all labels.

  • Empty state: centered message with “No tickets found” text.

  • Step 3: Commit

Terminal window
git add app/\[locale\]/customer/\(portal\)/tickets/page.tsx components/customer/CustomerTicketList.tsx
git commit -m "feat(customer): ticket liste sayfası eklendi"

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)
  • TicketAttachmentUpload for file attachments
  • Submit button
  • On submit:
    1. POST to /api/customer/tickets with form data
    2. If attachments exist, upload each via POST to /api/upload/ticket-attachment with the ticketId
    3. On success, redirect to /customer/tickets/[newTicketId]
  • 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
Terminal window
git add app/\[locale\]/customer/\(portal\)/tickets/new/ components/customer/NewTicketForm.tsx components/tickets/TicketRelationSelect.tsx
git commit -m "feat(customer): yeni ticket oluşturma sayfası ve formu eklendi"

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)
  • TicketAttachmentUpload component
  • 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
Terminal window
git add app/\[locale\]/customer/\(portal\)/tickets/\[id\]/ components/customer/CustomerTicketDetail.tsx
git commit -m "feat(customer): ticket detay sayfası ve mesaj thread'i eklendi"

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
Terminal window
npx next build 2>&1 | tail -10

Expected: Build succeeds with no errors.

  • Step 6: Commit
Terminal window
git add components/admin/AdminSidebar.tsx components/admin/AdminHeader.tsx components/customer/CustomerSidebar.tsx
git commit -m "feat(nav): sidebar'lara ticket menü öğeleri ve header'lara bildirim çanı eklendi"

  • Step 1: Run full build
Terminal window
cd /var/www/vhosts/ecutuningportal.com/httpdocs
npx next build

Expected: Build succeeds with no TypeScript or compilation errors.

  • Step 2: Verify database state
Terminal window
npx prisma db push --dry-run

Expected: No pending changes.

  • Step 3: Restart the application
Terminal window
passenger-config restart-app /var/www/vhosts/ecutuningportal.com/httpdocs
  • Step 4: Verify all new routes respond

Test key API endpoints:

Terminal window
# 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
Terminal window
git status
# If any unstaged changes:
git add -A
git commit -m "feat: ticket destek sistemi tamamlandı — build ve doğrulama başarılı"