Skip to content

Commit

Permalink
Merge pull request #13875 from nextcloud/feat/13451/import-emails-fro…
Browse files Browse the repository at this point in the history
…ntend

feat(invitations): Import e-mail participants from CSV 🗒️
  • Loading branch information
DorraJaouad authored Nov 28, 2024
2 parents a40536e + af2cbff commit dae416f
Show file tree
Hide file tree
Showing 21 changed files with 568 additions and 85 deletions.
4 changes: 2 additions & 2 deletions lib/Controller/CallController.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ public function downloadParticipantsForCall(string $format = 'csv'): DataDownloa
'email',
'type',
'identifier',
]);
], escape: '');

foreach ($participants as $participant) {
$email = '';
Expand All @@ -171,7 +171,7 @@ public function downloadParticipantsForCall(string $format = 'csv'): DataDownloa
$email,
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId(),
]);
], escape: '');
}

fseek($output, 0);
Expand Down
4 changes: 2 additions & 2 deletions lib/Controller/RoomController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2456,7 +2456,7 @@ public function setMessageExpiration(int $seconds): DataResponse {
*
* Content format is comma separated values:
* - Header line is required and must match `"email","name"` or `"email"`
* - one entry per line
* - One entry per line (e.g. `"John Doe","john@example.tld"`)
*
* Required capability: `email-csv-import`
*
Expand Down Expand Up @@ -2485,7 +2485,7 @@ public function importEmailsAsParticipants(bool $testRun = false): DataResponse
}

try {
$data = $this->guestManager->importEmails($this->room, $file, $testRun);
$data = $this->guestManager->importEmails($this->room, $file['tmp_name'], $testRun);
return new DataResponse($data);
} catch (GuestImportException $e) {
return new DataResponse($e->getData(), Http::STATUS_BAD_REQUEST);
Expand Down
2 changes: 1 addition & 1 deletion lib/Exceptions/GuestImportException.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public function __construct(
protected readonly ?int $invites = null,
protected readonly ?int $duplicates = null,
) {
parent::__construct();
parent::__construct($reason);
}

/**
Expand Down
48 changes: 28 additions & 20 deletions lib/GuestManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public function validateMailAddress(string $email): bool {
* @return array{invites: non-negative-int, duplicates: non-negative-int, invalid?: non-negative-int, invalidLines?: list<non-negative-int>, type?: int<-1, 6>}
* @throws GuestImportException
*/
public function importEmails(Room $room, $file, bool $testRun): array {
public function importEmails(Room $room, string $filePath, bool $testRun): array {
if ($room->getType() === Room::TYPE_ONE_TO_ONE
|| $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER
|| $room->getType() === Room::TYPE_NOTE_TO_SELF
Expand All @@ -93,43 +93,55 @@ public function importEmails(Room $room, $file, bool $testRun): array {
throw new GuestImportException(GuestImportException::REASON_ROOM);
}

$content = fopen($file['tmp_name'], 'rb');
$content = fopen($filePath, 'rb');
$details = fgetcsv($content, escape: '');
if (!isset($details[0]) || strtolower($details[0]) !== 'email') {

$emailKey = $nameKey = null;
foreach ($details as $key => $header) {
if (strtolower($header) === 'email') {
$emailKey = $key;
} elseif (strtolower($header) === 'name') {
$nameKey = $key;
}
}

if ($emailKey === null) {
throw new GuestImportException(
GuestImportException::REASON_HEADER_EMAIL,
$this->l->t('Missing email field in header line'),
);
}
if (isset($details[1]) && strtolower($details[1]) !== 'name') {
throw new GuestImportException(
GuestImportException::REASON_HEADER_NAME,
$this->l->t('Missing name field in header line'),
);

if ($nameKey === null) {
$this->logger->debug('No name field in header line, skipping name import');
}

$participants = $this->participantService->getParticipantsByActorType($room, Attendee::ACTOR_EMAILS);
$alreadyInvitedEmails = array_flip(array_map(static fn (Participant $participant): string => $participant->getAttendee()->getInvitedCloudId(), $participants));

$line = $duplicates = 0;
$line = 1;
$duplicates = 0;
$emailsToAdd = $invalidLines = [];
while ($details = fgetcsv($content, escape: '')) {
$line++;
if (isset($alreadyInvitedEmails[$details[0]])) {
$this->logger->debug('Skipping import of ' . $details[0] . ' (line: ' . $line . ') as they are already invited');
if (isset($alreadyInvitedEmails[$details[$emailKey]])) {
$this->logger->debug('Skipping import of ' . $details[$emailKey] . ' (line: ' . $line . ') as they are already invited');
$duplicates++;
continue;
}

if (count($details) > 2) {
$this->logger->debug('Invalid entry with too many fields on line: ' . $line);
if (!isset($details[$emailKey])) {
$this->logger->debug('Invalid entry without email fields on line: ' . $line);
$invalidLines[] = $line;
continue;
}

$email = strtolower(trim($details[0]));
if (count($details) === 2) {
$name = trim($details[1]);
$email = strtolower(trim($details[$emailKey]));
if ($nameKey !== null && isset($details[$nameKey])) {
$name = trim($details[$nameKey]);
if ($name === '' || strcasecmp($name, $email) === 0) {
$name = null;
}
} else {
$name = null;
}
Expand All @@ -140,10 +152,6 @@ public function importEmails(Room $room, $file, bool $testRun): array {
continue;
}

if ($name !== null && strcasecmp($name, $email) === 0) {
$name = null;
}

$actorId = hash('sha256', $email);
$alreadyInvitedEmails[$email] = $actorId;
$emailsToAdd[] = [
Expand Down
2 changes: 1 addition & 1 deletion openapi-full.json
Original file line number Diff line number Diff line change
Expand Up @@ -16590,7 +16590,7 @@
"post": {
"operationId": "room-import-emails-as-participants",
"summary": "Import a list of email attendees",
"description": "Content format is comma separated values: - Header line is required and must match `\"email\",\"name\"` or `\"email\"` - one entry per line\nRequired capability: `email-csv-import`",
"description": "Content format is comma separated values: - Header line is required and must match `\"email\",\"name\"` or `\"email\"` - One entry per line (e.g. `\"John Doe\",\"john@example.tld\"`)\nRequired capability: `email-csv-import`",
"tags": [
"room"
],
Expand Down
2 changes: 1 addition & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -16724,7 +16724,7 @@
"post": {
"operationId": "room-import-emails-as-participants",
"summary": "Import a list of email attendees",
"description": "Content format is comma separated values: - Header line is required and must match `\"email\",\"name\"` or `\"email\"` - one entry per line\nRequired capability: `email-csv-import`",
"description": "Content format is comma separated values: - Header line is required and must match `\"email\",\"name\"` or `\"email\"` - One entry per line (e.g. `\"John Doe\",\"john@example.tld\"`)\nRequired capability: `email-csv-import`",
"tags": [
"room"
],
Expand Down
3 changes: 3 additions & 0 deletions src/__mocks__/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export const mockedCapabilities: Capabilities = {
'archived-conversations',
'talk-polls-drafts',
'archived-conversations-v2',
'download-call-participants',
'email-csv-import',
],
'features-local': [
'favorites',
Expand All @@ -100,6 +102,7 @@ export const mockedCapabilities: Capabilities = {
'note-to-self',
'archived-conversations',
'archived-conversations-v2',
'chat-summary-api',
],
config: {
attachments: {
Expand Down
48 changes: 39 additions & 9 deletions src/components/ConversationSettings/LobbySettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,12 @@
<NcNoteCard v-if="hasCall && !hasLobbyEnabled"
type="warning"
:text="t('spreed', 'Enabling the lobby will remove non-moderators from the ongoing call.')" />
<div>
<NcCheckboxRadioSwitch :checked="hasLobbyEnabled"
type="switch"
:disabled="isLobbyStateLoading"
@update:checked="toggleLobby">
{{ t('spreed', 'Enable lobby, restricting the conversation to moderators') }}
</NcCheckboxRadioSwitch>
</div>
<NcCheckboxRadioSwitch :checked="hasLobbyEnabled"
type="switch"
:disabled="isLobbyStateLoading"
@update:checked="toggleLobby">
{{ t('spreed', 'Enable lobby, restricting the conversation to moderators') }}
</NcCheckboxRadioSwitch>
</div>
<div v-if="hasLobbyEnabled" class="app-settings-subsection">
<form :disabled="lobbyTimerFieldDisabled"
Expand All @@ -43,18 +41,43 @@
</div>
</form>
</div>
<div v-if="supportImportEmails" class="import-email-participants">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Import email participants') }}
</h4>
<div class="app-settings-section__hint">
{{ t('spreed', 'You can import a list of email participants from a CSV file.') }}
</div>
<NcButton @click="isImportEmailsDialogOpen = true">
<template #icon>
<IconFileUpload :size="20" />
</template>
{{ t('spreed', 'Import e-mail participants') }}
</NcButton>

<ImportEmailsDialog v-if="isImportEmailsDialogOpen"
:token="token"
container=".import-email-participants"
@close="isImportEmailsDialogOpen = false" />
</div>
</div>
</template>

<script>
import IconFileUpload from 'vue-material-design-icons/FileUpload.vue'

import { showError, showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'

import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcDateTimePicker from '@nextcloud/vue/dist/Components/NcDateTimePicker.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'

import ImportEmailsDialog from '../ImportEmailsDialog.vue'

import { WEBINAR } from '../../constants.js'
import { hasTalkFeature } from '../../services/CapabilitiesManager.ts'
import { futureRelativeTime } from '../../utils/formattedTime.ts'

const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000
Expand All @@ -63,6 +86,9 @@ export default {
name: 'LobbySettings',

components: {
IconFileUpload,
ImportEmailsDialog,
NcButton,
NcCheckboxRadioSwitch,
NcDateTimePicker,
NcNoteCard,
Expand All @@ -79,6 +105,7 @@ export default {
return {
isLobbyStateLoading: false,
isLobbyTimerLoading: false,
isImportEmailsDialogOpen: false,
}
},

Expand All @@ -99,6 +126,10 @@ export default {
return this.isLobbyStateLoading || this.isLobbyTimerLoading
},

supportImportEmails() {
return hasTalkFeature(this.token, 'email-csv-import')
},

defaultLobbyTimer() {
let date = new Date()
// strip minutes and seconds
Expand Down Expand Up @@ -202,7 +233,6 @@ export default {
</script>

<style lang="scss" scoped>

.lobby_timer {
&--relative {
color: var(--color-text-maxcontrast);
Expand Down
Loading

0 comments on commit dae416f

Please sign in to comment.