Skip to content

Commit

Permalink
feat: Add bulk messaging and email capabilities in admin panel (#1294)
Browse files Browse the repository at this point in the history
- Create `AdminSendBulkSms.vue` for sending bulk SMS
- Add `AdminSendBulkSms` route and button in `AdminDashboard.vue`
- Create `AdminSendBulkEmail.vue` for sending bulk emails
- Add `AdminSendBulkEmail` route and button in `AdminDashboard.vue`
- Update axios default baseURL configuration in `main.ts`
  • Loading branch information
tabiodun authored Nov 11, 2024
1 parent 733f7d2 commit 68d5ea4
Show file tree
Hide file tree
Showing 5 changed files with 373 additions and 3 deletions.
5 changes: 3 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import axios, { AxiosError } from 'axios';
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import VueTagsInput from '@sipec/vue3-tags-input';
// eslint-disable-next-line import/default

import Datepicker from '@vuepic/vue-datepicker';
import * as Sentry from '@sentry/vue';
import { fas } from '@fortawesome/free-solid-svg-icons';
Expand All @@ -15,7 +15,7 @@ import Toast, {
type PluginOptions as VueToastificationPluginOptions,
} from 'vue-toastification';
import { i18n } from '@/modules/i18n';
// eslint-disable-next-line import/default

import vSelect from 'vue-select';
import App from './App.vue';
import MaintenanceApp from './maintenance/App.vue';
Expand Down Expand Up @@ -64,6 +64,7 @@ library.add(far);
library.add(fab);

axios.defaults.withCredentials = true;
axios.defaults.baseURL = import.meta.env.VITE_APP_API_BASE_URL;
axios.interceptors.response.use(
(response) => response,
(error) => {
Expand Down
17 changes: 16 additions & 1 deletion src/pages/admin/AdminDashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,22 @@
:text="$t('~~Show God Mode')"
:alt="$t('~~Show God Mode')"
:action="() => $router.push('/admin/god_mode')"
></base-button>
/>

<base-button
size="medium"
variant="solid"
:text="$t('~~Send Bulk Sms')"
:alt="$t('~~Send Bulk Sms')"
:action="() => $router.push('/admin/send_bulk_sms')"
/>
<base-button
size="medium"
variant="solid"
:text="$t('~~Send Bulk Email')"
:alt="$t('~~Send Bulk Email')"
:action="() => $router.push('/admin/send_bulk_email')"
/>
</div>
</div>
<div class="flex" data-testid="testPendingOrganizationsDiv">
Expand Down
190 changes: 190 additions & 0 deletions src/pages/admin/AdminSendBulkEmail.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<!-- SendBulkEmail.vue -->
<template>
<div class="send-bulk-email">
<h2>{{ $t('Send Bulk Email') }}</h2>
<base-input
v-model="subject"
:placeholder="$t('Enter email subject')"
class="mb-2"
/>
<base-input
v-model="htmlMessage"
:placeholder="$t('Enter email message')"
text-area
rows="5"
class="mb-2"
/>
<base-input
v-model="emailList"
:placeholder="$t('Enter recipient emails, one per line')"
text-area
rows="5"
class="mb-2"
/>
<!-- Display invalid emails if any -->
<div v-if="invalidEmailsList.length > 0" class="mt-2">
<p class="text-red-500">
{{ $t('The following emails were invalid and have been removed:') }}
</p>
<ul class="list-disc list-inside text-red-500">
<li v-for="email in invalidEmailsList" :key="email">{{ email }}</li>
</ul>
</div>
<div class="flex gap-2">
<base-button
:action="sendEmails"
:alt="$t('Send Emails')"
variant="solid"
class="px-2 py-1 mt-4"
>
{{ $t('Send Emails') }}
</base-button>
<base-button
type="bare"
class="px-2 py-1 mt-4"
variant="outline"
:action="showPreview"
:text="$t('actions.show_preview')"
:alt="$t('actions.show_preview')"
/>
</div>
<div v-if="taskStatus" class="mt-4">
<p>{{ $t('Task Status') }}: {{ taskStatus.state }}</p>
<div v-if="taskStatus.state === 'SUCCESS'">
<p>{{ $t('Emails sent successfully.') }}</p>
</div>
<div v-else-if="taskStatus.state === 'FAILURE'">
<p>{{ $t('Failed to send emails.') }}</p>
</div>
<div v-else>
<p>{{ $t('Processing...') }}</p>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import axios from 'axios';
import { useI18n } from 'vue-i18n';
import { useToast } from 'vue-toastification';
import BaseInput from '@/components/BaseInput.vue';
import BaseButton from '@/components/BaseButton.vue';
import CmsViewer from '@/components/cms/CmsViewer.vue';
import useDialogs from '@/hooks/useDialogs';
import { ref } from 'vue';
import { getErrorMessage } from '@/utils/errors';

const { t } = useI18n();
const $toasted = useToast();
const { component } = useDialogs();

const subject = ref('');
const htmlMessage = ref('');
const emailList = ref('');
const invalidEmailsList = ref<string[]>([]);
const taskStatus = ref(null);
let statusInterval = null;
const editor = ref<HTMLElement | null>(null);

const sendEmails = async () => {
if (!subject.value || !htmlMessage.value || !emailList.value.trim()) {
$toasted.error(
t('Please fill all fields and add at least one recipient email.'),
);
return;
}

const emails = emailList.value
.split('\n')
.map((email) => email.trim())
.filter((email) => email !== '');

// Validate emails
const invalidEmails = emails.filter((email) => !validateEmail(email));
const validEmails = emails.filter((email) => validateEmail(email));

if (invalidEmails.length > 0) {
// Remove invalid emails from emailList
emailList.value = validEmails.join('\n');
// Update invalidEmailsList
invalidEmailsList.value = invalidEmails;
if (validEmails.length === 0) {
$toasted.error(t('All emails were invalid and have been removed.'));
} else {
$toasted.error(
t('Invalid email addresses have been removed: ') +
invalidEmails.join(', ') +
'. ' +
t('Please click Send Emails again.'),
);
}
return;
}

if (validEmails.length === 0) {
$toasted.error(t('No valid email addresses to send.'));
return;
}

try {
const response = await axios.post(`admins/send_bulk_email`, {
emails: validEmails,
subject: subject.value,
html_message: htmlMessage.value,
});

if (response.status === 202) {
$toasted.success(t('Emails are being sent.'));
const taskId = response.data.task_id;
checkTaskStatus(taskId);
invalidEmailsList.value = [];
}
} catch (error) {
$toasted.error(getErrorMessage(error));
}
};

const validateEmail = (email: string) => {
const emailRegex =
/^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@(([^\s"(),.:;<>@[\\\]]+\.)+[^\s"(),.:;<>@[\\\]]{2,})$/i;
return emailRegex.test(email);
};

const checkTaskStatus = (taskId: string) => {
if (statusInterval) {
clearInterval(statusInterval);
}
statusInterval = setInterval(async () => {
try {
const statusResponse = await axios.get(`tasks/${taskId}/`);
taskStatus.value = statusResponse.data;
if (['SUCCESS', 'FAILURE'].includes(taskStatus.value.state)) {
clearInterval(statusInterval);
}
} catch {
$toasted.error(t('Failed to check task status.'));
clearInterval(statusInterval);
}
}, 5000);
};

const showPreview = async () => {
await component({
title: t('Email Preview'),
component: CmsViewer,
classes: 'w-full h-96 overflow-auto p-3',
modalClasses: 'bg-white max-w-3xl shadow',
props: {
title: subject.value,
content: htmlMessage.value,
},
});
};
</script>

<style scoped>
.wysiwyg-editor {
min-height: 150px;
outline: none;
}
</style>
152 changes: 152 additions & 0 deletions src/pages/admin/AdminSendBulkSms.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<!-- SendBulkSMS.vue -->
<template>
<div class="send-bulk-sms">
<h2>{{ $t('Send Bulk SMS') }}</h2>
<base-input
v-model="messageText"
:placeholder="$t('Enter SMS message')"
text-area
rows="5"
class="mb-2"
/>
<base-input
v-model="phoneNumberList"
:placeholder="$t('Enter recipient phone numbers, one per line')"
text-area
rows="5"
class="mb-2"
/>
<div class="flex gap-2">
<base-button
:action="sendSMS"
:alt="$t('Send SMS')"
variant="solid"
class="px-2 py-1 mt-4"
>
{{ $t('Send SMS') }}
</base-button>
<base-button
type="bare"
class="px-2 py-1 mt-4"
variant="outline"
:action="showPreview"
:text="$t('actions.show_preview')"
:alt="$t('actions.show_preview')"
/>
</div>
<div v-if="taskStatus" class="mt-4">
<p>{{ $t('Task Status') }}: {{ taskStatus.state }}</p>
<div v-if="taskStatus.state === 'SUCCESS'">
<p>{{ $t('SMS messages sent successfully.') }}</p>
</div>
<div v-else-if="taskStatus.state === 'FAILURE'">
<p>{{ $t('Failed to send SMS messages.') }}</p>
</div>
<div v-else>
<p>{{ $t('Processing...') }}</p>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import axios from 'axios';
import { useI18n } from 'vue-i18n';
import { useToast } from 'vue-toastification';
import BaseInput from '@/components/BaseInput.vue';
import BaseButton from '@/components/BaseButton.vue';
import CmsViewer from '@/components/cms/CmsViewer.vue';
import useDialogs from '@/hooks/useDialogs';

const { t } = useI18n();
const $toasted = useToast();
const { component } = useDialogs();

const messageText = ref('');
const phoneNumberList = ref('');
const taskStatus = ref(null);
let statusInterval = null;

const sendSMS = async () => {
if (!messageText.value.trim() || !phoneNumberList.value.trim()) {
$toasted.error(
t('Please enter a message and add at least one recipient phone number.'),
);
return;
}

const phoneNumbers = phoneNumberList.value
.split('\n')
.map((number) => number.trim())
.filter((number) => number !== '');

// Validate phone numbers
const invalidNumbers = phoneNumbers.filter(
(number) => !validatePhoneNumber(number),
);

if (invalidNumbers.length > 0) {
$toasted.error(t('Invalid phone numbers: ') + invalidNumbers.join(', '));
return;
}

try {
const response = await axios.post(`admins/send_bulk_sms`, {
phone_numbers: phoneNumbers,
message_text: messageText.value,
});
$toasted.success(t('SMS messages are being sent.'));
const taskId = response.data.task_id;
checkTaskStatus(taskId);
} catch (error) {
const errorMessage =
error.response?.data?.detail || t('Failed to send SMS messages.');
$toasted.error(errorMessage);
}
};

const validatePhoneNumber = (number: string) => {
const phoneRegex = /^\+?[1-9]\d{1,14}$/; // E.164 format
return phoneRegex.test(number);
};

const checkTaskStatus = (taskId: string) => {
if (statusInterval) {
clearInterval(statusInterval);
}
statusInterval = setInterval(async () => {
try {
const statusResponse = await axios.get(`tasks/${taskId}/`);
taskStatus.value = statusResponse.data;
if (['SUCCESS', 'FAILURE'].includes(taskStatus.value.state)) {
clearInterval(statusInterval);
}
} catch {
$toasted.error(t('Failed to check task status.'));
clearInterval(statusInterval);
}
}, 5000);
};

const showPreview = async () => {
if (!messageText.value.trim()) {
$toasted.error(t('Please enter a message to preview.'));
return;
}

await component({
title: t('SMS Preview'),
component: CmsViewer,
classes: 'w-full h-96 overflow-auto p-3',
modalClasses: 'bg-white max-w-md shadow',
props: {
content: messageText.value,
},
});
};
</script>

<style scoped>
/* Add any styles if necessary */
</style>
Loading

0 comments on commit 68d5ea4

Please sign in to comment.