Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 443 |
|
0.00% |
0 / 24 |
CRAP | |
0.00% |
0 / 1 |
| EventService | |
0.00% |
0 / 443 |
|
0.00% |
0 / 24 |
9506 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| setIntegrationService | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| getIntegrationService | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| list | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
42 | |||
| get | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
| getIntegrations | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
| create | |
0.00% |
0 / 39 |
|
0.00% |
0 / 1 |
20 | |||
| update | |
0.00% |
0 / 47 |
|
0.00% |
0 / 1 |
210 | |||
| delete | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
56 | |||
| activate | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
42 | |||
| cancel | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
20 | |||
| archive | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
| unarchive | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
42 | |||
| permanentDelete | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
42 | |||
| duplicate | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
42 | |||
| checkConflicts | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
42 | |||
| logAudit | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
| validateTransition | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| recalculatePhase | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| hydrateEventFromData | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
20 | |||
| mergeEventData | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
2 | |||
| parseDateTime | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
30 | |||
| camelToSnake | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| formatValueForDb | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace BuyerKiosk\EventManagement\Services; |
| 4 | |
| 5 | use PDO; |
| 6 | use PDOException; |
| 7 | use DateTime; |
| 8 | use InvalidArgumentException; |
| 9 | use RuntimeException; |
| 10 | use BuyerKiosk\EventManagement\Models\Event; |
| 11 | use BuyerKiosk\EventManagement\Models\EventIntegration; |
| 12 | use BuyerKiosk\EventManagement\Services\IntegrationService; |
| 13 | |
| 14 | /** |
| 15 | * EventService - Manages store event lifecycle and CRUD operations |
| 16 | * |
| 17 | * Handles all event-related operations including: |
| 18 | * - CRUD operations for events |
| 19 | * - Status lifecycle transitions (draft -> scheduled -> active -> completed) |
| 20 | * - Event phase calculation |
| 21 | * - Conflict detection for overlapping events |
| 22 | * - Audit logging for all mutations |
| 23 | * |
| 24 | * @package BuyerKiosk\EventManagement\Services |
| 25 | */ |
| 26 | class EventService |
| 27 | { |
| 28 | private PDO $db; |
| 29 | private ?int $employeeId; |
| 30 | private ?IntegrationService $integrationService = null; |
| 31 | |
| 32 | /** |
| 33 | * Valid status transitions |
| 34 | * |
| 35 | * Key: current status, Value: array of allowed target statuses |
| 36 | */ |
| 37 | private const STATUS_TRANSITIONS = [ |
| 38 | Event::STATUS_DRAFT => [Event::STATUS_SCHEDULED, Event::STATUS_CANCELLED], |
| 39 | Event::STATUS_SCHEDULED => [Event::STATUS_ACTIVE, Event::STATUS_CANCELLED], |
| 40 | Event::STATUS_ACTIVE => [Event::STATUS_COMPLETED, Event::STATUS_CANCELLED], |
| 41 | Event::STATUS_COMPLETED => [Event::STATUS_ARCHIVED], |
| 42 | Event::STATUS_CANCELLED => [Event::STATUS_ARCHIVED], |
| 43 | Event::STATUS_ARCHIVED => [], // Can only unarchive via special method |
| 44 | ]; |
| 45 | |
| 46 | /** |
| 47 | * Constructor |
| 48 | * |
| 49 | * @param PDO $db Database connection for the store |
| 50 | * @param int|null $employeeId Employee ID for audit logging |
| 51 | */ |
| 52 | public function __construct(PDO $db, ?int $employeeId = null) |
| 53 | { |
| 54 | $this->db = $db; |
| 55 | $this->employeeId = $employeeId; |
| 56 | } |
| 57 | |
| 58 | /** |
| 59 | * Set the IntegrationService for cascade operations |
| 60 | * |
| 61 | * When set, cascade operations (date sync, activate, deactivate, delete) |
| 62 | * will use the IntegrationService to cascade changes to all linked systems. |
| 63 | * |
| 64 | * @param IntegrationService $integrationService |
| 65 | * @return self For method chaining |
| 66 | */ |
| 67 | public function setIntegrationService(IntegrationService $integrationService): self |
| 68 | { |
| 69 | $this->integrationService = $integrationService; |
| 70 | return $this; |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * Get the IntegrationService |
| 75 | * |
| 76 | * @return IntegrationService|null |
| 77 | */ |
| 78 | public function getIntegrationService(): ?IntegrationService |
| 79 | { |
| 80 | return $this->integrationService; |
| 81 | } |
| 82 | |
| 83 | // ========================================================================= |
| 84 | // LIST & GET OPERATIONS |
| 85 | // ========================================================================= |
| 86 | |
| 87 | /** |
| 88 | * List events with filtering |
| 89 | * |
| 90 | * @param array $filters ['year' => int, 'status' => string, 'eventType' => string, 'includeArchived' => bool] |
| 91 | * @return Event[] Array of Event objects |
| 92 | */ |
| 93 | public function list(array $filters = []): array |
| 94 | { |
| 95 | $sql = "SELECT * FROM events WHERE 1=1"; |
| 96 | $params = []; |
| 97 | |
| 98 | if (isset($filters['year'])) { |
| 99 | $sql .= " AND year = :year"; |
| 100 | $params[':year'] = (int) $filters['year']; |
| 101 | } |
| 102 | |
| 103 | if (isset($filters['status'])) { |
| 104 | $sql .= " AND status = :status"; |
| 105 | $params[':status'] = $filters['status']; |
| 106 | } |
| 107 | |
| 108 | if (isset($filters['eventType'])) { |
| 109 | $sql .= " AND eventType = :eventType"; |
| 110 | $params[':eventType'] = $filters['eventType']; |
| 111 | } |
| 112 | |
| 113 | if (!($filters['includeArchived'] ?? false)) { |
| 114 | $sql .= " AND status != 'archived'"; |
| 115 | } |
| 116 | |
| 117 | $sql .= " ORDER BY startDate DESC, created_at DESC"; |
| 118 | |
| 119 | $stmt = $this->db->prepare($sql); |
| 120 | $stmt->execute($params); |
| 121 | |
| 122 | $events = []; |
| 123 | while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { |
| 124 | $events[] = Event::fromRow($row); |
| 125 | } |
| 126 | |
| 127 | return $events; |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * Get single event by ID with integrations loaded |
| 132 | * |
| 133 | * @param int $eventId Event ID |
| 134 | * @return Event|null Event object or null if not found |
| 135 | */ |
| 136 | public function get(int $eventId): ?Event |
| 137 | { |
| 138 | $sql = "SELECT * FROM events WHERE id = :id LIMIT 1"; |
| 139 | $stmt = $this->db->prepare($sql); |
| 140 | $stmt->execute([':id' => $eventId]); |
| 141 | $row = $stmt->fetch(PDO::FETCH_ASSOC); |
| 142 | |
| 143 | if (!$row) { |
| 144 | return null; |
| 145 | } |
| 146 | |
| 147 | return Event::fromRow($row); |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Get integrations for an event |
| 152 | * |
| 153 | * @param int $eventId Event ID |
| 154 | * @return EventIntegration[] Array of EventIntegration objects |
| 155 | */ |
| 156 | public function getIntegrations(int $eventId): array |
| 157 | { |
| 158 | $sql = "SELECT * FROM event_integrations WHERE eventId = :eventId ORDER BY integrationType, id"; |
| 159 | $stmt = $this->db->prepare($sql); |
| 160 | $stmt->execute([':eventId' => $eventId]); |
| 161 | |
| 162 | $integrations = []; |
| 163 | while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { |
| 164 | $integrations[] = EventIntegration::fromRow($row); |
| 165 | } |
| 166 | |
| 167 | return $integrations; |
| 168 | } |
| 169 | |
| 170 | // ========================================================================= |
| 171 | // CREATE, UPDATE, DELETE OPERATIONS |
| 172 | // ========================================================================= |
| 173 | |
| 174 | /** |
| 175 | * Create new event |
| 176 | * |
| 177 | * @param array $data Event data |
| 178 | * @return Event Created event |
| 179 | * @throws InvalidArgumentException On validation failure |
| 180 | */ |
| 181 | public function create(array $data): Event |
| 182 | { |
| 183 | // Hydrate and validate the event |
| 184 | $event = $this->hydrateEventFromData($data); |
| 185 | $errors = $event->validate(); |
| 186 | |
| 187 | if (!empty($errors)) { |
| 188 | throw new InvalidArgumentException(implode('; ', $errors)); |
| 189 | } |
| 190 | |
| 191 | // Check for conflicts |
| 192 | $conflicts = $this->checkConflicts($data); |
| 193 | if ($conflicts['hasConflicts']) { |
| 194 | throw new InvalidArgumentException( |
| 195 | 'Event conflicts detected: ' . implode('; ', $conflicts['conflicts']) |
| 196 | ); |
| 197 | } |
| 198 | |
| 199 | $sql = "INSERT INTO events ( |
| 200 | templateId, sourceEventId, name, description, eventType, year, |
| 201 | startDate, endDate, buildUpDays, windDownDays, |
| 202 | status, previousStatus, phase, color, icon, isRecurring, |
| 203 | createdBy, created_at, updated_at |
| 204 | ) VALUES ( |
| 205 | :templateId, :sourceEventId, :name, :description, :eventType, :year, |
| 206 | :startDate, :endDate, :buildUpDays, :windDownDays, |
| 207 | :status, :previousStatus, :phase, :color, :icon, :isRecurring, |
| 208 | :createdBy, NOW(), NOW() |
| 209 | )"; |
| 210 | |
| 211 | $stmt = $this->db->prepare($sql); |
| 212 | $stmt->execute([ |
| 213 | ':templateId' => $event->templateId, |
| 214 | ':sourceEventId' => $event->sourceEventId, |
| 215 | ':name' => $event->name, |
| 216 | ':description' => $event->description, |
| 217 | ':eventType' => $event->eventType, |
| 218 | ':year' => $event->year, |
| 219 | ':startDate' => $event->startDate?->format('Y-m-d H:i:s'), |
| 220 | ':endDate' => $event->endDate?->format('Y-m-d H:i:s'), |
| 221 | ':buildUpDays' => $event->buildUpDays, |
| 222 | ':windDownDays' => $event->windDownDays, |
| 223 | ':status' => $event->status, |
| 224 | ':previousStatus' => $event->previousStatus, |
| 225 | ':phase' => $event->calculatePhase(), |
| 226 | ':color' => $event->color, |
| 227 | ':icon' => $event->icon, |
| 228 | ':isRecurring' => $event->isRecurring ? 1 : 0, |
| 229 | ':createdBy' => $this->employeeId ?? $event->createdBy, |
| 230 | ]); |
| 231 | |
| 232 | $eventId = (int) $this->db->lastInsertId(); |
| 233 | $createdEvent = $this->get($eventId); |
| 234 | |
| 235 | // Log the creation |
| 236 | $this->logAudit($eventId, 'created', [ |
| 237 | 'name' => $event->name, |
| 238 | 'eventType' => $event->eventType, |
| 239 | 'status' => $event->status, |
| 240 | ]); |
| 241 | |
| 242 | return $createdEvent; |
| 243 | } |
| 244 | |
| 245 | /** |
| 246 | * Update existing event |
| 247 | * |
| 248 | * @param int $eventId Event ID |
| 249 | * @param array $data Fields to update |
| 250 | * @return Event Updated event |
| 251 | * @throws InvalidArgumentException On validation failure |
| 252 | */ |
| 253 | public function update(int $eventId, array $data): Event |
| 254 | { |
| 255 | $existingEvent = $this->get($eventId); |
| 256 | if (!$existingEvent) { |
| 257 | throw new InvalidArgumentException('Event not found'); |
| 258 | } |
| 259 | |
| 260 | // Merge existing data with updates |
| 261 | $mergedData = $this->mergeEventData($existingEvent, $data); |
| 262 | $event = $this->hydrateEventFromData($mergedData); |
| 263 | $event->id = $eventId; |
| 264 | |
| 265 | $errors = $event->validate(); |
| 266 | if (!empty($errors)) { |
| 267 | throw new InvalidArgumentException(implode('; ', $errors)); |
| 268 | } |
| 269 | |
| 270 | // Check for conflicts (excluding this event) |
| 271 | $conflicts = $this->checkConflicts($mergedData, $eventId); |
| 272 | if ($conflicts['hasConflicts']) { |
| 273 | throw new InvalidArgumentException( |
| 274 | 'Event conflicts detected: ' . implode('; ', $conflicts['conflicts']) |
| 275 | ); |
| 276 | } |
| 277 | |
| 278 | // Build dynamic UPDATE query |
| 279 | $allowedFields = [ |
| 280 | 'name', 'description', 'eventType', 'year', |
| 281 | 'startDate', 'endDate', 'buildUpDays', 'windDownDays', |
| 282 | 'color', 'icon', 'isRecurring', |
| 283 | ]; |
| 284 | |
| 285 | $setClauses = []; |
| 286 | $params = [':id' => $eventId]; |
| 287 | $changes = []; |
| 288 | |
| 289 | foreach ($allowedFields as $field) { |
| 290 | if (array_key_exists($field, $data)) { |
| 291 | // DB uses camelCase column names, so no conversion needed |
| 292 | $value = $this->formatValueForDb($field, $data[$field]); |
| 293 | $setClauses[] = "`{$field}` = :{$field}"; |
| 294 | $params[":{$field}"] = $value; |
| 295 | $changes[$field] = $data[$field]; |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | if (empty($setClauses)) { |
| 300 | return $existingEvent; |
| 301 | } |
| 302 | |
| 303 | // Recalculate phase if dates changed |
| 304 | $datesChanged = isset($data['startDate']) || isset($data['endDate']) || |
| 305 | isset($data['buildUpDays']) || isset($data['windDownDays']); |
| 306 | |
| 307 | if ($datesChanged) { |
| 308 | $setClauses[] = "`phase` = :phase"; |
| 309 | $params[':phase'] = $event->calculatePhase(); |
| 310 | } |
| 311 | |
| 312 | $setClauses[] = "updated_at = NOW()"; |
| 313 | $sql = "UPDATE events SET " . implode(', ', $setClauses) . " WHERE id = :id"; |
| 314 | |
| 315 | $stmt = $this->db->prepare($sql); |
| 316 | $stmt->execute($params); |
| 317 | |
| 318 | // Log the update |
| 319 | $this->logAudit($eventId, 'updated', $changes); |
| 320 | |
| 321 | // Get the updated event |
| 322 | $updatedEvent = $this->get($eventId); |
| 323 | |
| 324 | // Cascade date changes to integrations if dates changed and service is available |
| 325 | if ($datesChanged && $this->integrationService !== null) { |
| 326 | $syncResult = $this->integrationService->syncAllDates($updatedEvent); |
| 327 | if (!empty($syncResult['failed'])) { |
| 328 | // Log failed syncs but don't fail the update |
| 329 | error_log("EventService: Some integrations failed to sync dates: " . |
| 330 | json_encode($syncResult['failed'])); |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | return $updatedEvent; |
| 335 | } |
| 336 | |
| 337 | /** |
| 338 | * Delete event (cascade to integrations) |
| 339 | * |
| 340 | * @param int $eventId Event ID |
| 341 | * @return array ['success' => bool, 'cascadeDeleted' => ['type' => count], 'failed' => array] |
| 342 | * @throws InvalidArgumentException If event cannot be deleted |
| 343 | */ |
| 344 | public function delete(int $eventId): array |
| 345 | { |
| 346 | $event = $this->get($eventId); |
| 347 | if (!$event) { |
| 348 | throw new InvalidArgumentException('Event not found'); |
| 349 | } |
| 350 | |
| 351 | // Only draft events can be deleted |
| 352 | if ($event->status !== Event::STATUS_DRAFT) { |
| 353 | throw new InvalidArgumentException('Only draft events can be deleted. Use archive for non-draft events.'); |
| 354 | } |
| 355 | |
| 356 | $this->db->beginTransaction(); |
| 357 | |
| 358 | try { |
| 359 | $cascadeResult = ['deleted' => 0, 'failed' => [], 'byType' => []]; |
| 360 | |
| 361 | // Use IntegrationService for cascade delete if available |
| 362 | if ($this->integrationService !== null) { |
| 363 | $cascadeResult = $this->integrationService->cascadeDelete($eventId); |
| 364 | } else { |
| 365 | // Fallback: just delete integration link records (target records remain) |
| 366 | $sql = "SELECT integrationType, COUNT(*) as cnt FROM event_integrations WHERE eventId = :eventId GROUP BY integrationType"; |
| 367 | $stmt = $this->db->prepare($sql); |
| 368 | $stmt->execute([':eventId' => $eventId]); |
| 369 | while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { |
| 370 | $cascadeResult['byType'][$row['integrationType']] = (int) $row['cnt']; |
| 371 | } |
| 372 | |
| 373 | $sql = "DELETE FROM event_integrations WHERE eventId = :eventId"; |
| 374 | $stmt = $this->db->prepare($sql); |
| 375 | $stmt->execute([':eventId' => $eventId]); |
| 376 | } |
| 377 | |
| 378 | // Delete the event |
| 379 | $sql = "DELETE FROM events WHERE id = :id AND status = 'draft'"; |
| 380 | $stmt = $this->db->prepare($sql); |
| 381 | $stmt->execute([':id' => $eventId]); |
| 382 | |
| 383 | if ($stmt->rowCount() === 0) { |
| 384 | throw new RuntimeException('Failed to delete event'); |
| 385 | } |
| 386 | |
| 387 | // Log the deletion |
| 388 | $this->logAudit($eventId, 'deleted', [ |
| 389 | 'name' => $event->name, |
| 390 | 'cascadeDeleted' => $cascadeResult['byType'], |
| 391 | ]); |
| 392 | |
| 393 | $this->db->commit(); |
| 394 | |
| 395 | return [ |
| 396 | 'success' => true, |
| 397 | 'cascadeDeleted' => $cascadeResult['byType'], |
| 398 | 'failed' => $cascadeResult['failed'], |
| 399 | ]; |
| 400 | } catch (\Exception $e) { |
| 401 | $this->db->rollBack(); |
| 402 | throw $e; |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | // ========================================================================= |
| 407 | // LIFECYCLE TRANSITIONS |
| 408 | // ========================================================================= |
| 409 | |
| 410 | /** |
| 411 | * Activate event (change status to scheduled or active based on dates) |
| 412 | * |
| 413 | * @param int $eventId Event ID |
| 414 | * @return Event Updated event |
| 415 | * @throws InvalidArgumentException On invalid transition |
| 416 | */ |
| 417 | public function activate(int $eventId): Event |
| 418 | { |
| 419 | $event = $this->get($eventId); |
| 420 | if (!$event) { |
| 421 | throw new InvalidArgumentException('Event not found'); |
| 422 | } |
| 423 | |
| 424 | $this->validateTransition($event, Event::STATUS_ACTIVE); |
| 425 | |
| 426 | // Must have dates to activate |
| 427 | if ($event->startDate === null || $event->endDate === null) { |
| 428 | throw new InvalidArgumentException('Event must have start and end dates to be activated'); |
| 429 | } |
| 430 | |
| 431 | $this->db->beginTransaction(); |
| 432 | |
| 433 | try { |
| 434 | // Update event status |
| 435 | $sql = "UPDATE events SET |
| 436 | status = 'active', |
| 437 | previousStatus = :previousStatus, |
| 438 | phase = :phase, |
| 439 | updated_at = NOW() |
| 440 | WHERE id = :id"; |
| 441 | $stmt = $this->db->prepare($sql); |
| 442 | $stmt->execute([ |
| 443 | ':id' => $eventId, |
| 444 | ':previousStatus' => $event->status, |
| 445 | ':phase' => Event::PHASE_ACTIVE, |
| 446 | ]); |
| 447 | |
| 448 | // Activate pending integrations using IntegrationService if available |
| 449 | $activationResult = ['activated' => 0, 'failed' => []]; |
| 450 | if ($this->integrationService !== null) { |
| 451 | $updatedEvent = $this->get($eventId); |
| 452 | $activationResult = $this->integrationService->activatePending($updatedEvent); |
| 453 | } else { |
| 454 | // Fallback: just update integration status records |
| 455 | $sql = "UPDATE event_integrations SET status = 'active' WHERE event_id = :eventId AND status = 'pending'"; |
| 456 | $stmt = $this->db->prepare($sql); |
| 457 | $stmt->execute([':eventId' => $eventId]); |
| 458 | } |
| 459 | |
| 460 | $this->logAudit($eventId, 'activated', [ |
| 461 | 'previousStatus' => $event->status, |
| 462 | 'newStatus' => Event::STATUS_ACTIVE, |
| 463 | 'integrationsActivated' => $activationResult['activated'], |
| 464 | 'integrationsFailed' => count($activationResult['failed']), |
| 465 | ]); |
| 466 | |
| 467 | $this->db->commit(); |
| 468 | |
| 469 | return $this->get($eventId); |
| 470 | } catch (\Exception $e) { |
| 471 | $this->db->rollBack(); |
| 472 | throw $e; |
| 473 | } |
| 474 | } |
| 475 | |
| 476 | /** |
| 477 | * Cancel event and deactivate integrations |
| 478 | * |
| 479 | * @param int $eventId Event ID |
| 480 | * @return Event Updated event |
| 481 | * @throws InvalidArgumentException On invalid transition |
| 482 | */ |
| 483 | public function cancel(int $eventId): Event |
| 484 | { |
| 485 | $event = $this->get($eventId); |
| 486 | if (!$event) { |
| 487 | throw new InvalidArgumentException('Event not found'); |
| 488 | } |
| 489 | |
| 490 | $this->validateTransition($event, Event::STATUS_CANCELLED); |
| 491 | |
| 492 | $this->db->beginTransaction(); |
| 493 | |
| 494 | try { |
| 495 | // Update event status |
| 496 | $sql = "UPDATE events SET |
| 497 | status = 'cancelled', |
| 498 | previousStatus = :previousStatus, |
| 499 | updated_at = NOW() |
| 500 | WHERE id = :id"; |
| 501 | $stmt = $this->db->prepare($sql); |
| 502 | $stmt->execute([ |
| 503 | ':id' => $eventId, |
| 504 | ':previousStatus' => $event->status, |
| 505 | ]); |
| 506 | |
| 507 | // Deactivate integrations using IntegrationService if available |
| 508 | $deactivationResult = ['deactivated' => 0, 'failed' => []]; |
| 509 | if ($this->integrationService !== null) { |
| 510 | $deactivationResult = $this->integrationService->deactivateAll($event); |
| 511 | } else { |
| 512 | // Fallback: just update integration status records |
| 513 | $sql = "UPDATE event_integrations SET status = 'failed' |
| 514 | WHERE event_id = :eventId AND status IN ('pending', 'active')"; |
| 515 | $stmt = $this->db->prepare($sql); |
| 516 | $stmt->execute([':eventId' => $eventId]); |
| 517 | $deactivationResult['deactivated'] = $stmt->rowCount(); |
| 518 | } |
| 519 | |
| 520 | $this->logAudit($eventId, 'cancelled', [ |
| 521 | 'previousStatus' => $event->status, |
| 522 | 'deactivatedIntegrations' => $deactivationResult['deactivated'], |
| 523 | 'deactivationFailed' => count($deactivationResult['failed']), |
| 524 | ]); |
| 525 | |
| 526 | $this->db->commit(); |
| 527 | |
| 528 | return $this->get($eventId); |
| 529 | } catch (\Exception $e) { |
| 530 | $this->db->rollBack(); |
| 531 | throw $e; |
| 532 | } |
| 533 | } |
| 534 | |
| 535 | /** |
| 536 | * Archive event (soft delete) |
| 537 | * |
| 538 | * @param int $eventId Event ID |
| 539 | * @return Event Updated event |
| 540 | * @throws InvalidArgumentException On invalid transition |
| 541 | */ |
| 542 | public function archive(int $eventId): Event |
| 543 | { |
| 544 | $event = $this->get($eventId); |
| 545 | if (!$event) { |
| 546 | throw new InvalidArgumentException('Event not found'); |
| 547 | } |
| 548 | |
| 549 | $this->validateTransition($event, Event::STATUS_ARCHIVED); |
| 550 | |
| 551 | $sql = "UPDATE events SET |
| 552 | status = 'archived', |
| 553 | previousStatus = :previousStatus, |
| 554 | archivedAt = NOW(), |
| 555 | updated_at = NOW() |
| 556 | WHERE id = :id"; |
| 557 | $stmt = $this->db->prepare($sql); |
| 558 | $stmt->execute([ |
| 559 | ':id' => $eventId, |
| 560 | ':previousStatus' => $event->status, |
| 561 | ]); |
| 562 | |
| 563 | $this->logAudit($eventId, 'archived', [ |
| 564 | 'previousStatus' => $event->status, |
| 565 | ]); |
| 566 | |
| 567 | return $this->get($eventId); |
| 568 | } |
| 569 | |
| 570 | /** |
| 571 | * Unarchive event (restore previous status) |
| 572 | * |
| 573 | * @param int $eventId Event ID |
| 574 | * @return Event Updated event |
| 575 | * @throws InvalidArgumentException If event is not archived |
| 576 | */ |
| 577 | public function unarchive(int $eventId): Event |
| 578 | { |
| 579 | $event = $this->get($eventId); |
| 580 | if (!$event) { |
| 581 | throw new InvalidArgumentException('Event not found'); |
| 582 | } |
| 583 | |
| 584 | if ($event->status !== Event::STATUS_ARCHIVED) { |
| 585 | throw new InvalidArgumentException('Only archived events can be unarchived'); |
| 586 | } |
| 587 | |
| 588 | // Restore to previous status, default to draft if no previous status |
| 589 | $restoreStatus = $event->previousStatus ?? Event::STATUS_DRAFT; |
| 590 | |
| 591 | // If restoring to active, verify it's still valid |
| 592 | if ($restoreStatus === Event::STATUS_ACTIVE) { |
| 593 | $now = new DateTime(); |
| 594 | if ($event->endDate !== null && $event->endDate < $now) { |
| 595 | $restoreStatus = Event::STATUS_COMPLETED; |
| 596 | } |
| 597 | } |
| 598 | |
| 599 | $sql = "UPDATE events SET |
| 600 | status = :status, |
| 601 | previousStatus = 'archived', |
| 602 | archivedAt = NULL, |
| 603 | updated_at = NOW() |
| 604 | WHERE id = :id"; |
| 605 | $stmt = $this->db->prepare($sql); |
| 606 | $stmt->execute([ |
| 607 | ':id' => $eventId, |
| 608 | ':status' => $restoreStatus, |
| 609 | ]); |
| 610 | |
| 611 | $this->logAudit($eventId, 'unarchived', [ |
| 612 | 'restoredStatus' => $restoreStatus, |
| 613 | ]); |
| 614 | |
| 615 | return $this->get($eventId); |
| 616 | } |
| 617 | |
| 618 | /** |
| 619 | * Permanently delete archived event |
| 620 | * |
| 621 | * @param int $eventId Event ID |
| 622 | * @return array ['success' => bool, 'cascadeDeleted' => ['type' => count]] |
| 623 | * @throws InvalidArgumentException If event is not archived |
| 624 | */ |
| 625 | public function permanentDelete(int $eventId): array |
| 626 | { |
| 627 | $event = $this->get($eventId); |
| 628 | if (!$event) { |
| 629 | throw new InvalidArgumentException('Event not found'); |
| 630 | } |
| 631 | |
| 632 | if ($event->status !== Event::STATUS_ARCHIVED) { |
| 633 | throw new InvalidArgumentException('Only archived events can be permanently deleted'); |
| 634 | } |
| 635 | |
| 636 | $this->db->beginTransaction(); |
| 637 | |
| 638 | try { |
| 639 | // Get integration counts by type for return value |
| 640 | $integrationCounts = []; |
| 641 | $sql = "SELECT integrationType, COUNT(*) as cnt FROM event_integrations WHERE eventId = :eventId GROUP BY integrationType"; |
| 642 | $stmt = $this->db->prepare($sql); |
| 643 | $stmt->execute([':eventId' => $eventId]); |
| 644 | while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { |
| 645 | $integrationCounts[$row['integrationType']] = (int) $row['cnt']; |
| 646 | } |
| 647 | |
| 648 | // Delete audit logs |
| 649 | $sql = "DELETE FROM event_audit_log WHERE eventId = :eventId"; |
| 650 | $stmt = $this->db->prepare($sql); |
| 651 | $stmt->execute([':eventId' => $eventId]); |
| 652 | |
| 653 | // Delete integrations |
| 654 | $sql = "DELETE FROM event_integrations WHERE eventId = :eventId"; |
| 655 | $stmt = $this->db->prepare($sql); |
| 656 | $stmt->execute([':eventId' => $eventId]); |
| 657 | |
| 658 | // Delete the event |
| 659 | $sql = "DELETE FROM events WHERE id = :id AND status = 'archived'"; |
| 660 | $stmt = $this->db->prepare($sql); |
| 661 | $stmt->execute([':id' => $eventId]); |
| 662 | |
| 663 | if ($stmt->rowCount() === 0) { |
| 664 | throw new RuntimeException('Failed to permanently delete event'); |
| 665 | } |
| 666 | |
| 667 | $this->db->commit(); |
| 668 | |
| 669 | return [ |
| 670 | 'success' => true, |
| 671 | 'cascadeDeleted' => $integrationCounts, |
| 672 | ]; |
| 673 | } catch (\Exception $e) { |
| 674 | $this->db->rollBack(); |
| 675 | throw $e; |
| 676 | } |
| 677 | } |
| 678 | |
| 679 | // ========================================================================= |
| 680 | // DUPLICATE OPERATION |
| 681 | // ========================================================================= |
| 682 | |
| 683 | /** |
| 684 | * Duplicate event with new dates |
| 685 | * |
| 686 | * @param int $eventId Source event ID |
| 687 | * @param array $data ['name' => string, 'startDate' => string, 'endDate' => string, 'includeIntegrations' => bool] |
| 688 | * @return Event Duplicated event |
| 689 | * @throws InvalidArgumentException If source event not found |
| 690 | */ |
| 691 | public function duplicate(int $eventId, array $data): Event |
| 692 | { |
| 693 | $sourceEvent = $this->get($eventId); |
| 694 | if (!$sourceEvent) { |
| 695 | throw new InvalidArgumentException('Source event not found'); |
| 696 | } |
| 697 | |
| 698 | // Prepare new event data |
| 699 | $newEventData = [ |
| 700 | 'name' => $data['name'] ?? $sourceEvent->name . ' (Copy)', |
| 701 | 'description' => $sourceEvent->description, |
| 702 | 'eventType' => $sourceEvent->eventType, |
| 703 | 'year' => $data['year'] ?? (int) date('Y'), |
| 704 | 'startDate' => $data['startDate'] ?? null, |
| 705 | 'endDate' => $data['endDate'] ?? null, |
| 706 | 'buildUpDays' => $sourceEvent->buildUpDays, |
| 707 | 'windDownDays' => $sourceEvent->windDownDays, |
| 708 | 'color' => $sourceEvent->color, |
| 709 | 'icon' => $sourceEvent->icon, |
| 710 | 'isRecurring' => $sourceEvent->isRecurring, |
| 711 | 'sourceEventId' => $sourceEvent->id, |
| 712 | 'templateId' => $sourceEvent->templateId, |
| 713 | 'status' => Event::STATUS_DRAFT, |
| 714 | ]; |
| 715 | |
| 716 | $this->db->beginTransaction(); |
| 717 | |
| 718 | try { |
| 719 | // Create the new event |
| 720 | $newEvent = $this->create($newEventData); |
| 721 | |
| 722 | // Duplicate integrations if requested |
| 723 | if ($data['includeIntegrations'] ?? false) { |
| 724 | $integrations = $this->getIntegrations($eventId); |
| 725 | foreach ($integrations as $integration) { |
| 726 | $sql = "INSERT INTO event_integrations ( |
| 727 | eventId, integrationType, foreignId, config, status, relativeDays, created_at |
| 728 | ) VALUES ( |
| 729 | :eventId, :integrationType, :foreignId, :config, 'pending', :relativeDays, NOW() |
| 730 | )"; |
| 731 | $stmt = $this->db->prepare($sql); |
| 732 | $stmt->execute([ |
| 733 | ':eventId' => $newEvent->id, |
| 734 | ':integrationType' => $integration->integrationType, |
| 735 | ':foreignId' => $integration->foreignId, |
| 736 | ':config' => $integration->config ? json_encode($integration->config) : null, |
| 737 | ':relativeDays' => $integration->relativeDays, |
| 738 | ]); |
| 739 | } |
| 740 | } |
| 741 | |
| 742 | $this->logAudit($newEvent->id, 'duplicated', [ |
| 743 | 'sourceEventId' => $eventId, |
| 744 | 'includeIntegrations' => $data['includeIntegrations'] ?? false, |
| 745 | ]); |
| 746 | |
| 747 | $this->db->commit(); |
| 748 | |
| 749 | return $this->get($newEvent->id); |
| 750 | } catch (\Exception $e) { |
| 751 | $this->db->rollBack(); |
| 752 | throw $e; |
| 753 | } |
| 754 | } |
| 755 | |
| 756 | // ========================================================================= |
| 757 | // CONFLICT CHECKING |
| 758 | // ========================================================================= |
| 759 | |
| 760 | /** |
| 761 | * Check for conflicts before create/update |
| 762 | * |
| 763 | * @param array $data Event data to check |
| 764 | * @param int|null $excludeEventId Event ID to exclude from conflict check (for updates) |
| 765 | * @return array ['hasConflicts' => bool, 'conflicts' => [], 'warnings' => []] |
| 766 | */ |
| 767 | public function checkConflicts(array $data, ?int $excludeEventId = null): array |
| 768 | { |
| 769 | $conflicts = []; |
| 770 | $warnings = []; |
| 771 | |
| 772 | // Only check conflicts if we have dates |
| 773 | if (empty($data['startDate']) || empty($data['endDate'])) { |
| 774 | return [ |
| 775 | 'hasConflicts' => false, |
| 776 | 'conflicts' => [], |
| 777 | 'warnings' => ['No date range specified; cannot check for conflicts'], |
| 778 | ]; |
| 779 | } |
| 780 | |
| 781 | $startDate = $data['startDate']; |
| 782 | $endDate = $data['endDate']; |
| 783 | $eventType = $data['eventType'] ?? Event::TYPE_CUSTOM; |
| 784 | |
| 785 | // Check for overlapping events of the same type |
| 786 | $sql = "SELECT id, name, startDate, endDate, status |
| 787 | FROM events |
| 788 | WHERE eventType = :eventType |
| 789 | AND status NOT IN ('cancelled', 'archived') |
| 790 | AND ( |
| 791 | (startDate <= :endDate AND endDate >= :startDate) |
| 792 | )"; |
| 793 | $params = [ |
| 794 | ':eventType' => $eventType, |
| 795 | ':startDate' => $startDate, |
| 796 | ':endDate' => $endDate, |
| 797 | ]; |
| 798 | |
| 799 | if ($excludeEventId !== null) { |
| 800 | $sql .= " AND id != :excludeId"; |
| 801 | $params[':excludeId'] = $excludeEventId; |
| 802 | } |
| 803 | |
| 804 | $stmt = $this->db->prepare($sql); |
| 805 | $stmt->execute($params); |
| 806 | |
| 807 | while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { |
| 808 | if ($row['status'] === Event::STATUS_ACTIVE) { |
| 809 | $conflicts[] = "Overlaps with active event '{$row['name']}' ({$row['startDate']} - {$row['endDate']})"; |
| 810 | } else { |
| 811 | $warnings[] = "Overlaps with {$row['status']} event '{$row['name']}' ({$row['startDate']} - {$row['endDate']})"; |
| 812 | } |
| 813 | } |
| 814 | |
| 815 | return [ |
| 816 | 'hasConflicts' => !empty($conflicts), |
| 817 | 'conflicts' => $conflicts, |
| 818 | 'warnings' => $warnings, |
| 819 | ]; |
| 820 | } |
| 821 | |
| 822 | // ========================================================================= |
| 823 | // PRIVATE HELPER METHODS |
| 824 | // ========================================================================= |
| 825 | |
| 826 | /** |
| 827 | * Log an audit entry for event changes |
| 828 | * |
| 829 | * @param int $eventId Event ID |
| 830 | * @param string $action Action performed |
| 831 | * @param array|null $details Additional details |
| 832 | */ |
| 833 | private function logAudit(int $eventId, string $action, ?array $details = null): void |
| 834 | { |
| 835 | try { |
| 836 | $sql = "INSERT INTO event_audit_log (event_id, action, details, employee_id, created_at) |
| 837 | VALUES (:eventId, :action, :details, :employeeId, NOW())"; |
| 838 | $stmt = $this->db->prepare($sql); |
| 839 | $stmt->execute([ |
| 840 | ':eventId' => $eventId, |
| 841 | ':action' => $action, |
| 842 | ':details' => $details ? json_encode($details) : null, |
| 843 | ':employeeId' => $this->employeeId, |
| 844 | ]); |
| 845 | } catch (PDOException $e) { |
| 846 | // Log failure should not break the main operation |
| 847 | error_log("EventService: Failed to log audit entry: " . $e->getMessage()); |
| 848 | } |
| 849 | } |
| 850 | |
| 851 | /** |
| 852 | * Validate a status transition |
| 853 | * |
| 854 | * @param Event $event Current event |
| 855 | * @param string $newStatus Target status |
| 856 | * @throws InvalidArgumentException If transition is not allowed |
| 857 | */ |
| 858 | private function validateTransition(Event $event, string $newStatus): void |
| 859 | { |
| 860 | $allowedTransitions = self::STATUS_TRANSITIONS[$event->status] ?? []; |
| 861 | |
| 862 | if (!in_array($newStatus, $allowedTransitions, true)) { |
| 863 | throw new InvalidArgumentException( |
| 864 | "Cannot transition from '{$event->status}' to '{$newStatus}'" |
| 865 | ); |
| 866 | } |
| 867 | } |
| 868 | |
| 869 | /** |
| 870 | * Recalculate and update event phase based on current dates |
| 871 | * |
| 872 | * @param Event $event Event to update |
| 873 | */ |
| 874 | private function recalculatePhase(Event $event): void |
| 875 | { |
| 876 | $newPhase = $event->calculatePhase(); |
| 877 | |
| 878 | if ($newPhase !== $event->phase) { |
| 879 | $sql = "UPDATE events SET phase = :phase, updated_at = NOW() WHERE id = :id"; |
| 880 | $stmt = $this->db->prepare($sql); |
| 881 | $stmt->execute([ |
| 882 | ':id' => $event->id, |
| 883 | ':phase' => $newPhase, |
| 884 | ]); |
| 885 | } |
| 886 | } |
| 887 | |
| 888 | /** |
| 889 | * Hydrate an Event object from input data array |
| 890 | * |
| 891 | * @param array $data Input data with camelCase keys |
| 892 | * @return Event Hydrated event object |
| 893 | */ |
| 894 | private function hydrateEventFromData(array $data): Event |
| 895 | { |
| 896 | $event = new Event(); |
| 897 | |
| 898 | $event->templateId = isset($data['templateId']) ? (int) $data['templateId'] : null; |
| 899 | $event->sourceEventId = isset($data['sourceEventId']) ? (int) $data['sourceEventId'] : null; |
| 900 | $event->name = $data['name'] ?? ''; |
| 901 | $event->description = $data['description'] ?? null; |
| 902 | $event->eventType = $data['eventType'] ?? Event::TYPE_CUSTOM; |
| 903 | $event->year = (int) ($data['year'] ?? date('Y')); |
| 904 | $event->startDate = $this->parseDateTime($data['startDate'] ?? null); |
| 905 | $event->endDate = $this->parseDateTime($data['endDate'] ?? null); |
| 906 | $event->buildUpDays = (int) ($data['buildUpDays'] ?? 14); |
| 907 | $event->windDownDays = (int) ($data['windDownDays'] ?? 7); |
| 908 | $event->status = $data['status'] ?? Event::STATUS_DRAFT; |
| 909 | $event->previousStatus = $data['previousStatus'] ?? null; |
| 910 | $event->phase = $event->calculatePhase(); |
| 911 | $event->color = $data['color'] ?? null; |
| 912 | $event->icon = $data['icon'] ?? null; |
| 913 | $event->isRecurring = (bool) ($data['isRecurring'] ?? false); |
| 914 | $event->createdBy = isset($data['createdBy']) ? (int) $data['createdBy'] : null; |
| 915 | |
| 916 | return $event; |
| 917 | } |
| 918 | |
| 919 | /** |
| 920 | * Merge existing event data with update data |
| 921 | * |
| 922 | * @param Event $existing Existing event |
| 923 | * @param array $updates Update data |
| 924 | * @return array Merged data array |
| 925 | */ |
| 926 | private function mergeEventData(Event $existing, array $updates): array |
| 927 | { |
| 928 | $existingArray = [ |
| 929 | 'templateId' => $existing->templateId, |
| 930 | 'sourceEventId' => $existing->sourceEventId, |
| 931 | 'name' => $existing->name, |
| 932 | 'description' => $existing->description, |
| 933 | 'eventType' => $existing->eventType, |
| 934 | 'year' => $existing->year, |
| 935 | 'startDate' => $existing->startDate?->format('Y-m-d H:i:s'), |
| 936 | 'endDate' => $existing->endDate?->format('Y-m-d H:i:s'), |
| 937 | 'buildUpDays' => $existing->buildUpDays, |
| 938 | 'windDownDays' => $existing->windDownDays, |
| 939 | 'status' => $existing->status, |
| 940 | 'previousStatus' => $existing->previousStatus, |
| 941 | 'phase' => $existing->phase, |
| 942 | 'color' => $existing->color, |
| 943 | 'icon' => $existing->icon, |
| 944 | 'isRecurring' => $existing->isRecurring, |
| 945 | 'createdBy' => $existing->createdBy, |
| 946 | ]; |
| 947 | |
| 948 | return array_merge($existingArray, $updates); |
| 949 | } |
| 950 | |
| 951 | /** |
| 952 | * Parse a datetime string into a DateTime object |
| 953 | * |
| 954 | * @param string|null $value Datetime string or null |
| 955 | * @return DateTime|null Parsed DateTime or null |
| 956 | */ |
| 957 | private function parseDateTime(?string $value): ?DateTime |
| 958 | { |
| 959 | if ($value === null || $value === '' || $value === '0000-00-00 00:00:00') { |
| 960 | return null; |
| 961 | } |
| 962 | |
| 963 | try { |
| 964 | return new DateTime($value); |
| 965 | } catch (\Exception $e) { |
| 966 | return null; |
| 967 | } |
| 968 | } |
| 969 | |
| 970 | /** |
| 971 | * Convert camelCase to snake_case |
| 972 | * |
| 973 | * @param string $input camelCase string |
| 974 | * @return string snake_case string |
| 975 | */ |
| 976 | private function camelToSnake(string $input): string |
| 977 | { |
| 978 | return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $input)); |
| 979 | } |
| 980 | |
| 981 | /** |
| 982 | * Format a value for database storage |
| 983 | * |
| 984 | * @param string $field Field name |
| 985 | * @param mixed $value Value to format |
| 986 | * @return mixed Formatted value |
| 987 | */ |
| 988 | private function formatValueForDb(string $field, $value) |
| 989 | { |
| 990 | // Handle DateTime fields |
| 991 | $dateFields = ['startDate', 'endDate', 'archivedAt']; |
| 992 | if (in_array($field, $dateFields)) { |
| 993 | if ($value instanceof DateTime) { |
| 994 | return $value->format('Y-m-d H:i:s'); |
| 995 | } |
| 996 | return $value; |
| 997 | } |
| 998 | |
| 999 | // Handle boolean fields |
| 1000 | $boolFields = ['isRecurring']; |
| 1001 | if (in_array($field, $boolFields)) { |
| 1002 | return $value ? 1 : 0; |
| 1003 | } |
| 1004 | |
| 1005 | return $value; |
| 1006 | } |
| 1007 | } |