<?php
namespace CompanyGroupBundle\Controller;
use ApplicationBundle\Controller\GenericController;
use CompanyGroupBundle\Entity\EntityTicket;
use CompanyGroupBundle\Entity\EntityTicketReport;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* TicketController
*
* Handles the full ticket lifecycle:
* - Manual ticket list / create / edit / view
* - Status transitions: resolve / close / assign
* - System reporter endpoint with 1-hour problemHash deduplication
*
* Entity manager: company_group
* Views: CompanyGroupBundle:pages/tickets:*.html.twig
*/
class TicketController extends GenericController
{
// =========================================================================
// CONSTANTS
// =========================================================================
const STATUS_OPEN = 1;
const STATUS_IN_PROGRESS = 2;
const STATUS_RESOLVED = 3;
const STATUS_CLOSED = 4;
const SOURCE_MANUAL = 1;
const SOURCE_SYSTEM = 2;
const SOURCE_API = 3;
const DEDUP_WINDOW_SECONDS = 3600; // 1 hour
const TICKETS_PER_PAGE = 20;
// =========================================================================
// LIST
// =========================================================================
/**
* GET /tickets
* Paginated, filterable list of all tickets.
*/
public function TicketListAction(Request $request)
{
$em = $this->getDoctrine()->getManager('company_group');
// ── Filters ──────────────────────────────────────────────────────────
$filters = [
'status' => $request->query->get('status', null),
'priority' => $request->query->get('priority', null),
'source' => $request->query->get('source', null),
'q' => trim($request->query->get('q', '')),
];
$page = max(1, (int) $request->query->get('page', 1));
$offset = ($page - 1) * self::TICKETS_PER_PAGE;
// ── Build DQL ────────────────────────────────────────────────────────
$qb = $em->createQueryBuilder()
->select('t')
->from('CompanyGroupBundle\Entity\EntityTicket', 't')
->orderBy('t.createdAt', 'DESC');
if ($filters['status'] !== null && $filters['status'] !== '') {
$qb->andWhere('t.status = :status')->setParameter('status', (int) $filters['status']);
}
if ($filters['priority'] !== null && $filters['priority'] !== '') {
$qb->andWhere('t.urgency = :priority')->setParameter('priority', (int) $filters['priority']);
}
if ($filters['source'] !== null && $filters['source'] !== '') {
$qb->andWhere('t.source = :source')->setParameter('source', (int) $filters['source']);
}
if ($filters['q'] !== '') {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->like('t.title', ':q'),
$qb->expr()->like('t.ticketNumber', ':q'),
$qb->expr()->like('t.ticketBody', ':q')
)
)->setParameter('q', '%' . $filters['q'] . '%');
}
// ── Count for pagination ──────────────────────────────────────────────
$countQb = clone $qb;
$total = (int) $countQb->select('COUNT(t.id)')->getQuery()->getSingleScalarResult();
$tickets = $qb->setFirstResult($offset)->setMaxResults(self::TICKETS_PER_PAGE)->getQuery()->getResult();
$totalPages = (int) ceil($total / self::TICKETS_PER_PAGE);
// ── Summary counts ────────────────────────────────────────────────────
$summary = $this->_getStatusSummary($em);
return $this->render('@CompanyGroup/pages/tickets/list_tickets.html.twig', [
'tickets' => $tickets,
'filters' => $filters,
'currentPage' => $page,
'totalPages' => $totalPages,
'total' => $total,
'summary' => $summary,
]);
}
// =========================================================================
// VIEW
// =========================================================================
/**
* GET /ticket/{id}
*/
public function ViewTicketAction(Request $request, $id)
{
$em = $this->getDoctrine()->getManager('company_group');
$ticket = $em->getRepository('CompanyGroupBundle\Entity\EntityTicket')->find($id);
if (!$ticket) {
throw $this->createNotFoundException('Ticket #' . $id . ' not found.');
}
// Load occurrence log (EntityTicketReport rows for this ticket)
$reports = $em->createQueryBuilder()
->select('r')
->from('CompanyGroupBundle\Entity\EntityTicketReport', 'r')
->where('r.ticketId = :tid')
->setParameter('tid', $id)
->orderBy('r.reportedAt', 'DESC')
->setMaxResults(100)
->getQuery()
->getResult();
return $this->render('@CompanyGroup/pages/tickets/view_ticket.html.twig', [
'ticket' => $ticket,
'reports' => $reports,
]);
}
// =========================================================================
// CREATE
// =========================================================================
/**
* GET /ticket/create — show blank form
*/
public function CreateTicketAction(Request $request)
{
return $this->render('@CompanyGroup/pages/tickets/create_ticket.html.twig', [
'formData' => $this->_emptyFormData(),
]);
}
/**
* POST /ticket/create/submit — persist new ticket
*/
public function CreateTicketSubmitAction(Request $request)
{
$session = $request->getSession();
$em = $this->getDoctrine()->getManager('company_group');
$post = $request->request;
$ticket = new EntityTicket();
$ticket->setTitle(trim($post->get('title', '')));
$ticket->setTicketBody(trim($post->get('description', '')));
$ticket->setNote(trim($post->get('note', '')));
$ticket->setType((int) $post->get('ticketType', 0) ?: null);
$ticket->setStatus(self::STATUS_OPEN);
$ticket->setUrgency((int) $post->get('priority', 3));
$ticket->setSource((int) $post->get('source', self::SOURCE_MANUAL));
$ticket->setSystemCreated(false);
$ticket->setUserId($this->loggedUserId($request));
// Ownership
if ($post->get('assignedToUserId')) {
$ticket->setAssignedToUserId((int) $post->get('assignedToUserId'));
$ticket->setAssignedByUserId($this->loggedUserId($request));
$ticket->setTicketAssignedDate(new \DateTime());
}
if ($post->get('taggedUserIds')) {
$ticket->setTaggedUserIds($post->get('taggedUserIds'));
}
if ($post->get('companyId')) {
$ticket->setCompanyId((int) $post->get('companyId'));
}
if ($post->get('appId')) {
$ticket->setAppId((int) $post->get('appId'));
}
if ($post->get('branchId')) {
$ticket->setBranchId((int) $post->get('branchId'));
}
// Reporter contact
$ticket->setName(trim($post->get('name', '')));
$ticket->setEmail(trim($post->get('email', '')));
$ticket->setPhone(trim($post->get('phone', '')));
if ($post->get('preferredContactMethod')) {
$ticket->setPreferredContactMethod((int) $post->get('preferredContactMethod'));
}
// Deadline
if ($post->get('deadlineDate')) {
try {
$ticket->setDeadlineDate(new \DateTime($post->get('deadlineDate')));
} catch (\Exception $e) {
// ignore bad date format
}
}
// Feedback
if ($post->get('feedback')) {
$ticket->setFeedback(trim($post->get('feedback')));
}
// System tracking fields (advanced section)
if ($post->get('problemHash')) {
$ticket->setProblemHash(trim($post->get('problemHash')));
}
if ($post->get('serverIdentifier')) {
$ticket->setServerIdentifier(trim($post->get('serverIdentifier')));
}
if ($post->get('lastPayloadJson')) {
$ticket->setLastPayloadJson(trim($post->get('lastPayloadJson')));
}
$em->persist($ticket);
$em->flush();
$this->addFlash('success', 'Ticket ' . $ticket->getTicketNumber() . ' created successfully.');
return $this->redirectToRoute('ticket_view', ['id' => $ticket->getId()]);
}
// =========================================================================
// EDIT
// =========================================================================
/**
* GET /ticket/edit/{id} — show pre-filled form
*/
public function EditTicketAction(Request $request, $id)
{
$em = $this->getDoctrine()->getManager('company_group');
$ticket = $em->getRepository('CompanyGroupBundle\Entity\EntityTicket')->find($id);
if (!$ticket) {
throw $this->createNotFoundException('Ticket #' . $id . ' not found.');
}
// Map entity back to form data array for the view
$formData = [
'title' => $ticket->getTitle(),
'description' => $ticket->getTicketBody(),
'note' => $ticket->getNote(),
'ticketType' => $ticket->getType(),
'priority' => $ticket->getUrgency(),
'source' => $ticket->getSource(),
'assignedToUserId' => $ticket->getAssignedToUserId(),
'taggedUserIds' => $ticket->getTaggedUserIds(),
'companyId' => $ticket->getCompanyId(),
'appId' => $ticket->getAppId(),
'branchId' => $ticket->getBranchId(),
'name' => $ticket->getName(),
'email' => $ticket->getEmail(),
'phone' => $ticket->getPhone(),
'preferredContactMethod' => $ticket->getPreferredContactMethod(),
'deadlineDate' => $ticket->getDeadlineDate() ? $ticket->getDeadlineDate()->format('Y-m-d') : '',
'problemHash' => $ticket->getProblemHash(),
'serverIdentifier' => $ticket->getServerIdentifier(),
'lastPayloadJson' => $ticket->getLastPayloadJson(),
'feedback' => $ticket->getFeedback(),
];
return $this->render('@CompanyGroup/pages/tickets/create_ticket.html.twig', [
'ticket' => $ticket,
'formData' => $formData,
'editMode' => true,
]);
}
/**
* POST /ticket/edit/{id}/submit
*/
public function EditTicketSubmitAction(Request $request, $id)
{
$em = $this->getDoctrine()->getManager('company_group');
$post = $request->request;
$ticket = $em->getRepository('CompanyGroupBundle\Entity\EntityTicket')->find($id);
if (!$ticket) {
throw $this->createNotFoundException('Ticket #' . $id . ' not found.');
}
$ticket->setTitle(trim($post->get('title', '')));
$ticket->setTicketBody(trim($post->get('description', '')));
$ticket->setNote(trim($post->get('note', '')));
$ticket->setType((int) $post->get('ticketType', 0) ?: null);
$ticket->setUrgency((int) $post->get('priority', 3));
$ticket->setSource((int) $post->get('source', self::SOURCE_MANUAL));
$ticket->setName(trim($post->get('name', '')));
$ticket->setEmail(trim($post->get('email', '')));
$ticket->setPhone(trim($post->get('phone', '')));
if ($post->get('preferredContactMethod')) {
$ticket->setPreferredContactMethod((int) $post->get('preferredContactMethod'));
}
if ($post->get('assignedToUserId')) {
if (!$ticket->getAssignedToUserId()) {
$ticket->setTicketAssignedDate(new \DateTime());
$ticket->setAssignedByUserId($this->loggedUserId($request));
}
$ticket->setAssignedToUserId((int) $post->get('assignedToUserId'));
}
if ($post->get('taggedUserIds')) {
$ticket->setTaggedUserIds($post->get('taggedUserIds'));
}
if ($post->get('companyId')) {
$ticket->setCompanyId((int) $post->get('companyId'));
}
if ($post->get('appId')) {
$ticket->setAppId((int) $post->get('appId'));
}
if ($post->get('branchId')) {
$ticket->setBranchId((int) $post->get('branchId'));
}
if ($post->get('deadlineDate')) {
try {
$ticket->setDeadlineDate(new \DateTime($post->get('deadlineDate')));
} catch (\Exception $e) {}
}
if ($post->get('problemHash')) {
$ticket->setProblemHash(trim($post->get('problemHash')));
}
if ($post->get('serverIdentifier')) {
$ticket->setServerIdentifier(trim($post->get('serverIdentifier')));
}
if ($post->get('lastPayloadJson')) {
$ticket->setLastPayloadJson(trim($post->get('lastPayloadJson')));
}
if ($post->get('feedback')) {
$ticket->setFeedback(trim($post->get('feedback')));
}
$em->flush();
$this->addFlash('success', 'Ticket ' . $ticket->getTicketNumber() . ' updated.');
return $this->redirectToRoute('ticket_view', ['id' => $ticket->getId()]);
}
// =========================================================================
// STATUS TRANSITIONS
// =========================================================================
/**
* GET /ticket/resolve/{id}
*/
public function ResolveTicketAction(Request $request, $id)
{
$em = $this->getDoctrine()->getManager('company_group');
$ticket = $em->getRepository('CompanyGroupBundle\Entity\EntityTicket')->find($id);
if (!$ticket) {
throw $this->createNotFoundException('Ticket #' . $id . ' not found.');
}
$ticket->setStatus(self::STATUS_RESOLVED);
$ticket->setResolvedAt(new \DateTime());
$em->flush();
$this->addFlash('success', 'Ticket ' . $ticket->getTicketNumber() . ' marked as resolved.');
return $this->redirectToRoute('ticket_view', ['id' => $id]);
}
/**
* GET /ticket/close/{id}
*/
public function CloseTicketAction(Request $request, $id)
{
$em = $this->getDoctrine()->getManager('company_group');
$ticket = $em->getRepository('CompanyGroupBundle\Entity\EntityTicket')->find($id);
if (!$ticket) {
throw $this->createNotFoundException('Ticket #' . $id . ' not found.');
}
$ticket->setStatus(self::STATUS_CLOSED);
$ticket->setClosedAt(new \DateTime());
$em->flush();
$this->addFlash('success', 'Ticket ' . $ticket->getTicketNumber() . ' closed.');
return $this->redirectToRoute('ticket_view', ['id' => $id]);
}
/**
* POST /ticket/assign/{id}
* Body params: assignedToUserId
*/
public function AssignTicketAction(Request $request, $id)
{
$em = $this->getDoctrine()->getManager('company_group');
$ticket = $em->getRepository('CompanyGroupBundle\Entity\EntityTicket')->find($id);
if (!$ticket) {
return new JsonResponse(['success' => false, 'message' => 'Ticket not found'], 404);
}
$assignTo = (int) $request->request->get('assignedToUserId', 0);
if (!$assignTo) {
return new JsonResponse(['success' => false, 'message' => 'assignedToUserId required'], 400);
}
$ticket->setAssignedToUserId($assignTo);
$ticket->setAssignedByUserId($this->loggedUserId($request));
$ticket->setTicketAssignedDate(new \DateTime());
if ($ticket->getStatus() === self::STATUS_OPEN) {
$ticket->setStatus(self::STATUS_IN_PROGRESS);
}
$em->flush();
return new JsonResponse([
'success' => true,
'ticketNumber' => $ticket->getTicketNumber(),
'assignedTo' => $assignTo,
]);
}
// =========================================================================
// SYSTEM REPORTER (called by cron / error handlers, no browser session)
// =========================================================================
/**
* POST /ticket/system/report
*
* Expected JSON body:
* {
* "errorCode": "DB_CONN_FAIL",
* "message": "Could not connect to database",
* "exceptionClass": "Doctrine\\DBAL\\Exception",
* "fileLocation": "src/Bundle/Service/ReportService.php:142",
* "serverIdentifier":"app-server-01",
* "appId": 3,
* "payloadJson": "{...full raw error payload...}"
* }
*
* Returns JSON: { success, ticketId, ticketNumber, action: "created"|"merged" }
*/
public function SystemReportAction(Request $request)
{
// ── Parse input ───────────────────────────────────────────────────────
$body = json_decode($request->getContent(), true);
if (!$body) {
$body = $request->request->all();
}
$errorCode = $body['errorCode'] ?? '';
$message = $body['message'] ?? '';
$exceptionClass = $body['exceptionClass'] ?? '';
$fileLocation = $body['fileLocation'] ?? '';
$serverIdentifier= $body['serverIdentifier'] ?? gethostname();
$appId = isset($body['appId']) ? (int) $body['appId'] : null;
$payloadJson = isset($body['payloadJson'])
? (is_string($body['payloadJson']) ? $body['payloadJson'] : json_encode($body['payloadJson']))
: json_encode($body);
// ── Compute problemHash (stable attributes only, NO timestamps/userIds) ──
$hashInput = implode('|', [$errorCode, $exceptionClass, $fileLocation, (string) $appId]);
$problemHash = hash('sha256', $hashInput);
$em = $this->getDoctrine()->getManager('company_group');
$now = new \DateTime();
// ── Deduplication: look for open ticket with same hash within 1 hour ──
$windowStart = (clone $now)->modify('-' . self::DEDUP_WINDOW_SECONDS . ' seconds');
$existing = $em->createQueryBuilder()
->select('t')
->from('CompanyGroupBundle\Entity\EntityTicket', 't')
->where('t.problemHash = :hash')
->andWhere('t.lastReportedAt >= :window')
->andWhere('t.status != :closed')
->setParameter('hash', $problemHash)
->setParameter('window', $windowStart)
->setParameter('closed', self::STATUS_CLOSED)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
if ($existing) {
// ── MERGE: increment counter, refresh last payload ─────────────
$existing->setReportCount($existing->getReportCount() + 1);
$existing->setLastReportedAt($now);
$existing->setLastPayloadJson($payloadJson);
if ($serverIdentifier) {
$existing->setServerIdentifier($serverIdentifier);
}
$ticket = $existing;
$action = 'merged';
} else {
// ── CREATE: new ticket (new incident window) ───────────────────
$ticket = new EntityTicket();
$ticket->setTitle('[' . $errorCode . '] ' . mb_substr($message, 0, 200));
$ticket->setTicketBody($message);
$ticket->setStatus(self::STATUS_OPEN);
$ticket->setUrgency(1); // emergency by default for system errors
$ticket->setSource(self::SOURCE_SYSTEM);
$ticket->setSystemCreated(true);
$ticket->setProblemHash($problemHash);
$ticket->setReportCount(1);
$ticket->setFirstReportedAt($now);
$ticket->setLastReportedAt($now);
$ticket->setLastPayloadJson($payloadJson);
$ticket->setServerIdentifier($serverIdentifier);
if ($appId) {
$ticket->setAppId($appId);
}
$em->persist($ticket);
$action = 'created';
}
// ── Always log one EntityTicketReport row per occurrence ──────────────
$em->flush(); // flush ticket first so we have its ID if just created
$report = new EntityTicketReport();
$report->setTicketId($ticket->getId());
$report->setMessage($message);
$report->setErrorCode($errorCode);
$report->setServerIdentifier($serverIdentifier);
$report->setPayloadJson($payloadJson);
$report->setReportedAt($now);
$em->persist($report);
$em->flush();
return new JsonResponse([
'success' => true,
'action' => $action,
'ticketId' => $ticket->getId(),
'ticketNumber' => $ticket->getTicketNumber(),
'reportCount' => $ticket->getReportCount(),
]);
}
// =========================================================================
// PRIVATE HELPERS
// =========================================================================
/**
* Returns a fully-keyed formData array with safe defaults.
* Prevents Twig strict-access errors when the form is blank (create mode).
*/
private function _emptyFormData()
{
return [
'title' => '',
'description' => '',
'note' => '',
'ticketType' => null,
'priority' => 3,
'source' => 1,
'assignedToUserId' => null,
'taggedUserIds' => '',
'companyId' => null,
'appId' => null,
'branchId' => null,
'name' => '',
'email' => '',
'phone' => '',
'preferredContactMethod' => null,
'deadlineDate' => '',
'problemHash' => '',
'serverIdentifier' => '',
'lastPayloadJson' => '',
'errorCode' => '',
'feedback' => '',
];
}
/**
* Returns ticket counts grouped by status.
*/
private function _getStatusSummary($em)
{
$rows = $em->createQueryBuilder()
->select('t.status, COUNT(t.id) as cnt')
->from('CompanyGroupBundle\Entity\EntityTicket', 't')
->groupBy('t.status')
->getQuery()
->getArrayResult();
$map = ['open' => 0, 'inProgress' => 0, 'resolved' => 0, 'closed' => 0];
foreach ($rows as $row) {
switch ((int) $row['status']) {
case self::STATUS_OPEN: $map['open'] = (int) $row['cnt']; break;
case self::STATUS_IN_PROGRESS: $map['inProgress'] = (int) $row['cnt']; break;
case self::STATUS_RESOLVED: $map['resolved'] = (int) $row['cnt']; break;
case self::STATUS_CLOSED: $map['closed'] = (int) $row['cnt']; break;
}
}
return $map;
}
}