app/Services/VCard/ImportVCard.php (654 lines of code) (raw):
<?php
namespace App\Services\VCard;
use Ramsey\Uuid\Uuid;
use App\Models\User\User;
use App\Traits\DAVFormat;
use function Safe\substr;
use Sabre\VObject\Reader;
use App\Helpers\DateHelper;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use App\Helpers\VCardHelper;
use App\Helpers\LocaleHelper;
use App\Services\BaseService;
use function Safe\preg_split;
use App\Models\Contact\Gender;
use App\Models\Contact\Address;
use App\Models\Contact\Contact;
use Illuminate\Validation\Rule;
use App\Helpers\CountriesHelper;
use Sabre\VObject\ParseException;
use Sabre\VObject\Component\VCard;
use App\Models\Contact\ContactField;
use App\Services\Contact\Tag\DetachTag;
use App\Models\Contact\ContactFieldType;
use App\Services\Contact\Tag\AssociateTag;
use App\Services\Account\Photo\UploadPhoto;
use App\Services\Contact\Avatar\UpdateAvatar;
use App\Services\Contact\Address\CreateAddress;
use App\Services\Contact\Address\UpdateAddress;
use App\Services\Contact\Address\DestroyAddress;
use App\Services\Contact\ContactField\CreateContactField;
use App\Services\Contact\ContactField\UpdateContactField;
use App\Services\Contact\ContactField\DestroyContactField;
use App\Services\Contact\Contact\UpdateBirthdayInformation;
class ImportVCard extends BaseService
{
use DAVFormat;
public const BEHAVIOUR_ADD = 'behaviour_add';
public const BEHAVIOUR_REPLACE = 'behaviour_replace';
protected $errorResults = [
'ERROR_PARSER' => 'import_vcard_parse_error',
'ERROR_CONTACT_EXIST' => 'import_vcard_contact_exist',
'ERROR_CONTACT_DOESNT_HAVE_FIRSTNAME' => 'import_vcard_contact_no_firstname',
];
/**
* Valids value for frequency type.
*
* @var array
*/
public static $behaviourTypes = [
self::BEHAVIOUR_ADD, self::BEHAVIOUR_REPLACE,
];
/**
* The Account id.
*
* @var int
*/
public $accountId;
/**
* The User id.
*
* @var int
*/
public $userId;
/**
* The contact fields ids.
*
* @var array
*/
protected $contactFields;
/**
* The genders that will be associated with imported contacts.
*
* @var array[Gender]
*/
protected $genders;
/**
* Get the validation rules that apply to the service.
*
*
* @return array
*/
public function rules()
{
return [
'account_id' => 'required|integer|exists:accounts,id',
'user_id' => 'required|integer|exists:users,id',
'contact_id' => 'nullable|integer|exists:contacts,id',
'entry' => [
'required',
function ($attribute, $value, $fail) {
if (! is_string($value) && ! $value instanceof VCard) {
$fail($attribute.' must be a string or a VCard object.');
}
},
],
'behaviour' => [
'required',
Rule::in(self::$behaviourTypes),
],
];
}
/**
* Import one VCard.
*
* @param array $data
* @return array
*/
public function execute(array $data): array
{
$this->validate($data);
User::where('account_id', $data['account_id'])
->findOrFail($data['user_id']);
if ($contactId = Arr::get($data, 'contact_id')) {
Contact::where('account_id', $data['account_id'])
->findOrFail($contactId);
}
return $this->process($data);
}
private function clear()
{
$this->contactFields = [];
$this->genders = [];
$this->accountId = 0;
$this->userId = 0;
}
/**
* Process data importation.
*
* @param array $data
* @return array
*/
private function process(array $data): array
{
if ($this->accountId !== $data['account_id']) {
$this->clear();
$this->accountId = $data['account_id'];
}
$this->userId = $data['user_id'];
$entry = $this->getEntry($data);
if (! $entry) {
return [
'error' => 'ERROR_PARSER',
'reason' => $this->errorResults['ERROR_PARSER'],
'name' => '(unknow)',
];
}
return $this->processEntry($data, $entry);
}
/**
* Process entry importation.
*
* @param array $data
* @param VCard $entry
* @return array
*/
private function processEntry(array $data, VCard $entry): array
{
if (! $this->canImportCurrentEntry($entry)) {
return [
'error' => 'ERROR_CONTACT_DOESNT_HAVE_FIRSTNAME',
'reason' => $this->errorResults['ERROR_CONTACT_DOESNT_HAVE_FIRSTNAME'],
'name' => $this->name($entry),
];
}
$contactId = Arr::get($data, 'contact_id');
$contact = $this->getExistingContact($entry, $contactId);
return $this->processEntryContact($data, $entry, $contact);
}
/**
* Process entry importation.
*
* @param array $data
* @param VCard $entry
* @param Contact|null $contact
* @return array
*/
private function processEntryContact(array $data, VCard $entry, $contact): array
{
$behaviour = $data['behaviour'] ?: self::BEHAVIOUR_ADD;
if ($contact && $behaviour === self::BEHAVIOUR_ADD) {
return [
'contact_id' => $contact->id,
'error' => 'ERROR_CONTACT_EXIST',
'reason' => $this->errorResults['ERROR_CONTACT_EXIST'],
'name' => $this->name($entry),
];
}
if ($contact) {
$timestamps = $contact->timestamps;
$contact->timestamps = false;
}
$contact = $this->importEntry($contact, $entry);
if (isset($timestamps)) {
$contact->timestamps = $timestamps;
}
return [
'contact_id' => $contact->id,
'name' => $this->name($entry),
];
}
/**
* @param array $data
* @return VCard|null
*/
private function getEntry($data): ?VCard
{
$entry = $data['entry'];
if (! $entry instanceof VCard) {
try {
$entry = Reader::read($entry, Reader::OPTION_FORGIVING + Reader::OPTION_IGNORE_INVALID_LINES);
} catch (ParseException $e) {
return null;
}
}
if ($entry instanceof VCard) {
return $entry;
}
return null;
}
/**
* Get or create the gender called "Vcard" that is associated with all
* imported contacts.
*
* @param string $genderCode
* @return Gender
*/
private function getGender($genderCode): Gender
{
if (! Arr::has($this->genders, $genderCode)) {
$gender = $this->getGenderByType($genderCode);
if (! $gender) {
switch ($genderCode) {
case 'M':
$gender = $this->getGenderByName(trans('app.gender_male')) ?? $this->getGenderByName(config('dav.default_gender'));
break;
case 'F':
$gender = $this->getGenderByName(trans('app.gender_female')) ?? $this->getGenderByName(config('dav.default_gender'));
break;
default:
$gender = $this->getGenderByName(config('dav.default_gender'));
break;
}
}
if (! $gender) {
$gender = new Gender;
$gender->account_id = $this->accountId;
$gender->name = config('dav.default_gender');
$gender->type = Gender::UNKNOWN;
$gender->save();
}
Arr::set($this->genders, $genderCode, $gender);
}
return Arr::get($this->genders, $genderCode);
}
/**
* Get the gender by name.
*
* @param string $name
* @return Gender|null
*/
private function getGenderByName($name)
{
return Gender::where([
'account_id' => $this->accountId,
'name' => $name,
])->first();
}
/**
* Get the gender by type.
*
* @param string $type
* @return Gender|null
*/
private function getGenderByType($type)
{
return Gender::where([
'account_id' => $this->accountId,
'type' => $type,
])->first();
}
/**
* Check whether a contact has a first name or a nickname. If not, contact
* can not be imported.
*
* @param VCard $entry
* @return bool
*/
private function canImportCurrentEntry(VCard $entry): bool
{
return
$this->hasFirstnameInN($entry) ||
$this->hasNickname($entry) ||
$this->hasFN($entry);
}
/**
* @param VCard $entry
* @return bool
*/
private function hasFirstnameInN(VCard $entry): bool
{
return $entry->N !== null && ! empty(Arr::get($entry->N->getParts(), '1'));
}
/**
* @param VCard $entry
* @return bool
*/
private function hasNICKNAME(VCard $entry): bool
{
return ! empty((string) $entry->NICKNAME);
}
/**
* @param VCard $entry
* @return bool
*/
private function hasFN(VCard $entry): bool
{
return ! empty((string) $entry->FN);
}
/**
* Check whether the email is valid.
*
* @param string $email
*/
private function isValidEmail(string $email): bool
{
return (bool) filter_var($email, FILTER_VALIDATE_EMAIL);
}
/**
* Check whether the contact already exists in the database.
*
* @param VCard $entry
* @param int $contact_id
* @return Contact|null
*/
private function getExistingContact(VCard $entry, $contact_id = null)
{
$contact = null;
if (! is_null($contact_id)) {
$contact = Contact::where('account_id', $this->accountId)
->find($contact_id);
}
if (! $contact) {
$contact = $this->existingContactWithEmail($entry);
}
if (! $contact) {
$contact = $this->existingContactWithName($entry);
}
return $contact;
}
/**
* Search with email field.
*
* @param VCard $entry
* @return Contact|null
*/
private function existingContactWithEmail(VCard $entry): ?Contact
{
if (empty($entry->EMAIL)) {
return null;
}
if ($this->isValidEmail((string) $entry->EMAIL)) {
$contactField = ContactField::where([
'account_id' => $this->accountId,
'contact_field_type_id' => $this->getContactFieldTypeId(ContactFieldType::EMAIL),
])->whereIn('data', iterator_to_array($entry->EMAIL))->first();
if ($contactField) {
return $contactField->contact;
}
}
return null;
}
/**
* Search with names fields.
*
* @param VCard $entry
* @return Contact|null
*/
private function existingContactWithName(VCard $entry)
{
$contact = new Contact;
$this->importNames($contact, $entry);
return Contact::where([
'account_id' => $this->accountId,
'first_name' => $contact->first_name,
'middle_name' => $contact->middle_name,
'last_name' => $contact->last_name,
])->first();
}
/**
* Create the Contact object matching the current entry.
*
* @param Contact|null $contact
* @param VCard $entry
* @return Contact
*/
private function importEntry($contact, VCard $entry): Contact
{
if (! $contact) {
$contact = new Contact;
$contact->account_id = $this->accountId;
$contact->gender_id = $this->getGender('O')->id;
$contact->setAvatarColor();
$contact->uuid = Str::uuid()->toString();
$contact->save();
}
$this->importNames($contact, $entry);
$this->importUid($contact, $entry);
$this->importGender($contact, $entry);
$this->importPhoto($contact, $entry);
$this->importWorkInformation($contact, $entry);
$this->importBirthday($contact, $entry);
$this->importAddress($contact, $entry);
$this->importEmail($contact, $entry);
$this->importTel($contact, $entry);
$this->importSocialProfile($contact, $entry);
$this->importCategories($contact, $entry);
$contact->save();
return $contact;
}
/**
* Import names of the contact.
*
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importNames(Contact $contact, VCard $entry): void
{
if ($this->hasFirstnameInN($entry)) {
$this->importFromN($contact, $entry);
} elseif ($this->hasFN($entry)) {
$this->importFromFN($contact, $entry);
} elseif ($this->hasNICKNAME($entry)) {
$this->importFromNICKNAME($contact, $entry);
} else {
throw new \LogicException('Check if you can import entry!');
}
}
/**
* Return the name and email address of the current entry.
* John Doe Johnny john@doe.com.
* Only used for report display.
*
* @psalm-suppress InvalidReturnStatement
* @psalm-suppress InvalidReturnType
*
* @param VCard $entry
*
* @return array|string|null|\Illuminate\Contracts\Translation\Translator
*/
private function name($entry)
{
if ($this->hasFirstnameInN($entry)) {
$parts = $entry->N->getParts();
$name = '';
if (! empty(Arr::get($parts, '1'))) {
$name .= $this->formatValue($parts[1]);
}
if (! empty(Arr::get($parts, '2'))) {
$name .= ' '.$this->formatValue($parts[2]);
}
if (! empty(Arr::get($parts, '0'))) {
$name .= ' '.$this->formatValue($parts[0]);
}
$name .= ' '.$this->formatValue($entry->EMAIL);
} elseif ($this->hasNICKNAME($entry)) {
$name = $this->formatValue($entry->NICKNAME);
$name .= ' '.$this->formatValue($entry->EMAIL);
} elseif ($this->hasFN($entry)) {
$name = $this->formatValue($entry->FN);
$name .= ' '.$this->formatValue($entry->EMAIL);
} else {
$name = trans('settings.import_vcard_unknown_entry');
}
return $name;
}
/**
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importFromN(Contact $contact, VCard $entry): void
{
$parts = $entry->N->getParts();
$contact->last_name = $this->formatValue(Arr::get($parts, '0'));
$contact->first_name = $this->formatValue(Arr::get($parts, '1'));
$contact->middle_name = $this->formatValue(Arr::get($parts, '2'));
// prefix [3]
// suffix [4]
if (! empty($entry->NICKNAME)) {
$contact->nickname = $this->formatValue($entry->NICKNAME);
}
}
/**
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importFromNICKNAME(Contact $contact, VCard $entry): void
{
$contact->first_name = $this->formatValue($entry->NICKNAME);
}
/**
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importFromFN(Contact $contact, VCard $entry): void
{
$fullnameParts = preg_split('/\s+/', $entry->FN, 2);
$user = User::where('account_id', $this->accountId)
->findOrFail($this->userId);
if ($user->name_order == 'firstname_lastname' || $user->name_order == 'firstname_lastname_nickname') {
$contact->first_name = $this->formatValue($fullnameParts[0]);
if (count($fullnameParts) > 1) {
$contact->last_name = $this->formatValue($fullnameParts[1]);
}
} elseif (count($fullnameParts) > 1) {
$contact->last_name = $this->formatValue($fullnameParts[0]);
$contact->first_name = $this->formatValue($fullnameParts[1]);
} else {
$contact->first_name = $this->formatValue($fullnameParts[0]);
}
if (! empty($entry->NICKNAME)) {
$contact->nickname = $this->formatValue($entry->NICKNAME);
}
}
/**
* Import uid of the contact.
*
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importUid(Contact $contact, VCard $entry): void
{
if (empty($contact->uuid) && Uuid::isValid((string) $entry->UID)) {
$contact->uuid = (string) $entry->UID;
}
}
/**
* Import gender of the contact.
*
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importGender(Contact $contact, VCard $entry): void
{
if ($entry->GENDER) {
$contact->gender_id = $this->getGender((string) $entry->GENDER)->id;
}
}
/**
* Import photo of the contact.
*
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importPhoto(Contact $contact, VCard $entry): void
{
if ($entry->PHOTO) {
if (Str::startsWith((string) $entry->PHOTO, 'https://secure.gravatar.com') || Str::startsWith((string) $entry->PHOTO, 'https://www.gravatar.com')) {
// Gravatar
$contact->avatar_gravatar_url = (string) $entry->PHOTO;
} elseif (! Str::startsWith((string) $entry->PHOTO, 'https://')
&& ! Str::startsWith((string) $entry->PHOTO, 'http://')
&& ($contact->avatar_source != 'photo' || empty($contact->avatar_photo_id))) {
// Import photo image
// Skipping in case a photo avatar is already set
$array = [
'account_id' => $contact->account_id,
'contact_id' => $contact->id,
'data' => (string) $entry->PHOTO,
];
if (! is_null($entry->PHOTO['TYPE'])) {
/** @var \Sabre\VObject\Parameter */
$type = $entry->PHOTO['TYPE'];
$array['extension'] = $type->getValue();
}
$photo = app(UploadPhoto::class)->execute($array);
if (! $photo) {
return;
}
app(UpdateAvatar::class)->execute([
'account_id' => $contact->account_id,
'contact_id' => $contact->id,
'source' => 'photo',
'photo_id' => $photo->id,
]);
}
}
}
/**
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importWorkInformation(Contact $contact, VCard $entry): void
{
if ($entry->ORG) {
$contact->company = $this->formatValue($entry->ORG);
}
if ($entry->ROLE) {
$contact->job = $this->formatValue($entry->ROLE);
}
if ($entry->TITLE) {
$contact->job = $this->formatValue($entry->TITLE);
}
}
/**
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importBirthday(Contact $contact, VCard $entry): void
{
if ($entry->BDAY && ! empty((string) $entry->BDAY)) {
$bday = (string) $entry->BDAY;
$is_year_unknown = false;
if (Str::startsWith($bday, '--')) {
$bday = '0'.substr($bday, 1);
$is_year_unknown = true;
}
$birthdate = null;
try {
$birthdate = DateHelper::parseDate($bday);
} catch (\Exception $e) {
// catch any date parse exception
}
if (! is_null($birthdate)) {
app(UpdateBirthdayInformation::class)->execute([
'account_id' => $contact->account_id,
'contact_id' => $contact->id,
'is_date_known' => true,
'is_age_based' => false,
'day' => $birthdate->day,
'month' => $birthdate->month,
'year' => $is_year_unknown ? null : $birthdate->year,
'add_reminder' => true,
'is_deceased' => false,
]);
}
}
}
/**
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importAddress(Contact $contact, VCard $entry): void
{
if (! $entry->ADR) {
return;
}
$addresses = $contact->addresses()
->get()
->sortBy('id');
foreach ($entry->ADR as $adr) {
$parts = $adr->getParts();
$addressContent = [
'account_id' => $contact->account_id,
'contact_id' => $contact->id,
'street' => $this->formatValue(Arr::get($parts, '2')),
'city' => $this->formatValue(Arr::get($parts, '3')),
'province' => $this->formatValue(Arr::get($parts, '4')),
'postal_code' => $this->formatValue(Arr::get($parts, '5')),
'country' => CountriesHelper::find(Arr::get($parts, '6')),
'labels' => preg_split('/,/', (string) $adr['TYPE']),
];
// We assume addresses are in the same order
$address = $addresses->shift();
if (is_null($address)) {
// Address does not exist
app(CreateAddress::class)->execute($addressContent);
} else {
// Address has to be updated
$address = app(UpdateAddress::class)->execute([
'address_id' => $address->id,
'name' => $address->name,
] +
$addressContent
);
}
}
foreach ($addresses as $address) {
// Remaining addresses have to be removed
app(DestroyAddress::class)->execute([
'account_id' => $contact->account_id,
'address_id' => $address->id,
]);
}
}
/**
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importEmail(Contact $contact, VCard $entry): void
{
if (is_null($entry->EMAIL)) {
return;
}
$contactFieldTypeId = $this->getContactFieldTypeId(ContactFieldType::EMAIL);
if (! $contactFieldTypeId) {
// Case of contact field type email does not exist
return;
}
$emails = $contact->contactFields()
->email()
->get()
->sortBy('id');
foreach ($entry->EMAIL as $email) {
$contactFieldContent = [
'account_id' => $contact->account_id,
'contact_id' => $contact->id,
'contact_field_type_id' => $contactFieldTypeId,
'data' => $this->formatValue((string) $email),
'labels' => preg_split('/,/', (string) $email['TYPE']),
];
// We assume contact fields are in the same order
$contactField = $emails->shift();
if (is_null($contactField)) {
// Address does not exist
app(CreateContactField::class)->execute($contactFieldContent);
} else {
// Address has to be updated
app(UpdateContactField::class)->execute([
'contact_field_id' => $contactField->id,
] +
$contactFieldContent
);
}
}
foreach ($emails as $email) {
// Remaining emails have to be removed
app(DestroyContactField::class)->execute([
'account_id' => $contact->account_id,
'contact_field_id' => $email->id,
]);
}
}
/**
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importTel(Contact $contact, VCard $entry): void
{
if (is_null($entry->TEL)) {
return;
}
$contactFieldTypeId = $this->getContactFieldTypeId(ContactFieldType::PHONE);
if (! $contactFieldTypeId) {
// Case of contact field type phone does not exist
return;
}
$phones = $contact->contactFields()
->phone()
->get()
->sortBy('id');
$countryISO = VCardHelper::getCountryISOFromSabreVCard($entry);
foreach ($entry->TEL as $tel) {
$data = (string) $tel;
$data = LocaleHelper::formatTelephoneNumberByISO($data, $countryISO, Str::startsWith($data, '+') ? \libphonenumber\PhoneNumberFormat::INTERNATIONAL : \libphonenumber\PhoneNumberFormat::NATIONAL);
$contactFieldContent = [
'account_id' => $contact->account_id,
'contact_id' => $contact->id,
'contact_field_type_id' => $contactFieldTypeId,
'data' => $this->formatValue($data),
'labels' => preg_split('/,/', (string) $tel['TYPE']),
];
// We assume contact fields are in the same order
$phone = $phones->shift();
if (is_null($phone)) {
// Address does not exist
app(CreateContactField::class)->execute($contactFieldContent);
} else {
// Address has to be updated
app(UpdateContactField::class)->execute([
'contact_field_id' => $phone->id,
] +
$contactFieldContent
);
}
}
foreach ($phones as $phone) {
// Remaining phones have to be removed
app(DestroyContactField::class)->execute([
'account_id' => $contact->account_id,
'contact_field_id' => $phone->id,
]);
}
}
/**
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importSocialProfile(Contact $contact, VCard $entry): void
{
if (is_null($entry->socialProfile)) {
return;
}
foreach ($entry->socialProfile as $socialProfile) {
$type = $socialProfile['type'];
$contactFieldTypeId = null;
$data = null;
switch ((string) $type) {
case 'facebook':
$contactFieldTypeId = $this->getContactFieldTypeId('Facebook');
$data = str_replace('https://www.facebook.com/', '', $this->formatValue((string) $socialProfile));
break;
case 'twitter':
$contactFieldTypeId = $this->getContactFieldTypeId('Twitter');
$data = str_replace('https://twitter.com/', '', $this->formatValue((string) $socialProfile));
break;
case 'whatsapp':
$contactFieldTypeId = $this->getContactFieldTypeId('Whatsapp');
$data = str_replace('https://wa.me/', '', $this->formatValue((string) $socialProfile));
break;
case 'telegram':
$contactFieldTypeId = $this->getContactFieldTypeId('Telegram');
$data = str_replace('http://t.me/', '', $this->formatValue((string) $socialProfile));
break;
case 'linkedin':
$contactFieldTypeId = $this->getContactFieldTypeId('LinkedIn');
$data = str_replace('http://www.linkedin.com/in/', '', $this->formatValue((string) $socialProfile));
break;
default:
// Not supported
break;
}
if (! is_null($contactFieldTypeId) && ! is_null($data)) {
ContactField::firstOrCreate([
'account_id' => $contact->account_id,
'contact_id' => $contact->id,
'data' => $data,
'contact_field_type_id' => $contactFieldTypeId,
]);
}
}
}
/**
* Get the contact field type id for the $type.
*
* @param string $type The type of the ContactFieldType, or the name
* @return int|null
*/
private function getContactFieldTypeId(string $type)
{
if (! Arr::has($this->contactFields, $type)) {
$contactFieldType = ContactFieldType::where([
'account_id' => $this->accountId,
'type' => $type,
])->first();
if (is_null($contactFieldType)) {
$contactFieldType = ContactFieldType::where([
'account_id' => $this->accountId,
'name' => $type,
])->first();
}
Arr::set($this->contactFields, $type, $contactFieldType != null ? $contactFieldType->id : null);
}
return Arr::get($this->contactFields, $type);
}
/**
* Import the categories as tags.
*
* @param Contact $contact
* @param VCard $entry
* @return void
*/
private function importCategories(Contact $contact, VCard $entry)
{
$tags = [];
foreach ($contact->tags as $tag) {
$tags[$tag->name] = $tag->id;
}
if (! is_null($entry->CATEGORIES)) {
$categories = preg_split('/,/', $entry->CATEGORIES);
foreach ($categories as $category) {
$name = (string) $category;
if (isset($tags[$name])) {
unset($tags[$name]);
} else {
app(AssociateTag::class)->execute([
'account_id' => $contact->account_id,
'contact_id' => $contact->id,
'name' => $name,
]);
}
}
}
foreach ($tags as $tag) {
app(DetachTag::class)->execute([
'account_id' => $contact->account_id,
'contact_id' => $contact->id,
'tag_id' => $tag,
]);
}
}
}