Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.13% covered (success)
92.13%
117 / 127
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
DavCalendarSyncService
92.06% covered (success)
92.06%
116 / 126
57.14% covered (warning)
57.14%
4 / 7
20.20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pushEvent
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
5
 pullUserCalendar
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
5.50
 syncRemoteResource
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
5.33
 findCalendarRecord
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 updateCalendarRecord
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 insertCalendarRecord
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Dav\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Dav\Domain\Model\DavAccount;
12use App\Modules\Dav\Domain\Model\DavResource;
13use App\Modules\Dav\Domain\Model\DavSyncResult;
14use App\Modules\Dav\Domain\Repository\DavClientInterface;
15use App\Modules\Dav\Infrastructure\Converter\ICalendarConverter;
16use Exception;
17use PDO;
18
19/**
20 * Calendar DAV Synchronization Application Service.
21 *
22 * Coordinates CalDAV push and pull operations with SOGo server.
23 *
24 * @package App\Modules\Dav\Application\Service
25 */
26final readonly class DavCalendarSyncService
27{
28    /**
29     * DavCalendarSyncService constructor.
30     *
31     * @param PDO $pdo PDO database connection.
32     * @param DavClientInterface $davClient HTTP DAV transport client.
33     * @param ICalendarConverter $converter iCalendar RFC 5545 converter.
34     * @param string $tablePrefix Optional database table prefix.
35     */
36    public function __construct(
37        private PDO $pdo,
38        private DavClientInterface $davClient,
39        private ICalendarConverter $converter,
40        private string $tablePrefix = 'a_'
41    ) {
42    }
43
44    /**
45     * Pushes a local calendar event change to the remote CalDAV collection.
46     *
47     * @param int $calendarId Record ID in c_mod_calendar_records.
48     * @param string $action Action type: create, update, delete.
49     * @param DavAccount $account Authenticated user DAV profile.
50     * @param array<string, mixed> $snapshot Optional record snapshot.
51     * @return void
52     */
53    public function pushEvent(
54        int $calendarId,
55        string $action,
56        DavAccount $account,
57        array $snapshot = []
58    ): void {
59        if ($action === 'delete') {
60            $uid = (string) ($snapshot['c_uid'] ?? '');
61            if ($uid !== '') {
62                $resourceUrl = sprintf('%s%s.ics', $account->getCalendarUrl(), $uid);
63                $this->davClient->deleteResource($account, $resourceUrl);
64            }
65            return;
66        }
67
68        $record = $this->findCalendarRecord($calendarId);
69        if ($record === null) {
70            return;
71        }
72
73        $uid = (string) ($record['c_uid'] ?? '');
74        if ($uid === '') {
75            $uid = sprintf('%s@ammonly.com', bin2hex(random_bytes(16)));
76            $record['c_uid'] = $uid;
77        }
78
79        $sequence = ((int) ($record['sequence'] ?? 0)) + 1;
80        $record['sequence'] = $sequence;
81
82        $icsContent = $this->converter->toIcs($record);
83        $etag = (string) ($record['c_etag'] ?? '');
84        $resource = new DavResource($uid, $etag, $icsContent, 'text/calendar; charset=utf-8');
85        $resourceUrl = sprintf('%s%s.ics', $account->getCalendarUrl(), $uid);
86
87        $newEtag = $this->davClient->putResource($account, $resourceUrl, $resource);
88
89        $table = $this->tablePrefix . 'mod_calendar_records';
90        $sql = sprintf(
91            'UPDATE `%s` SET `c_uid` = :uid, `c_etag` = :etag, `sequence` = :seq WHERE `id` = :id',
92            $table
93        );
94        $stmt = $this->pdo->prepare($sql);
95        $stmt->execute([
96            ':uid' => $uid,
97            ':etag' => $newEtag,
98            ':seq' => $sequence,
99            ':id' => $calendarId,
100        ]);
101    }
102
103    /**
104     * Pulls remote calendar changes from the user's CalDAV personal calendar.
105     *
106     * @param DavAccount $account Authenticated user DAV profile.
107     * @return DavSyncResult Summary of synchronization metrics.
108     */
109    public function pullUserCalendar(DavAccount $account): DavSyncResult
110    {
111        $result = new DavSyncResult();
112        $table = $this->tablePrefix . 'mod_calendar_records';
113
114        try {
115            $resources = $this->davClient->listResources($account, $account->getCalendarUrl(), 'calendar');
116        } catch (Exception $e) {
117            $result->addError('Failed to list CalDAV resources: ' . $e->getMessage());
118            return $result;
119        }
120
121        foreach ($resources as $resource) {
122            try {
123                $this->syncRemoteResource($account, $resource, $table, $result);
124            } catch (Exception $e) {
125                $result->addError(sprintf('Error syncing event %s: %s', $resource->uid, $e->getMessage()));
126            }
127        }
128
129        return $result;
130    }
131
132    /**
133     * Synchronizes an individual remote calendar resource with local database.
134     *
135     * @param DavAccount $account Authenticated user profile.
136     * @param DavResource $resource Remote DAV resource.
137     * @param string $table Database table name.
138     * @param DavSyncResult $result Accumulator for metrics.
139     * @return void
140     */
141    private function syncRemoteResource(
142        DavAccount $account,
143        DavResource $resource,
144        string $table,
145        DavSyncResult $result
146    ): void {
147        $stmt = $this->pdo->prepare(
148            sprintf('SELECT `id`, `c_etag` FROM `%s` WHERE `c_uid` = :uid LIMIT 1', $table)
149        );
150        $stmt->execute([':uid' => $resource->uid]);
151        $existing = $stmt->fetch(PDO::FETCH_ASSOC);
152
153        if ($existing !== false && (string) ($existing['c_etag'] ?? '') === $resource->etag) {
154            $result->skippedCount++;
155            return;
156        }
157
158        $mapped = $this->converter->toRecord($resource->content, $resource->etag);
159        if (empty($mapped)) {
160            $result->skippedCount++;
161            return;
162        }
163
164        if ($existing !== false) {
165            $this->updateCalendarRecord($table, (int) $existing['id'], $mapped);
166            $result->updatedCount++;
167        } else {
168            $this->insertCalendarRecord($table, $account->userId, $mapped);
169            $result->createdCount++;
170        }
171    }
172
173    /**
174     * Fetches a calendar record from database by ID.
175     *
176     * @param int $calendarId Record ID.
177     * @return array<string, mixed>|null Record array or null.
178     */
179    private function findCalendarRecord(int $calendarId): ?array
180    {
181        $table = $this->tablePrefix . 'mod_calendar_records';
182        $stmt = $this->pdo->prepare(sprintf(
183            'SELECT `id`, `subject`, `description`, `location`, `meeting_url`, `event_type`, `priority`, '
184            . '`reminder_minutes`, `is_all_day`, `status`, `start_date`, `end_date`, `c_etag`, `c_uid`, '
185            . '`sequence`, `c_isopaque`, `attendees` FROM `%s` WHERE `id` = :id LIMIT 1',
186            $table
187        ));
188        $stmt->execute([':id' => $calendarId]);
189        $row = $stmt->fetch(PDO::FETCH_ASSOC);
190        return is_array($row) ? $row : null;
191    }
192
193    /**
194     * Updates an existing calendar database row.
195     *
196     * @param string $table Table name.
197     * @param int $id Record ID.
198     * @param array<string, mixed> $data Data to update.
199     * @return void
200     */
201    private function updateCalendarRecord(string $table, int $id, array $data): void
202    {
203        $sql = sprintf(
204            'UPDATE `%s` SET `subject` = :subject, `description` = :desc, `location` = :loc, '
205            . '`meeting_url` = :murl, `event_type` = :etype, `priority` = :prio, '
206            . '`reminder_minutes` = :rmins, `is_all_day` = :allday, `start_date` = :sdate, '
207            . '`end_date` = :edate, `c_etag` = :etag, `sequence` = :seq, `c_isopaque` = :opaque, '
208            . '`attendees` = :attendees WHERE `id` = :id',
209            $table
210        );
211        $stmt = $this->pdo->prepare($sql);
212        $stmt->execute([
213            ':subject' => $data['subject'] ?? '',
214            ':desc' => $data['description'] ?? '',
215            ':loc' => $data['location'] ?? '',
216            ':murl' => $data['meeting_url'] ?? '',
217            ':etype' => $data['event_type'] ?? 'meeting',
218            ':prio' => $data['priority'] ?? 'normal',
219            ':rmins' => $data['reminder_minutes'] ?? 15,
220            ':allday' => $data['is_all_day'] ?? 0,
221            ':sdate' => $data['start_date'] ?? null,
222            ':edate' => $data['end_date'] ?? null,
223            ':etag' => $data['c_etag'] ?? '',
224            ':seq' => $data['sequence'] ?? 0,
225            ':opaque' => $data['c_isopaque'] ?? 1,
226            ':attendees' => $data['attendees'] ?? null,
227            ':id' => $id,
228        ]);
229    }
230
231    /**
232     * Inserts a new calendar record into the database.
233     *
234     * @param string $table Table name.
235     * @param int $userId Owner and creator user ID.
236     * @param array<string, mixed> $data Mapped calendar columns.
237     * @return void
238     */
239    private function insertCalendarRecord(string $table, int $userId, array $data): void
240    {
241        $sql = sprintf(
242            'INSERT INTO `%s` (`c_uid`, `c_etag`, `sequence`, `c_isopaque`, `is_all_day`, `subject`, '
243            . '`description`, `location`, `meeting_url`, `event_type`, `priority`, `reminder_minutes`, '
244            . '`start_date`, `end_date`, `attendees`, `created_by`, `owner`) '
245            . 'VALUES (:uid, :etag, :seq, :opaque, :allday, :subject, :desc, :loc, :murl, :etype, :prio, '
246            . ':rmins, :sdate, :edate, :attendees, :c_by, :c_owner)',
247            $table
248        );
249        $stmt = $this->pdo->prepare($sql);
250        $ownerId = $userId > 0 ? $userId : 1;
251        $stmt->execute([
252            ':uid' => $data['c_uid'],
253            ':etag' => $data['c_etag'],
254            ':seq' => $data['sequence'] ?? 0,
255            ':opaque' => $data['c_isopaque'] ?? 1,
256            ':allday' => $data['is_all_day'] ?? 0,
257            ':subject' => $data['subject'],
258            ':desc' => $data['description'] ?? '',
259            ':loc' => $data['location'] ?? '',
260            ':murl' => $data['meeting_url'] ?? '',
261            ':etype' => $data['event_type'] ?? 'meeting',
262            ':prio' => $data['priority'] ?? 'normal',
263            ':rmins' => $data['reminder_minutes'] ?? 15,
264            ':sdate' => $data['start_date'],
265            ':edate' => $data['end_date'],
266            ':attendees' => $data['attendees'] ?? null,
267            ':c_by' => $ownerId,
268            ':c_owner' => $ownerId,
269        ]);
270    }
271}