Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.52% covered (success)
92.52%
136 / 147
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
DavContactSyncService
92.47% covered (success)
92.47%
135 / 146
55.56% covered (warning)
55.56%
5 / 9
26.29
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
 pushContact
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
5
 pullUserContacts
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
5.50
 syncRemoteContact
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
5.47
 findExistingContact
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 matchContactByEmail
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 findContactRecord
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 updateContactRecord
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
1
 insertContactRecord
100.00% covered (success)
100.00%
35 / 35
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\VCardConverter;
16use Exception;
17use PDO;
18
19/**
20 * Contacts CardDAV Synchronization Application Service.
21 *
22 * Coordinates CardDAV push and pull operations with SOGo server,
23 * including intelligent deduplication and record matching.
24 *
25 * @package App\Modules\Dav\Application\Service
26 */
27final readonly class DavContactSyncService
28{
29    /**
30     * DavContactSyncService constructor.
31     *
32     * @param PDO $pdo PDO database connection.
33     * @param DavClientInterface $davClient HTTP DAV transport client.
34     * @param VCardConverter $converter vCard RFC 6350 converter.
35     * @param string $tablePrefix Optional database table prefix.
36     */
37    public function __construct(
38        private PDO $pdo,
39        private DavClientInterface $davClient,
40        private VCardConverter $converter,
41        private string $tablePrefix = 'a_'
42    ) {
43    }
44
45    /**
46     * Pushes a local contact change to the remote CardDAV address book collection.
47     *
48     * @param int $contactId Record ID in c_mod_contacts_records.
49     * @param string $action Action type: create, update, delete.
50     * @param DavAccount $account Authenticated user DAV profile.
51     * @param array<string, mixed> $snapshot Optional record snapshot.
52     * @return void
53     */
54    public function pushContact(
55        int $contactId,
56        string $action,
57        DavAccount $account,
58        array $snapshot = []
59    ): void {
60        if ($action === 'delete') {
61            $uid = (string) ($snapshot['c_name'] ?? '');
62            if ($uid !== '') {
63                $resourceUrl = sprintf('%s%s.vcf', $account->getAddressBookUrl(), $uid);
64                $this->davClient->deleteResource($account, $resourceUrl);
65            }
66            return;
67        }
68
69        $record = $this->findContactRecord($contactId);
70        if ($record === null) {
71            return;
72        }
73
74        $uid = (string) ($record['c_name'] ?? '');
75        if ($uid === '') {
76            $uid = sprintf('urn:uuid:%s', bin2hex(random_bytes(16)));
77            $record['c_name'] = $uid;
78        }
79
80        $vcfContent = $this->converter->toVcf($record);
81        $resource = new DavResource($uid, (string) ($record['c_etag'] ?? ''), $vcfContent, 'text/vcard; charset=utf-8');
82        $resourceUrl = sprintf('%s%s.vcf', $account->getAddressBookUrl(), $uid);
83
84        $newEtag = $this->davClient->putResource($account, $resourceUrl, $resource);
85
86        $table = $this->tablePrefix . 'mod_contacts_records';
87        $sql = sprintf('UPDATE `%s` SET `c_name` = :c_name, `c_etag` = :etag WHERE `id` = :id', $table);
88        $stmt = $this->pdo->prepare($sql);
89        $stmt->execute([
90            ':c_name' => $uid,
91            ':etag' => $newEtag,
92            ':id' => $contactId,
93        ]);
94    }
95
96    /**
97     * Pulls remote contacts from the user's CardDAV personal address book.
98     *
99     * @param DavAccount $account Authenticated user DAV profile.
100     * @return DavSyncResult Summary of synchronization metrics.
101     */
102    public function pullUserContacts(DavAccount $account): DavSyncResult
103    {
104        $result = new DavSyncResult();
105        $table = $this->tablePrefix . 'mod_contacts_records';
106
107        try {
108            $resources = $this->davClient->listResources($account, $account->getAddressBookUrl(), 'addressbook');
109        } catch (Exception $e) {
110            $result->addError('Failed to list CardDAV resources: ' . $e->getMessage());
111            return $result;
112        }
113
114        foreach ($resources as $resource) {
115            try {
116                $this->syncRemoteContact($account, $resource, $table, $result);
117            } catch (Exception $e) {
118                $result->addError(sprintf('Error syncing contact %s: %s', $resource->uid, $e->getMessage()));
119            }
120        }
121
122        return $result;
123    }
124
125    /**
126     * Synchronizes a single remote vCard with the local contact repository.
127     *
128     * @param DavAccount $account Authenticated user profile.
129     * @param DavResource $resource Remote CardDAV resource.
130     * @param string $table Database table name.
131     * @param DavSyncResult $result Accumulator for metrics.
132     * @return void
133     */
134    private function syncRemoteContact(
135        DavAccount $account,
136        DavResource $resource,
137        string $table,
138        DavSyncResult $result
139    ): void {
140        $existing = $this->findExistingContact($table, $resource->uid);
141
142        if ($existing !== null && (string) ($existing['c_etag'] ?? '') === $resource->etag) {
143            $result->skippedCount++;
144            return;
145        }
146
147        $mapped = $this->converter->toRecord($resource->content, $resource->etag);
148        if (empty($mapped)) {
149            $result->skippedCount++;
150            return;
151        }
152
153        $targetExisting = $existing ?? $this->matchContactByEmail($table, (string) ($mapped['email'] ?? ''));
154        if ($targetExisting !== null) {
155            $this->updateContactRecord($table, (int) $targetExisting['id'], $mapped);
156            $result->updatedCount++;
157            return;
158        }
159
160        $this->insertContactRecord($table, $account->userId, $mapped);
161        $result->createdCount++;
162    }
163
164    /**
165     * Finds contact by UID (c_name).
166     *
167     * @param string $table Table name.
168     * @param string $uid UID string.
169     * @return array<string, mixed>|null Contact row or null.
170     */
171    private function findExistingContact(string $table, string $uid): ?array
172    {
173        $stmt = $this->pdo->prepare(
174            sprintf('SELECT `id`, `c_etag` FROM `%s` WHERE `c_name` = :uid LIMIT 1', $table)
175        );
176        $stmt->execute([':uid' => $uid]);
177        $row = $stmt->fetch(PDO::FETCH_ASSOC);
178        return is_array($row) ? $row : null;
179    }
180
181    /**
182     * Finds contact by email for deduplication.
183     *
184     * @param string $table Table name.
185     * @param string $email Email address.
186     * @return array<string, mixed>|null Contact row or null.
187     */
188    private function matchContactByEmail(string $table, string $email): ?array
189    {
190        if (trim($email) === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
191            return null;
192        }
193
194        $stmt = $this->pdo->prepare(
195            sprintf('SELECT `id`, `c_name`, `c_etag` FROM `%s` WHERE `email` = :email LIMIT 1', $table)
196        );
197        $stmt->execute([':email' => $email]);
198        $row = $stmt->fetch(PDO::FETCH_ASSOC);
199        return is_array($row) ? $row : null;
200    }
201
202    /**
203     * Finds contact record by ID.
204     *
205     * @param int $contactId Contact record ID.
206     * @return array<string, mixed>|null Record array or null.
207     */
208    private function findContactRecord(int $contactId): ?array
209    {
210        $table = $this->tablePrefix . 'mod_contacts_records';
211        $stmt = $this->pdo->prepare(sprintf(
212            'SELECT `id`, `c_name`, `c_etag`, `first_name`, `last_name`, `formatted_name`, `job_title`, '
213            . '`email`, `secondary_email`, `phone`, `secondary_phone`, `website`, `c_o`, `birthday`, '
214            . '`address_street`, `address_building_number`, `address_apartment_number`, `address_postal_code`, '
215            . '`address_city`, `address_country`, `address_latitude`, `address_longitude`, `description` '
216            . 'FROM `%s` WHERE `id` = :id LIMIT 1',
217            $table
218        ));
219        $stmt->execute([':id' => $contactId]);
220        $row = $stmt->fetch(PDO::FETCH_ASSOC);
221        return is_array($row) ? $row : null;
222    }
223
224    /**
225     * Updates an existing contact record.
226     *
227     * @param string $table Table name.
228     * @param int $id Record ID.
229     * @param array<string, mixed> $data Mapped columns.
230     * @return void
231     */
232    private function updateContactRecord(string $table, int $id, array $data): void
233    {
234        $sql = sprintf(
235            'UPDATE `%s` SET `c_name` = :c_name, `c_etag` = :etag, `first_name` = :fname, '
236            . '`last_name` = :lname, `formatted_name` = :fn, `job_title` = :jtitle, `email` = :email, '
237            . '`secondary_email` = :semail, `phone` = :phone, `secondary_phone` = :sphone, `website` = :site, '
238            . '`c_o` = :co, `birthday` = :bday, `address_street` = :astreet, `address_city` = :acity, '
239            . '`address_postal_code` = :apcode, `address_country` = :acountry, `address_latitude` = :alat, '
240            . '`address_longitude` = :alng, `description` = :descr WHERE `id` = :id',
241            $table
242        );
243        $stmt = $this->pdo->prepare($sql);
244        $stmt->execute([
245            ':c_name' => $data['c_name'],
246            ':etag' => $data['c_etag'],
247            ':fname' => $data['first_name'] ?? '',
248            ':lname' => $data['last_name'] ?? '',
249            ':fn' => $data['formatted_name'] ?? '',
250            ':jtitle' => $data['job_title'] ?? '',
251            ':email' => $data['email'] ?? '',
252            ':semail' => $data['secondary_email'] ?? '',
253            ':phone' => $data['phone'] ?? '',
254            ':sphone' => $data['secondary_phone'] ?? '',
255            ':site' => $data['website'] ?? '',
256            ':co' => $data['c_o'] ?? '',
257            ':bday' => $data['birthday'] ?? null,
258            ':astreet' => $data['address_street'] ?? '',
259            ':acity' => $data['address_city'] ?? '',
260            ':apcode' => $data['address_postal_code'] ?? '',
261            ':acountry' => $data['address_country'] ?? '',
262            ':alat' => $data['address_latitude'] ?? null,
263            ':alng' => $data['address_longitude'] ?? null,
264            ':descr' => $data['description'] ?? '',
265            ':id' => $id,
266        ]);
267    }
268
269    /**
270     * Inserts a new contact record into the database.
271     *
272     * @param string $table Table name.
273     * @param int $userId Owner and creator user ID.
274     * @param array<string, mixed> $data Mapped columns.
275     * @return void
276     */
277    private function insertContactRecord(string $table, int $userId, array $data): void
278    {
279        $sql = sprintf(
280            'INSERT INTO `%s` (`c_name`, `c_etag`, `first_name`, `last_name`, `formatted_name`, `job_title`, '
281            . '`email`, `secondary_email`, `phone`, `secondary_phone`, `website`, `c_o`, `birthday`, '
282            . '`address_street`, `address_city`, `address_postal_code`, `address_country`, `address_latitude`, '
283            . '`address_longitude`, `description`, `created_by`, `owner`) '
284            . 'VALUES (:c_name, :etag, :fname, :lname, :fn, :jtitle, :email, :semail, :phone, :sphone, :site, '
285            . ':co, :bday, :astreet, :acity, :apcode, :acountry, :alat, :alng, :descr, :c_by, :c_owner)',
286            $table
287        );
288        $stmt = $this->pdo->prepare($sql);
289        $ownerId = $userId > 0 ? $userId : 1;
290        $stmt->execute([
291            ':c_name' => $data['c_name'],
292            ':etag' => $data['c_etag'],
293            ':fname' => $data['first_name'] ?? '',
294            ':lname' => $data['last_name'] ?? '',
295            ':fn' => $data['formatted_name'] ?? '',
296            ':jtitle' => $data['job_title'] ?? '',
297            ':email' => $data['email'] ?? '',
298            ':semail' => $data['secondary_email'] ?? '',
299            ':phone' => $data['phone'] ?? '',
300            ':sphone' => $data['secondary_phone'] ?? '',
301            ':site' => $data['website'] ?? '',
302            ':co' => $data['c_o'] ?? '',
303            ':bday' => $data['birthday'] ?? null,
304            ':astreet' => $data['address_street'] ?? '',
305            ':acity' => $data['address_city'] ?? '',
306            ':apcode' => $data['address_postal_code'] ?? '',
307            ':acountry' => $data['address_country'] ?? '',
308            ':alat' => $data['address_latitude'] ?? null,
309            ':alng' => $data['address_longitude'] ?? null,
310            ':descr' => $data['description'] ?? '',
311            ':c_by' => $ownerId,
312            ':c_owner' => $ownerId,
313        ]);
314    }
315}