diff --git a/CHANGELOG.md b/CHANGELOG.md index 16a25282ac3..80be8bb48fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### New features: +* Add the notion of AddressBooks * Allow customization of life event types ### Enhancements: diff --git a/app/Helpers/AccountHelper.php b/app/Helpers/AccountHelper.php index 497454b078a..dc711276d2b 100644 --- a/app/Helpers/AccountHelper.php +++ b/app/Helpers/AccountHelper.php @@ -40,7 +40,7 @@ public static function hasLimitations(Account $account): bool */ public static function hasReachedContactLimit(Account $account): bool { - return $account->contacts()->real()->active()->count() >= config('monica.number_of_allowed_contacts_free_account'); + return $account->allContacts()->real()->active()->count() >= config('monica.number_of_allowed_contacts_free_account'); } /** @@ -54,7 +54,7 @@ public static function canDowngrade(Account $account): bool $canDowngrade = true; $numberOfUsers = $account->users()->count(); $numberPendingInvitations = $account->invitations()->count(); - $numberActiveContacts = $account->contacts()->active()->count(); + $numberActiveContacts = $account->allContacts()->active()->count(); // number of users in the account should be == 1 if ($numberOfUsers > 1) { diff --git a/app/Helpers/SearchHelper.php b/app/Helpers/SearchHelper.php index fc775e0f95f..4c2a4737b12 100644 --- a/app/Helpers/SearchHelper.php +++ b/app/Helpers/SearchHelper.php @@ -16,9 +16,10 @@ class SearchHelper * @param string $needle * @param string $orderByColumn * @param string $orderByDirection + * @param string|null $addressBookName * @return Builder */ - public static function searchContacts(string $needle, string $orderByColumn, string $orderByDirection = 'asc'): Builder + public static function searchContacts(string $needle, string $orderByColumn, string $orderByDirection = 'asc', string $addressBookName = null): Builder { $accountId = Auth::user()->account_id; @@ -41,9 +42,11 @@ public static function searchContacts(string $needle, string $orderByColumn, str ]); }); - return $b->orderBy($orderByColumn, $orderByDirection); + return $b->addressBook($accountId, $addressBookName) + ->orderBy($orderByColumn, $orderByDirection); } - return Contact::search($needle, $accountId, $orderByColumn, $orderByDirection); + return Contact::search($needle, $accountId, $orderByColumn, $orderByDirection) + ->addressBook($accountId, $addressBookName); } } diff --git a/app/Http/Controllers/Contacts/IntroductionsController.php b/app/Http/Controllers/Contacts/IntroductionsController.php index 4929d2ea829..ef97048697c 100644 --- a/app/Http/Controllers/Contacts/IntroductionsController.php +++ b/app/Http/Controllers/Contacts/IntroductionsController.php @@ -21,7 +21,7 @@ class IntroductionsController extends Controller */ public function edit(Contact $contact) { - $contacts = auth()->user()->account->contacts() + $contacts = $contact->siblingContacts() ->real() ->active() ->get(); diff --git a/app/Http/Controllers/DAV/Backend/CalDAV/AbstractCalDAVBackend.php b/app/Http/Controllers/DAV/Backend/CalDAV/AbstractCalDAVBackend.php index 714e35d6db0..2095d6c5131 100644 --- a/app/Http/Controllers/DAV/Backend/CalDAV/AbstractCalDAVBackend.php +++ b/app/Http/Controllers/DAV/Backend/CalDAV/AbstractCalDAVBackend.php @@ -15,7 +15,7 @@ abstract class AbstractCalDAVBackend implements ICalDAVBackend, IDAVBackend public function getDescription() { - $token = DAVSyncPlugin::SYNCTOKEN_PREFIX.$this->refreshSyncToken()->id; + $token = DAVSyncPlugin::SYNCTOKEN_PREFIX.$this->refreshSyncToken(null)->id; $des = [ 'id' => $this->backendUri(), 'uri' => $this->backendUri(), diff --git a/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBackend.php b/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBackend.php index 961a5c9522e..047db06091f 100644 --- a/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBackend.php +++ b/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBackend.php @@ -125,7 +125,7 @@ public function getChangesForCalendar($calendarId, $syncToken, $syncLevel, $limi { $backend = $this->getBackend($calendarId); if ($backend) { - return $backend->getChanges($syncToken); + return $backend->getChanges($calendarId, $syncToken); } return []; @@ -166,7 +166,7 @@ public function getCalendarObjects($calendarId) { $backend = $this->getBackend($calendarId); if ($backend) { - $objs = $backend->getObjects(); + $objs = $backend->getObjects($calendarId); return $objs ->map(function ($date) use ($backend) { @@ -201,7 +201,7 @@ public function getCalendarObject($calendarId, $objectUri) { $backend = $this->getBackend($calendarId); if ($backend) { - $obj = $backend->getObject($objectUri); + $obj = $backend->getObject($calendarId, $objectUri); if ($obj) { return $backend->prepareData($obj); @@ -257,7 +257,7 @@ public function updateCalendarObject($calendarId, $objectUri, $calendarData): ?s $backend = $this->getBackend($calendarId); return $backend ? - $backend->updateOrCreateCalendarObject($objectUri, $calendarData) + $backend->updateOrCreateCalendarObject($calendarId, $objectUri, $calendarData) : null; } diff --git a/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBirthdays.php b/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBirthdays.php index eaf5abde6f4..697a6213b31 100644 --- a/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBirthdays.php +++ b/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVBirthdays.php @@ -95,10 +95,11 @@ private function hasBirthday($contact) /** * Returns the date for the specific uuid. * + * @param string|null $collectionId * @param string $uuid * @return mixed */ - public function getObjectUuid($uuid) + public function getObjectUuid($collectionId, $uuid) { return SpecialDate::where([ 'account_id' => Auth::user()->account_id, @@ -111,10 +112,10 @@ public function getObjectUuid($uuid) * * @return \Illuminate\Support\Collection */ - public function getObjects() + public function getObjects($collectionId) { - $contacts = Auth::user()->account - ->contacts() + // We only return the birthday of default addressBook + $contacts = Auth::user()->account->contacts() ->real() ->active() ->get(); @@ -130,7 +131,7 @@ public function getObjects() /** * @return string|null */ - public function updateOrCreateCalendarObject($objectUri, $calendarData): ?string + public function updateOrCreateCalendarObject($calendarId, $objectUri, $calendarData): ?string { return null; } diff --git a/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVTasks.php b/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVTasks.php index a08a94e7087..8e995d69725 100644 --- a/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVTasks.php +++ b/app/Http/Controllers/DAV/Backend/CalDAV/CalDAVTasks.php @@ -42,7 +42,7 @@ public function getDescription() * * @return \Illuminate\Support\Collection */ - public function getObjects() + public function getObjects($collectionId) { return Auth::user()->account ->tasks() @@ -52,10 +52,11 @@ public function getObjects() /** * Returns the contact for the specific uuid. * + * @param mixed|null $collectionId * @param string $uuid * @return mixed */ - public function getObjectUuid($uuid) + public function getObjectUuid($collectionId, $uuid) { return Task::where([ 'account_id' => Auth::user()->account_id, @@ -123,11 +124,11 @@ public function prepareData($task) * @param string $calendarData * @return string|null */ - public function updateOrCreateCalendarObject($objectUri, $calendarData): ?string + public function updateOrCreateCalendarObject($calendarId, $objectUri, $calendarData): ?string { $task_id = null; if ($objectUri) { - $task = $this->getObject($objectUri); + $task = $this->getObject($this->backendUri(), $objectUri); if ($task) { $task_id = $task->id; @@ -167,7 +168,7 @@ public function updateOrCreateCalendarObject($objectUri, $calendarData): ?string */ public function deleteCalendarObject($objectUri) { - $task = $this->getObject($objectUri); + $task = $this->getObject($this->backendUri(), $objectUri); if ($task) { try { diff --git a/app/Http/Controllers/DAV/Backend/CalDAV/ICalDAVBackend.php b/app/Http/Controllers/DAV/Backend/CalDAV/ICalDAVBackend.php index 14a13d2feae..ff6d2287686 100644 --- a/app/Http/Controllers/DAV/Backend/CalDAV/ICalDAVBackend.php +++ b/app/Http/Controllers/DAV/Backend/CalDAV/ICalDAVBackend.php @@ -55,10 +55,11 @@ public function getDescription(); * The getChanges method returns all the changes that have happened, since * the specified syncToken in the specified calendar. * + * @param string|null $calendarId * @param string $syncToken * @return array */ - public function getChanges($syncToken); + public function getChanges($calendarId, $syncToken); /** * Returns calendar object. @@ -98,11 +99,12 @@ public function prepareData($obj); * calendar-data. If the result of a subsequent GET to this object is not * the exact same as this request body, you should omit the ETag. * + * @param string|null $calendarId * @param string $objectUri * @param string $calendarData * @return string|null */ - public function updateOrCreateCalendarObject($objectUri, $calendarData): ?string; + public function updateOrCreateCalendarObject($calendarId, $objectUri, $calendarData): ?string; /** * Deletes an existing calendar object. diff --git a/app/Http/Controllers/DAV/Backend/CardDAV/AddressBook.php b/app/Http/Controllers/DAV/Backend/CardDAV/AddressBook.php index df2ffdba78a..0bef22d75eb 100644 --- a/app/Http/Controllers/DAV/Backend/CardDAV/AddressBook.php +++ b/app/Http/Controllers/DAV/Backend/CardDAV/AddressBook.php @@ -70,7 +70,7 @@ public function getLastModified(): ?int { $carddavBackend = $this->carddavBackend; if ($carddavBackend instanceof CardDAVBackend) { - $date = $carddavBackend->getLastModified(); + $date = $carddavBackend->getLastModified(null); if (! is_null($date)) { return (int) $date->timestamp; } @@ -92,7 +92,7 @@ public function getSyncToken(): ?string { $carddavBackend = $this->carddavBackend; if ($carddavBackend instanceof CardDAVBackend) { - return (string) $carddavBackend->refreshSyncToken()->id; + return (string) $carddavBackend->refreshSyncToken(null)->id; } return null; diff --git a/app/Http/Controllers/DAV/Backend/CardDAV/CardDAVBackend.php b/app/Http/Controllers/DAV/Backend/CardDAV/CardDAVBackend.php index e9696792936..673f952334b 100644 --- a/app/Http/Controllers/DAV/Backend/CardDAV/CardDAVBackend.php +++ b/app/Http/Controllers/DAV/Backend/CardDAV/CardDAVBackend.php @@ -4,9 +4,8 @@ use Sabre\DAV; use Illuminate\Support\Arr; -use App\Models\User\SyncToken; use App\Models\Contact\Contact; -use Sabre\VObject\Component\VCard; +use App\Models\Account\AddressBook; use App\Services\VCard\ExportVCard; use App\Services\VCard\ImportVCard; use Illuminate\Support\Facades\Log; @@ -55,14 +54,44 @@ public function backendUri() */ public function getAddressBooksForUser($principalUri) { - $token = $this->getCurrentSyncToken(); + $result = []; + $result[] = $this->getDefaultAddressBook(); + + $addressbooks = AddressBook::where('account_id', Auth::user()->account_id) + ->get(); + + foreach ($addressbooks as $addressbook) { + $result[] = $this->getAddressBookDetails($addressbook); + } + + return $result; + } + + private function getDefaultAddressBook() + { + $des = $this->getAddressBookDetails(null); + + $me = auth()->user()->me; + if ($me) { + $des += [ + '{'.CalDAVPlugin::NS_CALENDARSERVER.'}me-card' => '/'.config('laravelsabre.path').'/addressbooks/'.Auth::user()->email.'/contacts/'.$this->encodeUri($me), + ]; + } + + return $des; + } + + private function getAddressBookDetails($addressbook) + { + $id = $addressbook ? $addressbook->name : $this->backendUri(); + $token = $this->getCurrentSyncToken($addressbook); $des = [ - 'id' => $this->backendUri(), - 'uri' => $this->backendUri(), + 'id' => $id, + 'uri' => $id, 'principaluri' => PrincipalBackend::getPrincipalUser(), '{DAV:}displayname' => trans('app.dav_contacts'), - '{'.CardDAVPlugin::NS_CARDDAV.'}addressbook-description' => trans('app.dav_contacts_description', ['name' => Auth::user()->name]), + '{'.CardDAVPlugin::NS_CARDDAV.'}addressbook-description' => $addressbook ? $addressbook->description : trans('app.dav_contacts_description', ['name' => Auth::user()->name]), ]; if ($token) { $des += [ @@ -72,16 +101,7 @@ public function getAddressBooksForUser($principalUri) ]; } - $me = auth()->user()->me; - if ($me) { - $des += [ - '{'.CalDAVPlugin::NS_CALENDARSERVER.'}me-card' => '/'.config('laravelsabre.path').'/addressbooks/'.Auth::user()->email.'/contacts/'.$this->encodeUri($me), - ]; - } - - return [ - $des, - ]; + return $des; } /** @@ -152,7 +172,7 @@ public function getExtension() */ public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) { - return $this->getChanges($syncToken); + return $this->getChanges($addressBookId, $syncToken); } /** @@ -164,13 +184,16 @@ public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, private function prepareCard($contact): array { try { - $vcard = app(ExportVCard::class) - ->execute([ - 'account_id' => Auth::user()->account_id, - 'contact_id' => $contact->id, - ]); - - $carddata = $vcard->serialize(); + $carddata = $contact->vcard; + if (empty($carddata)) { + $vcard = app(ExportVCard::class) + ->execute([ + 'account_id' => Auth::user()->account_id, + 'contact_id' => $contact->id, + ]); + + $carddata = $vcard->serialize(); + } return [ 'id' => $contact->hashID(), @@ -188,14 +211,24 @@ private function prepareCard($contact): array /** * Returns the contact for the specific uuid. * + * @param mixed|null $collectionId * @param string $uuid * @return Contact */ - public function getObjectUuid($uuid) + public function getObjectUuid($collectionId, $uuid) { + $addressBook = null; + if ($collectionId && $collectionId != $this->backendUri()) { + $addressBook = AddressBook::where([ + 'account_id' => Auth::user()->account_id, + 'name' => $collectionId, + ])->first(); + } + return Contact::where([ 'account_id' => Auth::user()->account_id, 'uuid' => $uuid, + 'address_book_id' => $addressBook ? $addressBook->id : null, ])->first(); } @@ -204,11 +237,9 @@ public function getObjectUuid($uuid) * * @return \Illuminate\Support\Collection */ - public function getObjects() + public function getObjects($addressBookId) { - return Auth::user()->account - ->contacts() - ->real() + return Auth::user()->account->contacts($addressBookId) ->active() ->get(); } @@ -229,12 +260,12 @@ public function getObjects() * calculating them. If they are specified, you can also ommit carddata. * This may speed up certain requests, especially with large cards. * - * @param mixed $addressbookId + * @param mixed $collectionId * @return array */ - public function getCards($addressbookId) + public function getCards($collectionId) { - $contacts = $this->getObjects(); + $contacts = $this->getObjects($collectionId); return $contacts->map(function ($contact) { return $this->prepareCard($contact); @@ -255,7 +286,7 @@ public function getCards($addressbookId) */ public function getCard($addressBookId, $cardUri) { - $contact = $this->getObject($cardUri); + $contact = $this->getObject($addressBookId, $cardUri); if ($contact) { return $this->prepareCard($contact); @@ -323,7 +354,7 @@ public function updateCard($addressBookId, $cardUri, $cardData): ?string { $contact_id = null; if ($cardUri) { - $contact = $this->getObject($cardUri); + $contact = $this->getObject($addressBookId, $cardUri); if ($contact) { $contact_id = $contact->id; @@ -338,6 +369,7 @@ public function updateCard($addressBookId, $cardUri, $cardData): ?string 'contact_id' => $contact_id, 'entry' => $cardData, 'behaviour' => ImportVCard::BEHAVIOUR_REPLACE, + 'addressBookName' => $addressBookId == $this->backendUri() ? null : $addressBookId, ]); if (! Arr::has($result, 'error')) { @@ -385,8 +417,8 @@ public function deleteCard($addressBookId, $cardUri) */ public function updateAddressBook($addressBookId, DAV\PropPatch $propPatch): ?bool { - $propPatch->handle('{'.CalDAVPlugin::NS_CALENDARSERVER.'}me-card', function ($props) { - $contact = $this->getObject($props->getHref()); + $propPatch->handle('{'.CalDAVPlugin::NS_CALENDARSERVER.'}me-card', function ($props) use ($addressBookId) { + $contact = $this->getObject($addressBookId, $props->getHref()); $data = [ 'contact_id' => $contact->id, diff --git a/app/Http/Controllers/DAV/Backend/IDAVBackend.php b/app/Http/Controllers/DAV/Backend/IDAVBackend.php index 1828ea5c30c..1eeff749600 100644 --- a/app/Http/Controllers/DAV/Backend/IDAVBackend.php +++ b/app/Http/Controllers/DAV/Backend/IDAVBackend.php @@ -14,17 +14,19 @@ public function backendUri(); /** * Returns the object for the specific uuid. * + * @param string|null $collectionId * @param string $uuid * @return mixed */ - public function getObjectUuid($uuid); + public function getObjectUuid($collectionId, $uuid); /** * Returns the collection of objects. * + * @param string|null $collectionId * @return \Illuminate\Support\Collection */ - public function getObjects(); + public function getObjects($collectionId); /** * Returns the extension for this backend. diff --git a/app/Http/Controllers/DAV/Backend/SyncDAVBackend.php b/app/Http/Controllers/DAV/Backend/SyncDAVBackend.php index fb99e8193b7..0a50628f425 100644 --- a/app/Http/Controllers/DAV/Backend/SyncDAVBackend.php +++ b/app/Http/Controllers/DAV/Backend/SyncDAVBackend.php @@ -14,14 +14,15 @@ trait SyncDAVBackend * If null is returned from this function, the plugin assumes there's no * sync information available. * + * @param string|null $collectionId * @return SyncToken|null */ - protected function getCurrentSyncToken(): ?SyncToken + protected function getCurrentSyncToken($collectionId): ?SyncToken { $tokens = SyncToken::where([ 'account_id' => Auth::user()->account_id, 'user_id' => Auth::user()->id, - 'name' => $this->backendUri(), + 'name' => $collectionId ?? $this->backendUri(), ]) ->orderBy('created_at') ->get(); @@ -32,14 +33,15 @@ protected function getCurrentSyncToken(): ?SyncToken /** * Create or refresh the token if a change happened. * + * @param string|null $collectionId * @return SyncToken */ - public function refreshSyncToken(): SyncToken + public function refreshSyncToken($collectionId): SyncToken { - $token = $this->getCurrentSyncToken(); + $token = $this->getCurrentSyncToken($collectionId); - if (! $token || $token->timestamp < $this->getLastModified()) { - $token = $this->createSyncTokenNow(); + if (! $token || $token->timestamp < $this->getLastModified($collectionId)) { + $token = $this->createSyncTokenNow($collectionId); } return $token; @@ -48,15 +50,16 @@ public function refreshSyncToken(): SyncToken /** * Get SyncToken by token id. * + * @param string|null $collectionId * @return SyncToken|null */ - protected function getSyncToken($syncToken) + protected function getSyncToken($collectionId, $syncToken) { /** @var SyncToken|null */ return SyncToken::where([ 'account_id' => Auth::user()->account_id, 'user_id' => Auth::user()->id, - 'name' => $this->backendUri(), + 'name' => $collectionId ?? $this->backendUri(), ]) ->find($syncToken); } @@ -64,14 +67,15 @@ protected function getSyncToken($syncToken) /** * Create a token with now timestamp. * + * @param string|null $collectionId * @return SyncToken */ - private function createSyncTokenNow() + private function createSyncTokenNow($collectionId) { return SyncToken::create([ 'account_id' => Auth::user()->account_id, 'user_id' => Auth::user()->id, - 'name' => $this->backendUri(), + 'name' => $collectionId ?? $this->backendUri(), 'timestamp' => now(), ]); } @@ -79,11 +83,12 @@ private function createSyncTokenNow() /** * Returns the last modification date. * + * @param string|null $collectionId * @return \Carbon\Carbon|null */ - public function getLastModified() + public function getLastModified($collectionId) { - return $this->getObjects() + return $this->getObjects($collectionId) ->max('updated_at'); } @@ -137,15 +142,16 @@ public function getLastModified() * * The limit is 'suggestive'. You are free to ignore it. * + * @param string $calendarId * @param string $syncToken * @return array|null */ - public function getChanges($syncToken): ?array + public function getChanges($calendarId, $syncToken): ?array { $token = null; $timestamp = null; if (! empty($syncToken)) { - $token = $this->getSyncToken($syncToken); + $token = $this->getSyncToken($calendarId, $syncToken); if (is_null($token)) { // syncToken is not recognized @@ -155,7 +161,7 @@ public function getChanges($syncToken): ?array $timestamp = $token->timestamp; } - $objs = $this->getObjects(); + $objs = $this->getObjects($calendarId); $modified = $objs->filter(function ($obj) use ($timestamp) { return ! is_null($timestamp) && @@ -168,7 +174,7 @@ public function getChanges($syncToken): ?array }); return [ - 'syncToken' => $this->refreshSyncToken()->id, + 'syncToken' => $this->refreshSyncToken($calendarId)->id, 'added' => $added->map(function ($obj) { return $this->encodeUri($obj); })->values()->toArray(), @@ -179,7 +185,7 @@ public function getChanges($syncToken): ?array ]; } - protected function encodeUri($obj) + protected function encodeUri($obj): string { if (empty($obj->uuid)) { // refresh model from database @@ -196,21 +202,33 @@ protected function encodeUri($obj) return urlencode($obj->uuid.$this->getExtension()); } - private function decodeUri($uri) + private function decodeUri($uri): string { return pathinfo(urldecode($uri), PATHINFO_FILENAME); } + /** + * Returns the contact uuid for the specific uri. + * + * @param string $uri + * @return string + */ + public function getUuid($uri): string + { + return $this->decodeUri($uri); + } + /** * Returns the contact for the specific uri. * + * @param string|null $collectionId * @param string $uri * @return mixed */ - public function getObject($uri) + public function getObject($collectionId, $uri) { try { - return $this->getObjectUuid($this->decodeUri($uri)); + return $this->getObjectUuid($collectionId, $this->getUuid($uri)); } catch (\Exception $e) { // Object not found } @@ -219,17 +237,19 @@ public function getObject($uri) /** * Returns the object for the specific uuid. * + * @param string|null $collectionId * @param string $uuid * @return mixed */ - abstract public function getObjectUuid($uuid); + abstract public function getObjectUuid($collectionId, $uuid); /** * Returns the collection of objects. * + * @param string|null $collectionId * @return \Illuminate\Support\Collection */ - abstract public function getObjects(); + abstract public function getObjects($collectionId); abstract public function getExtension(); } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index c675f632f87..adf62eeae1e 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -29,9 +29,9 @@ public function index() ->first(); $numberOfContacts = $account->contacts() - ->real() - ->active() - ->count(); + ->real() + ->active() + ->count(); if ($numberOfContacts === 0) { return view('dashboard.blank'); diff --git a/app/Http/Controllers/Settings/SubscriptionsController.php b/app/Http/Controllers/Settings/SubscriptionsController.php index c4d9d863ff5..30351c11fe6 100644 --- a/app/Http/Controllers/Settings/SubscriptionsController.php +++ b/app/Http/Controllers/Settings/SubscriptionsController.php @@ -177,7 +177,7 @@ public function downgrade() } return view('settings.subscriptions.downgrade-checklist') - ->with('numberOfActiveContacts', $account->contacts()->active()->count()) + ->with('numberOfActiveContacts', $account->allContacts()->active()->count()) ->with('numberOfPendingInvitations', $account->invitations()->count()) ->with('numberOfUsers', $account->users()->count()) ->with('accountHasLimitations', AccountHelper::hasLimitations($account)) diff --git a/app/Models/Account/Account.php b/app/Models/Account/Account.php index e9ea5d9ba6c..14e4c08adb8 100644 --- a/app/Models/Account/Account.php +++ b/app/Models/Account/Account.php @@ -91,11 +91,26 @@ public function activities() * * @return HasMany */ - public function contacts() + public function allContacts() { return $this->hasMany(Contact::class); } + /** + * Get the addressBook's contacts. + * + * @param string|null $addressBookName + * @return HasMany + */ + public function contacts(string $addressBookName = null) + { + $contacts = $this->allContacts(); + + return $addressBookName + ? $contacts->addressBook($this->id, $addressBookName) + : $contacts->addressBook(); + } + /** * Get the invitations associated with the account. * diff --git a/app/Models/Account/AddressBook.php b/app/Models/Account/AddressBook.php new file mode 100644 index 00000000000..5a38dd4e4d5 --- /dev/null +++ b/app/Models/Account/AddressBook.php @@ -0,0 +1,71 @@ +belongsTo(Account::class); + } + + /** + * Get the user record associated with the address book. + * + * @return BelongsTo + */ + public function user() + { + return $this->belongsTo(User::class); + } + + /** + * Get all contacts for this address book. + * + * @return HasMany + */ + public function contacts() + { + return $this->hasMany(Contact::class); + } +} diff --git a/app/Models/Contact/Contact.php b/app/Models/Contact/Contact.php index 4474e83817e..d3a0ac9e266 100644 --- a/app/Models/Contact/Contact.php +++ b/app/Models/Contact/Contact.php @@ -18,6 +18,7 @@ use App\Models\Instance\AuditLog; use function Safe\preg_match_all; use Illuminate\Support\Collection; +use App\Models\Account\AddressBook; use App\Models\Instance\SpecialDate; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; @@ -175,6 +176,34 @@ public function account() return $this->belongsTo(Account::class); } + /** + * Get the address book associated with the contact. + * + * @return BelongsTo + */ + public function addressBook() + { + return $this->belongsTo(AddressBook::class); + } + + /** + * Get the list of contacts from the same address book as this contact. + * + * @return HasMany|null + */ + public function siblingContacts(): ?HasMany + { + if ($this->account) { + if ($this->addressBook) { + return $this->account->contacts($this->addressBook->name); + } + + return $this->account->contacts(); + } + + return null; + } + /** * Get the gender of the contact. * @@ -564,6 +593,28 @@ public function scopeNotActive($query) return $query->where('is_active', 0); } + /** + * Scope a query to only include contacts from given address book. + * 'null' value for address book is the default address book. + * + * @param Builder $query + * @param int|null $accountId + * @param string|null $addressBookName + * @return Builder + */ + public function scopeAddressBook($query, int $accountId = null, string $addressBookName = null) + { + $addressBook = null; + if ($accountId && $addressBookName) { + $addressBook = AddressBook::where([ + 'account_id' => $accountId, + 'name' => $addressBookName, + ])->first(); + } + + return $query->where('address_book_id', $addressBook ? $addressBook->id : null); + } + /** * Mutator first_name. * Get the first name of the contact. diff --git a/app/Models/Contact/Tag.php b/app/Models/Contact/Tag.php index 35a55733651..ef91dfa12d9 100644 --- a/app/Models/Contact/Tag.php +++ b/app/Models/Contact/Tag.php @@ -48,13 +48,20 @@ public function contacts() public static function contactsCount() { return DB::table('contact_tag')->selectRaw('COUNT(tag_id) AS contact_count, name, name_slug') - ->join('tags', function ($join) { - $join->on('tags.id', '=', 'contact_tag.tag_id') - ->on('tags.account_id', '=', 'contact_tag.account_id'); - }) - ->where('tags.account_id', auth()->user()->account_id) - ->groupBy('tag_id') - ->get() - ->sortByCollator('name'); + ->join('tags', function ($join) { + $join->on('tags.id', '=', 'contact_tag.tag_id') + ->on('tags.account_id', '=', 'contact_tag.account_id'); + }) + ->join('contacts', function ($join) { + $join->on('contacts.id', '=', 'contact_tag.contact_id') + ->on('contacts.account_id', '=', 'contact_tag.account_id'); + }) + ->where([ + 'tags.account_id' => auth()->user()->account_id, + 'contacts.address_book_id' => null, + ]) + ->groupBy('tag_id') + ->get() + ->sortByCollator('name'); } } diff --git a/app/Services/VCard/ImportVCard.php b/app/Services/VCard/ImportVCard.php index 64d1ea71b03..7372d439993 100644 --- a/app/Services/VCard/ImportVCard.php +++ b/app/Services/VCard/ImportVCard.php @@ -21,6 +21,7 @@ use App\Helpers\CountriesHelper; use Sabre\VObject\ParseException; use Sabre\VObject\Component\VCard; +use App\Models\Account\AddressBook; use App\Models\Contact\ContactField; use App\Services\Contact\Tag\DetachTag; use App\Models\Contact\ContactFieldType; @@ -86,6 +87,11 @@ class ImportVCard extends BaseService */ protected $genders; + /** + * @var AddressBook|null + */ + protected $addressBook; + /** * Get the validation rules that apply to the service. * @@ -110,6 +116,7 @@ function ($attribute, $value, $fail) { 'required', Rule::in(self::$behaviourTypes), ], + 'addressBookName' => 'nullable|string|exists:addressbooks,name', ]; } @@ -131,6 +138,13 @@ public function execute(array $data): array ->findOrFail($contactId); } + if ($addressBookName = Arr::get($data, 'addressBookName')) { + AddressBook::where([ + 'account_id' => $data['account_id'], + 'name' => $addressBookName, + ])->firstOrFail(); + } + return $this->process($data); } @@ -140,6 +154,7 @@ private function clear() $this->genders = []; $this->accountId = 0; $this->userId = 0; + $this->addressBook = null; } /** @@ -156,6 +171,13 @@ private function process(array $data): array } $this->userId = $data['user_id']; + if ($addressBookName = Arr::get($data, 'addressBookName')) { + $this->addressBook = AddressBook::where([ + 'account_id' => $data['account_id'], + 'name' => $addressBookName, + ])->first(); + } + $entry = $this->getEntry($data); if (! $entry) { @@ -382,7 +404,10 @@ private function getExistingContact(VCard $entry, $contact_id = null) { $contact = null; if (! is_null($contact_id)) { - $contact = Contact::where('account_id', $this->accountId) + $contact = Contact::where([ + 'account_id' => $this->accountId, + 'address_book_id' => $this->addressBook ? $this->addressBook->id : null, + ]) ->find($contact_id); } @@ -415,7 +440,13 @@ private function existingContactWithEmail(VCard $entry): ?Contact 'contact_field_type_id' => $this->getContactFieldTypeId(ContactFieldType::EMAIL), ])->whereIn('data', iterator_to_array($entry->EMAIL))->first(); - if ($contactField) { + // filter contact field + // - if no address book selected + // - if the address book match the contact's contact field address book + if ($contactField && ( + ! $this->addressBook + || $contactField->contact->address_book_id === $this->addressBook->id + )) { return $contactField->contact; } } @@ -439,6 +470,7 @@ private function existingContactWithName(VCard $entry) 'first_name' => $contact->first_name, 'middle_name' => $contact->middle_name, 'last_name' => $contact->last_name, + 'address_book_id' => $this->addressBook ? $this->addressBook->id : null, ])->first(); } @@ -456,8 +488,13 @@ private function importEntry($contact, VCard $entry): Contact $contact->account_id = $this->accountId; $contact->gender_id = $this->getGender('O')->id; $contact->setAvatarColor(); - $contact->uuid = Str::uuid()->toString(); + $contact->address_book_id = $this->addressBook ? $this->addressBook->id : null; $contact->save(); + + $this->importUid($contact, $entry); + if (empty($contact->uuid)) { + $contact->uuid = Str::uuid()->toString(); + } } $this->importNames($contact, $entry); @@ -472,6 +509,11 @@ private function importEntry($contact, VCard $entry): Contact $this->importSocialProfile($contact, $entry); $this->importCategories($contact, $entry); + // Save vcard content + if ($contact->address_book_id) { + $contact->vcard = $entry->serialize(); + } + $contact->save(); return $contact; diff --git a/database/migrations/2020_03_25_055551_add_address_book.php b/database/migrations/2020_03_25_055551_add_address_book.php new file mode 100644 index 00000000000..6a77168de31 --- /dev/null +++ b/database/migrations/2020_03_25_055551_add_address_book.php @@ -0,0 +1,40 @@ +bigIncrements('id'); + $table->unsignedInteger('account_id'); + $table->unsignedInteger('user_id'); + + $table->string('description', 500)->nullable(); + $table->string('name', 100); + $table->timestamps(); + + $table->index('name'); + $table->foreign('account_id')->references('id')->on('accounts')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('addressbooks'); + } +} diff --git a/database/migrations/2020_03_25_082324_add_contact_address_book_id.php b/database/migrations/2020_03_25_082324_add_contact_address_book_id.php new file mode 100644 index 00000000000..30a87836a98 --- /dev/null +++ b/database/migrations/2020_03_25_082324_add_contact_address_book_id.php @@ -0,0 +1,33 @@ +unsignedBigInteger('address_book_id')->after('account_id')->nullable(); + $table->foreign('address_book_id')->references('id')->on('addressbooks')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('contacts', function (Blueprint $table) { + $table->dropColumn('address_book_id'); + }); + } +} diff --git a/database/migrations/2020_03_25_201407_add_contact_vcard_data.php b/database/migrations/2020_03_25_201407_add_contact_vcard_data.php new file mode 100644 index 00000000000..8d5f3953a2c --- /dev/null +++ b/database/migrations/2020_03_25_201407_add_contact_vcard_data.php @@ -0,0 +1,32 @@ +mediumText('vcard')->after('gravatar_url')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('contacts', function (Blueprint $table) { + $table->dropColumn('vcard'); + }); + } +} diff --git a/tests/Unit/Traits/SearchableTest.php b/tests/Unit/Traits/SearchableTest.php index 6ea251ea17b..03eb86c3ab8 100644 --- a/tests/Unit/Traits/SearchableTest.php +++ b/tests/Unit/Traits/SearchableTest.php @@ -55,7 +55,7 @@ public function testFailingSearchContacts() { $contact = factory(Contact::class)->create(['first_name' => 'TestShouldFail']); $searchResults = $contact->search('TestWillSucceed', $contact->account_id, 'created_at', 'desc') - ->get(); + ->paginate(10); $this->assertFalse($searchResults->contains($contact)); }