-
Notifications
You must be signed in to change notification settings - Fork 106
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
2c5db33
commit 5b5cb7b
Showing
18 changed files
with
521 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
*.awspublish* | ||
*.env | ||
.env* | ||
!.gulpfile.env | ||
!test/.env | ||
*.lcov | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') |
Oops, something went wrong.