Skip to content

Commit ffb0da8

Browse files
feat: add a new event for many-to-many relationships
1 parent a87252f commit ffb0da8

5 files changed

Lines changed: 398 additions & 1 deletion

File tree

app/Audit/AuditEventListener.php

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
use App\Audit\Interfaces\IAuditStrategy;
1616
use Doctrine\ORM\Event\OnFlushEventArgs;
17+
use Doctrine\ORM\Mapping\ClassMetadata;
18+
use Doctrine\ORM\PersistentCollection;
1719
use Illuminate\Support\Facades\App;
1820
use Illuminate\Support\Facades\Log;
1921
use Illuminate\Support\Facades\Route;
@@ -55,7 +57,11 @@ public function onFlush(OnFlushEventArgs $eventArgs): void
5557
}
5658

5759
foreach ($uow->getScheduledCollectionUpdates() as $col) {
58-
$strategy->audit($col, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx);
60+
$this->auditManyToManyCollection($col, $strategy, $ctx, false);
61+
}
62+
63+
foreach ($uow->getScheduledCollectionDeletions() as $col) {
64+
$this->auditManyToManyCollection($col, $strategy, $ctx, true);
5965
}
6066
} catch (\Exception $e) {
6167
Log::error('Audit event listener failed', [
@@ -127,4 +133,69 @@ private function buildAuditContext(): AuditContext
127133
rawRoute: $rawRoute
128134
);
129135
}
136+
137+
138+
private function auditManyToManyCollection(PersistentCollection $col, IAuditStrategy $strategy, AuditContext $ctx, bool $isDeletion = false): void
139+
{
140+
if (!$col instanceof PersistentCollection) {
141+
return;
142+
}
143+
144+
$mapping = $col->getMapping();
145+
146+
if (($mapping['type'] ?? null) !== ClassMetadata::MANY_TO_MANY) {
147+
return;
148+
}
149+
150+
if (empty($mapping['isOwningSide'])) {
151+
return;
152+
}
153+
154+
$owner = $col->getOwner();
155+
if ($owner === null) {
156+
return;
157+
}
158+
159+
$addedEntities = $col->getInsertDiff();
160+
$removedEntities = $col->getDeleteDiff();
161+
162+
$addedIds = $this->extractEntityIds($addedEntities);
163+
$removedIds = $this->extractEntityIds($removedEntities);
164+
165+
if (empty($addedIds) && empty($removedIds)) {
166+
return;
167+
}
168+
169+
$payload = [
170+
'field' => $mapping['fieldName'] ?? 'unknown',
171+
'join_table' => $mapping['joinTable']['name'] ?? null,
172+
'target_entity' => $mapping['targetEntity'] ?? null,
173+
'is_deletion' => $isDeletion,
174+
'added_ids' => $addedIds,
175+
'removed_ids' => $removedIds,
176+
'owner_class' => get_class($owner),
177+
'owner_id' => method_exists($owner, 'getId') ? $owner->getId() : null,
178+
];
179+
180+
$strategy->audit($owner, $payload, IAuditStrategy::EVENT_MANYTOMANY_ASSOCIATION_UPDATE, $ctx);
181+
}
182+
183+
184+
private function extractEntityIds(array $entities): array
185+
{
186+
$ids = [];
187+
foreach ($entities as $entity) {
188+
if (method_exists($entity, 'getId')) {
189+
$id = $entity->getId();
190+
if ($id !== null) {
191+
$ids[] = $id;
192+
}
193+
}
194+
}
195+
196+
$uniqueIds = array_unique($ids);
197+
sort($uniqueIds);
198+
199+
return array_values($uniqueIds);
200+
}
130201
}

app/Audit/AuditLogFormatterFactory.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo
107107

108108
$formatter = new EntityCollectionUpdateAuditLogFormatter($child_entity_formatter);
109109
break;
110+
case IAuditStrategy::EVENT_MANYTOMANY_ASSOCIATION_UPDATE:
111+
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
112+
if(is_null($formatter)) {
113+
$child_entity_formatter = ChildEntityFormatterFactory::build($subject);
114+
$formatter = $child_entity_formatter;
115+
}
116+
break;
110117
case IAuditStrategy::EVENT_ENTITY_CREATION:
111118
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
112119
if(is_null($formatter)) {

app/Audit/ConcreteFormatters/SummitAttendeeAuditLogFormatter.php

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,117 @@ public function format($subject, array $change_set): ?string
4242

4343
case IAuditStrategy::EVENT_ENTITY_DELETION:
4444
return sprintf("Attendee (%s) '%s' deleted by user %s", $id, $name, $this->getUserInfo());
45+
46+
case IAuditStrategy::EVENT_COLLECTION_UPDATE:
47+
return $this->formatCollectionUpdate($subject, $change_set, $id, $name);
48+
49+
case IAuditStrategy::EVENT_MANYTOMANY_ASSOCIATION_UPDATE:
50+
$is_deletion = $change_set['is_deletion'] ?? false;
51+
if ($is_deletion) {
52+
return $this->formatManyToManyDelete($subject, $change_set, $id, $name);
53+
} else {
54+
return $this->formatManyToManyUpdate($subject, $change_set, $id, $name);
55+
}
4556
}
4657
} catch (\Exception $ex) {
4758
Log::warning("SummitAttendeeAuditLogFormatter error: " . $ex->getMessage());
4859
}
4960

5061
return null;
5162
}
63+
64+
private function formatManyToManyUpdate(SummitAttendee $subject, array $change_set, $id, $name): ?string
65+
{
66+
try {
67+
$field = $change_set['field'] ?? 'unknown';
68+
$targetEntity = $change_set['target_entity'] ?? 'unknown';
69+
$added_ids = $change_set['added_ids'] ?? [];
70+
71+
$ownerId = $subject->getId();
72+
73+
$description = sprintf(
74+
" Attendee (%s), Field: %s, Target: %s, Added IDs: %s, by user %s",
75+
$ownerId,
76+
$field,
77+
class_basename($targetEntity),
78+
json_encode($added_ids),
79+
$this->getUserInfo()
80+
);
81+
82+
return $description;
83+
84+
} catch (\Exception $ex) {
85+
Log::warning("SummitAttendeeAuditLogFormatter::formatManyToManyUpdate error: " . $ex->getMessage());
86+
return sprintf("Attendee (%s) '%s' association updated by user %s", $id, $name, $this->getUserInfo());
87+
}
88+
}
89+
90+
private function formatManyToManyDelete(SummitAttendee $subject, array $change_set, $id, $name): ?string
91+
{
92+
try {
93+
$field = $change_set['field'] ?? 'unknown';
94+
$targetEntity = $change_set['target_entity'] ?? 'unknown';
95+
$removed_ids = $change_set['removed_ids'] ?? $change_set['added_ids'] ?? [];
96+
97+
$description = sprintf(
98+
"Attendee Delete: Field: %s, Target: %s, Cleared IDs: %s, by user %s",
99+
$field,
100+
class_basename($targetEntity),
101+
json_encode($removed_ids),
102+
$this->getUserInfo()
103+
);
104+
105+
return $description;
106+
107+
} catch (\Exception $ex) {
108+
Log::warning("SummitAttendeeAuditLogFormatter::formatManyToManyDelete error: " . $ex->getMessage());
109+
return sprintf("Attendee (%s) '%s' association deleted by user %s", $id, $name, $this->getUserInfo());
110+
}
111+
}
112+
113+
114+
private function formatCollectionUpdate(SummitAttendee $subject, array $change_set, $id, $name): ?string
115+
{
116+
try {
117+
$details = [];
118+
119+
if (!empty($change_set['tags'])) {
120+
$tags_info = $change_set['tags'];
121+
122+
$added = $tags_info['added'] ?? [];
123+
$removed = $tags_info['removed'] ?? [];
124+
125+
$added_names = array_map(function($tag) {
126+
return $tag->getTag() ?? $tag->getId();
127+
}, $added);
128+
129+
$removed_names = array_map(function($tag) {
130+
return $tag->getTag() ?? $tag->getId();
131+
}, $removed);
132+
133+
if (!empty($added_names)) {
134+
$details[] = "added tags: [" . implode(", ", $added_names) . "]";
135+
}
136+
if (!empty($removed_names)) {
137+
$details[] = "removed tags: [" . implode(", ", $removed_names) . "]";
138+
}
139+
}
140+
141+
if (empty($details)) {
142+
return sprintf("Attendee (%s) '%s' collection updated by user %s", $id, $name, $this->getUserInfo());
143+
}
144+
145+
return sprintf(
146+
"Attendee (%s) '%s' tags updated: %s by user %s",
147+
$id,
148+
$name,
149+
implode(", ", $details),
150+
$this->getUserInfo()
151+
);
152+
153+
} catch (\Exception $ex) {
154+
Log::warning("SummitAttendeeAuditLogFormatter::formatCollectionUpdate error: " . $ex->getMessage());
155+
return sprintf("Attendee (%s) '%s' collection updated by user %s", $id, $name, $this->getUserInfo());
156+
}
157+
}
52158
}

app/Audit/Interfaces/IAuditStrategy.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public function audit($subject, array $change_set, string $event_type, AuditCont
3333
public const EVENT_ENTITY_CREATION = 'event_entity_creation';
3434
public const EVENT_ENTITY_DELETION = 'event_entity_deletion';
3535
public const EVENT_ENTITY_UPDATE = 'event_entity_update';
36+
public const EVENT_MANYTOMANY_ASSOCIATION_UPDATE = 'event_manytomany_association_update';
3637

3738
public const ACTION_CREATE = 'create';
3839
public const ACTION_UPDATE = 'update';

0 commit comments

Comments
 (0)