Skip to content

Commit

Permalink
Merge pull request #242 from shaunwarman/feature/enhance-support
Browse files Browse the repository at this point in the history
feat: add inquiries page to admin UI
  • Loading branch information
titanism committed May 1, 2024
2 parents 9c7b32b + 42dfb42 commit e094ae6
Show file tree
Hide file tree
Showing 15 changed files with 509 additions and 11 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
*.awspublish*
*.env
.env*
!.gulpfile.env
!test/.env
*.lcov
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/web/admin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const logs = require('./logs');
const allowlist = require('./allowlist');
const denylist = require('./denylist');
const emails = require('./emails');
const inquiries = require('./inquiries');

module.exports = {
dashboard,
Expand All @@ -18,5 +19,6 @@ module.exports = {
logs,
allowlist,
denylist,
emails
emails,
inquiries
};
211 changes: 211 additions & 0 deletions app/controllers/web/admin/inquiries.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* Copyright (c) Forward Email LLC
* SPDX-License-Identifier: BUSL-1.1
*/

const Boom = require('@hapi/boom');
const paginate = require('koa-ctx-paginate');

const { Inquiries, Users } = require('#models');
const config = require('#config');
const email = require('#helpers/email');

async function list(ctx) {
let $sort = { created_at: -1 };
if (ctx.query.sort) {
const order = ctx.query.sort.startsWith('-') ? -1 : 1;
$sort = {
[order === -1 ? ctx.query.sort.slice(1) : ctx.query.sort]: order
};
}

const [inquiries, itemCount] = await Promise.all([
Inquiries.aggregate([
{
$match: {
$or: [{ is_resolved: { $exists: false } }, { is_resolved: false }]
}
},
{
$lookup: {
from: 'users',
localField: 'user',
foreignField: '_id',
as: 'user'
}
},
{
$unwind: '$user'
},
{
$project: {
id: 1,
message: 1,
created_at: 1,
updated_at: 1,
email: '$user.email',
plan: '$user.plan'
}
},
{
$sort
},
{
$skip: ctx.paginate.skip
},
{
$limit: ctx.query.limit
}
]).exec(),
Inquiries.countDocuments()
]);

const pageCount = Math.ceil(itemCount / ctx.query.limit);

if (ctx.accepts('html'))
return ctx.render('admin/inquiries', {
inquiries,
pageCount,
itemCount,
pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page)
});

const table = await ctx.render('admin/inquiries/_table', {
inquiries,
pageCount,
itemCount,
pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page)
});

ctx.body = { table };
}

async function retrieve(ctx) {
ctx.state.result = await Inquiries.findById(ctx.params.id);
if (!ctx.state.result)
throw Boom.notFound(ctx.translateError('INVALID_INQUIRY'));
return ctx.render('admin/inquiries/retrieve');
}

async function remove(ctx) {
const inquiry = await Inquiries.findById(ctx.params.id);
if (!inquiry) throw Boom.notFound(ctx.translateError('INVALID_INQUIRY'));

await Inquiries.deleteOne({ _id: inquiry.id });

ctx.flash('custom', {
title: ctx.request.t('Success'),
text: ctx.translate('REQUEST_OK'),
type: 'success',
toast: true,
showConfirmButton: false,
timer: 3000,
position: 'top'
});

if (ctx.accepts('html')) ctx.redirect('back');
else ctx.body = { reloadPage: true };
}

async function reply(ctx) {
const inquiry = await Inquiries.findById(ctx.params.id);
if (!inquiry) throw Boom.notFound(ctx.translateError('INVALID_INQUIRY'));

const user = await Users.findById(inquiry.user);
if (!user) throw Boom.notFound(ctx.translateError('INVALID_USER'));

const { message } = ctx.request.body;

await email({
template: 'inquiry-response',
message: {
to: user[config.userFields.fullEmail],
cc: config.email.message.from,
inReplyTo: inquiry.references[0],
references: inquiry.references,
subject: inquiry.subject
},
locals: {
user: user.toObject(),
inquiry,
response: { message }
}
});

await Inquiries.findOneAndUpdate(
{ id: inquiry.id },
{
$set: { is_resolved: true }
}
);

ctx.flash('custom', {
title: ctx.request.t('Success'),
text: ctx.translate('REQUEST_OK'),
type: 'success',
toast: true,
showConfirmButton: false,
timer: 3000,
position: 'top'
});

if (ctx.accepts('html')) ctx.redirect('/admin/inquiries');
else ctx.body = { redirectTo: '/admin/inquiries' };
}

async function bulkReply(ctx) {
const { ids, message } = ctx.request.body;

const repliedTo = new Set();

try {
for (const id of ids) {
// eslint-disable-next-line no-await-in-loop
const inquiry = await Inquiries.findById(id);
if (!inquiry) throw Boom.notFound(ctx.translateError('INVALID_INQUIRY'));

// eslint-disable-next-line no-await-in-loop
const user = await Users.findById(inquiry.user);
if (!user) throw Boom.notFound(ctx.translateError('INVALID_USER'));

// if the user has multiple inquiries and we've just responded
// in bulk to a previous message then let's skip the email
if (!repliedTo.has(user)) {
// eslint-disable-next-line no-await-in-loop
await email({
template: 'inquiry-response',
message: {
to: user[config.userFields.fullEmail],
cc: config.email.message.from,
inReplyTo: inquiry.references[0],
references: inquiry.references,
subject: inquiry.subject
},
locals: {
user: user.toObject(),
inquiry,
response: { message }
}
});
}

// eslint-disable-next-line no-await-in-loop
await Inquiries.findOneAndUpdate(
{ id: inquiry.id },
{
$set: { is_resolved: true }
}
);

repliedTo.add(user);
}
} catch (err) {
ctx.flash('error', `Error replying: ${err.message}`);
return;
}

if (ctx.accepts('html')) ctx.redirect('/admin/inquiries');
else ctx.body = { redirectTo: '/admin/inquiries' };
}

module.exports = { list, retrieve, remove, reply, bulkReply };
11 changes: 10 additions & 1 deletion app/controllers/web/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async function help(ctx) {

ctx.logger.debug('created inquiry', { inquiry });

await email({
const emaild = await email({
template: 'inquiry',
message: {
to: ctx.state.user[config.userFields.fullEmail],
Expand All @@ -56,6 +56,15 @@ async function help(ctx) {
}
});

const { subject } = JSON.parse(emaild.message);

await Inquiries.findOneAndUpdate(
{ id: inquiry.id },
{
$set: { references: [emaild.messageId], subject }
}
);

const message = ctx.translate('SUPPORT_REQUEST_SENT');
if (ctx.accepts('html')) {
ctx.flash('success', message);
Expand Down
13 changes: 13 additions & 0 deletions app/models/inquiries.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ const Inquiries = new mongoose.Schema({
is_denylist: {
type: Boolean,
default: false
},
is_resolved: {
type: Boolean,
required: false,
default: false
},
references: {
type: Array,
required: false
},
subject: {
type: String,
required: false
}
});

Expand Down
4 changes: 4 additions & 0 deletions app/views/_nav.pug
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,10 @@ nav.navbar(class=navbarClasses.join(" "))
class=ctx.pathWithoutLocale.startsWith("/admin/domains") ? "active" : "",
href=l("/admin/domains")
)= t("Domains")
a.dropdown-item(
class=ctx.pathWithoutLocale.startsWith("/admin/inquiries") ? "active" : "",
href=l("/admin/inquiries")
)= t("Inquiries")
a.dropdown-item(
class=ctx.pathWithoutLocale.startsWith("/admin/emails") ? "active" : "",
href=l("/admin/emails")
Expand Down
102 changes: 102 additions & 0 deletions app/views/admin/inquiries/_table.pug
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
include ../../_sort-header
include ../../_pagination

.table-responsive
table.table.table-hover.table-bordered.table-sm
thead.thead-dark
tr
th.text-center.align-middle(scope="col")= t("Selected")
th(scope="col")
+sortHeader('email', null, '#table-inquiries')
th.text-center.align-middle(scope="col")= t("Message")
th(scope="col")
+sortHeader('plan', null, '#table-inquiries')
th(scope="col")
+sortHeader('created_at', 'Created', '#table-inquiries')
th(scope="col")
+sortHeader('updated_at', 'Updated', '#table-inquiries')
if passport && passport.otp
th(scope="col")
+sortHeader(config.passport.fields.otpEnabled, 'OTP Enabled', '#table-inquiries')
th.text-center.align-middle(scope="col")= t("Actions")
tbody
if inquiries.length === 0
td.alert.alert-info= t("No inquiries exist for that search.")
else
each inquiry in inquiries
tr
td.align-middle.text-center
.form-group.form-check.form-check-inline.mb-0
input#is-inquiry-selected.form-check-input(
type="checkbox",
name="is_inquiry_selected",
value=inquiry.id
)
td.align-middle
a(
href=`mailto:${inquiry.email}`,
target="_blank",
rel="noopener noreferrer"
)= inquiry.email
td.align-middle
=inquiry.message
td.align-middle
=inquiry.plan
td.align-middle.dayjs(
data-time=new Date(inquiry.created_at).getTime()
)= dayjs(inquiry.created_at).format("M/D/YY h:mm A z")
td.align-middle.dayjs(
data-time=new Date(inquiry.updated_at).getTime()
)= dayjs(inquiry.updated_at).format("M/D/YY h:mm A z")
td.align-middle
.btn-group(role="group", aria-label=t("Actions"))
a.btn.btn-secondary(
href=l(`/admin/inquiries/${inquiry.id}`),
data-toggle="tooltip",
data-title=t("Edit")
): i.fa.fa-fw.fa-edit
form.ajax-form.confirm-prompt.btn-group(
action=l(`/admin/inquiries/${inquiry.id}`),
method="POST",
autocomplete="off"
)
input(type="hidden", name="_method", value="DELETE")
button.btn.btn-danger(
type="submit",
data-toggle="tooltip",
data-title=t("Remove")
): i.fa.fa-fw.fa-remove
button#bulk-reply-button.btn.btn-secondary.float-right.mb-3 Bulk Reply
#bulk-reply-modal.modal.fade(
tabindex="-1",
role="dialog",
aria-labelledby="modal-bulk-reply-title",
aria-hidden="true"
)
.modal-dialog(role="document")
.modal-content
.modal-header.text-center.d-block
h4#modal-bulk-reply-title.d-inline-block.ml-4= t("Bulk Reply")
button.close(
type="button",
data-dismiss="modal",
aria-label="Close"
)
span(aria-hidden="true") ×
.modal-body
.text-center
form.form-group
label(for="bulk-reply-message")
h5= t("Message")
= " "
textarea#textarea-bulk-reply-message.form-control(
name="bulk-reply-message",
maxlength=300,
rows=8
)
p.form-text.small.text-black.text-themed-50= t("Message has a max of 300 characters.")
button.btn.btn-lg.btn-block.btn-primary(type='button' id='submit-bulk-reply')
i.fa.fa-edit
= " "
= t("Submit Bulk Reply")
+paginate('#table-inquiries')
Loading

0 comments on commit e094ae6

Please sign in to comment.