feat(customers): email customer when admin adds new gallery access
This commit is contained in:
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add `customer_gallery_assigned` email template.
|
||||||
|
*
|
||||||
|
* Sent when an admin adds new gallery assignments to an existing
|
||||||
|
* customer via the "Manage galleries" dialog on the customer detail
|
||||||
|
* page. Digest-style — one email per save listing every newly added
|
||||||
|
* gallery, not one email per gallery (admins often set up new clients
|
||||||
|
* by adding several galleries in a single sitting).
|
||||||
|
*
|
||||||
|
* Variables:
|
||||||
|
* - customer_name greeting name (display name / first name / email local)
|
||||||
|
* - gallery_count integer (string) — number of newly added galleries
|
||||||
|
* - singular "true" when count === 1 (drives intro wording)
|
||||||
|
* - multiple "true" when count > 1
|
||||||
|
* - gallery_list_html pre-rendered <ul> with names + dates; passes
|
||||||
|
* through unescaped because the service builds it
|
||||||
|
* from trusted DB fields (event_name from
|
||||||
|
* admin-owned rows + server-rendered dates).
|
||||||
|
* - gallery_list_text newline-separated plain-text equivalent for
|
||||||
|
* the text/plain body.
|
||||||
|
* - dashboard_link URL of /customer/dashboard on the configured
|
||||||
|
* frontend origin.
|
||||||
|
*
|
||||||
|
* Category + flag: 'customers' + customerPortal — categorisation
|
||||||
|
* scaffold from migration 098. When the customer portal flag is off,
|
||||||
|
* the Templates admin UI chips this card "Feature off" but it's still
|
||||||
|
* editable.
|
||||||
|
*
|
||||||
|
* Translations: en + de hand-translated; nl/pt/ru/fr machine-generated
|
||||||
|
* and flagged for native review per project convention.
|
||||||
|
*
|
||||||
|
* Idempotent: skips if the template_key already exists.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TRANSLATIONS = {
|
||||||
|
en: {
|
||||||
|
subject: 'New gallery access on your account',
|
||||||
|
body_html: `<h2>You have new gallery access</h2>
|
||||||
|
<p>Hi {{customer_name}},</p>
|
||||||
|
{{#if singular}}<p>Your photographer just gave you access to a new gallery on your account:</p>{{/if}}{{#if multiple}}<p>Your photographer just gave you access to {{gallery_count}} new galleries on your account:</p>{{/if}}
|
||||||
|
{{gallery_list_html}}
|
||||||
|
<p style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{dashboard_link}}" class="button">Open your dashboard</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 13px; color: #666;">If the button doesn't work, copy and paste this link into your browser:<br>
|
||||||
|
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||||
|
body_text: `You have new gallery access
|
||||||
|
|
||||||
|
Hi {{customer_name}},
|
||||||
|
|
||||||
|
Your photographer just gave you access to {{gallery_count}} new gallery (or galleries) on your account:
|
||||||
|
|
||||||
|
{{gallery_list_text}}
|
||||||
|
|
||||||
|
Open your dashboard: {{dashboard_link}}`,
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
subject: 'Neue Galerie in deinem Konto verfügbar',
|
||||||
|
body_html: `<h2>Du hast Zugriff auf neue Galerien</h2>
|
||||||
|
<p>Hallo {{customer_name}},</p>
|
||||||
|
{{#if singular}}<p>Dein Fotograf hat dir gerade Zugriff auf eine neue Galerie in deinem Konto gegeben:</p>{{/if}}{{#if multiple}}<p>Dein Fotograf hat dir gerade Zugriff auf {{gallery_count}} neue Galerien in deinem Konto gegeben:</p>{{/if}}
|
||||||
|
{{gallery_list_html}}
|
||||||
|
<p style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{dashboard_link}}" class="button">Zum Dashboard</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 13px; color: #666;">Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
|
||||||
|
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||||
|
body_text: `Du hast Zugriff auf neue Galerien
|
||||||
|
|
||||||
|
Hallo {{customer_name}},
|
||||||
|
|
||||||
|
Dein Fotograf hat dir gerade Zugriff auf {{gallery_count}} neue Galerie(n) in deinem Konto gegeben:
|
||||||
|
|
||||||
|
{{gallery_list_text}}
|
||||||
|
|
||||||
|
Zum Dashboard: {{dashboard_link}}`,
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
subject: 'Nouvel accès galerie sur votre compte',
|
||||||
|
body_html: `<h2>Vous avez accès à de nouvelles galeries</h2>
|
||||||
|
<p>Bonjour {{customer_name}},</p>
|
||||||
|
{{#if singular}}<p>Votre photographe vient de vous donner accès à une nouvelle galerie sur votre compte :</p>{{/if}}{{#if multiple}}<p>Votre photographe vient de vous donner accès à {{gallery_count}} nouvelles galeries sur votre compte :</p>{{/if}}
|
||||||
|
{{gallery_list_html}}
|
||||||
|
<p style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{dashboard_link}}" class="button">Ouvrir mon tableau de bord</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 13px; color: #666;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
|
||||||
|
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||||
|
body_text: `Vous avez accès à de nouvelles galeries
|
||||||
|
|
||||||
|
Bonjour {{customer_name}},
|
||||||
|
|
||||||
|
Votre photographe vient de vous donner accès à {{gallery_count}} nouvelle(s) galerie(s) sur votre compte :
|
||||||
|
|
||||||
|
{{gallery_list_text}}
|
||||||
|
|
||||||
|
Tableau de bord : {{dashboard_link}}`,
|
||||||
|
},
|
||||||
|
nl: {
|
||||||
|
subject: 'Nieuwe galerij toegevoegd aan uw account',
|
||||||
|
body_html: `<h2>U heeft toegang tot nieuwe galerijen</h2>
|
||||||
|
<p>Hallo {{customer_name}},</p>
|
||||||
|
{{#if singular}}<p>Uw fotograaf heeft u zojuist toegang gegeven tot een nieuwe galerij in uw account:</p>{{/if}}{{#if multiple}}<p>Uw fotograaf heeft u zojuist toegang gegeven tot {{gallery_count}} nieuwe galerijen in uw account:</p>{{/if}}
|
||||||
|
{{gallery_list_html}}
|
||||||
|
<p style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{dashboard_link}}" class="button">Open uw dashboard</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 13px; color: #666;">Werkt de knop niet? Kopieer dan deze link in uw browser:<br>
|
||||||
|
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||||
|
body_text: `U heeft toegang tot nieuwe galerijen
|
||||||
|
|
||||||
|
Hallo {{customer_name}},
|
||||||
|
|
||||||
|
Uw fotograaf heeft u zojuist toegang gegeven tot {{gallery_count}} nieuwe galerij(en) in uw account:
|
||||||
|
|
||||||
|
{{gallery_list_text}}
|
||||||
|
|
||||||
|
Dashboard: {{dashboard_link}}`,
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
subject: 'Nova galeria disponível em sua conta',
|
||||||
|
body_html: `<h2>Você tem acesso a novas galerias</h2>
|
||||||
|
<p>Olá {{customer_name}},</p>
|
||||||
|
{{#if singular}}<p>Seu fotógrafo acabou de lhe dar acesso a uma nova galeria em sua conta:</p>{{/if}}{{#if multiple}}<p>Seu fotógrafo acabou de lhe dar acesso a {{gallery_count}} novas galerias em sua conta:</p>{{/if}}
|
||||||
|
{{gallery_list_html}}
|
||||||
|
<p style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{dashboard_link}}" class="button">Abrir meu painel</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 13px; color: #666;">Se o botão não funcionar, copie este link no navegador:<br>
|
||||||
|
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||||
|
body_text: `Você tem acesso a novas galerias
|
||||||
|
|
||||||
|
Olá {{customer_name}},
|
||||||
|
|
||||||
|
Seu fotógrafo acabou de lhe dar acesso a {{gallery_count}} nova(s) galeria(s) em sua conta:
|
||||||
|
|
||||||
|
{{gallery_list_text}}
|
||||||
|
|
||||||
|
Painel: {{dashboard_link}}`,
|
||||||
|
},
|
||||||
|
ru: {
|
||||||
|
subject: 'Новая галерея доступна в вашем аккаунте',
|
||||||
|
body_html: `<h2>У вас новый доступ к галереям</h2>
|
||||||
|
<p>Здравствуйте, {{customer_name}}!</p>
|
||||||
|
{{#if singular}}<p>Ваш фотограф только что предоставил вам доступ к новой галерее в вашем аккаунте:</p>{{/if}}{{#if multiple}}<p>Ваш фотограф только что предоставил вам доступ к {{gallery_count}} новым галереям в вашем аккаунте:</p>{{/if}}
|
||||||
|
{{gallery_list_html}}
|
||||||
|
<p style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{dashboard_link}}" class="button">Открыть мой кабинет</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 13px; color: #666;">Если кнопка не работает, скопируйте эту ссылку в браузер:<br>
|
||||||
|
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||||
|
body_text: `У вас новый доступ к галереям
|
||||||
|
|
||||||
|
Здравствуйте, {{customer_name}}!
|
||||||
|
|
||||||
|
Ваш фотограф только что предоставил вам доступ к {{gallery_count}} новым галереям в вашем аккаунте:
|
||||||
|
|
||||||
|
{{gallery_list_text}}
|
||||||
|
|
||||||
|
Личный кабинет: {{dashboard_link}}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||||
|
|
||||||
|
const existing = await knex('email_templates')
|
||||||
|
.where({ template_key: 'customer_gallery_assigned' })
|
||||||
|
.first();
|
||||||
|
if (existing) {
|
||||||
|
console.log(' customer_gallery_assigned template already exists, skipping insert');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect schema variant (legacy per-column vs normalized translations).
|
||||||
|
// Newer installs have the email_template_translations table from
|
||||||
|
// migration 075; older ones might still have subject_en/de/... columns
|
||||||
|
// and NOT NULL constraints on the legacy columns. Cover both.
|
||||||
|
const cols = await knex('email_templates').columnInfo();
|
||||||
|
const hasTranslationsTable = await knex.schema.hasTable('email_template_translations');
|
||||||
|
|
||||||
|
const enContent = TRANSLATIONS.en;
|
||||||
|
|
||||||
|
// Build the master row. category/subcategory/feature_flag columns
|
||||||
|
// were added in migration 098 — guard so this migration works on
|
||||||
|
// a slightly older install too.
|
||||||
|
const masterRow = {
|
||||||
|
template_key: 'customer_gallery_assigned',
|
||||||
|
variables: JSON.stringify([
|
||||||
|
'customer_name',
|
||||||
|
'gallery_count',
|
||||||
|
'singular',
|
||||||
|
'multiple',
|
||||||
|
'gallery_list_html',
|
||||||
|
'gallery_list_text',
|
||||||
|
'dashboard_link',
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
if ('category' in cols) masterRow.category = 'customers';
|
||||||
|
if ('subcategory' in cols) masterRow.subcategory = null;
|
||||||
|
if ('feature_flag' in cols) masterRow.feature_flag = 'customerPortal';
|
||||||
|
if ('created_at' in cols) masterRow.created_at = new Date();
|
||||||
|
if ('updated_at' in cols) masterRow.updated_at = new Date();
|
||||||
|
|
||||||
|
// Populate any legacy subject_*/body_html_*/body_text_* columns the
|
||||||
|
// schema still carries. Fallback content for non-en locales is the
|
||||||
|
// English string — the translations table below has the real
|
||||||
|
// per-locale copy. This only matters if the install hasn't run
|
||||||
|
// migration 075 yet, which is rare but possible.
|
||||||
|
for (const colName of Object.keys(cols)) {
|
||||||
|
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
|
||||||
|
masterRow[colName] = enContent.subject;
|
||||||
|
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
|
||||||
|
masterRow[colName] = enContent.body_html;
|
||||||
|
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
|
||||||
|
masterRow[colName] = enContent.body_text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await knex('email_templates').insert(masterRow).returning('id');
|
||||||
|
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||||
|
|
||||||
|
if (hasTranslationsTable && templateId) {
|
||||||
|
for (const [language, content] of Object.entries(TRANSLATIONS)) {
|
||||||
|
await knex('email_template_translations').insert({
|
||||||
|
template_id: templateId,
|
||||||
|
language,
|
||||||
|
subject: content.subject,
|
||||||
|
body_html: content.body_html,
|
||||||
|
body_text: content.body_text,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(' customer_gallery_assigned template inserted with 6 translations');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||||
|
await knex('email_templates').where({ template_key: 'customer_gallery_assigned' }).del();
|
||||||
|
};
|
||||||
@@ -671,6 +671,11 @@ async function setAssignmentsForCustomer(customerId, targetEventIds, adminId, tr
|
|||||||
.del();
|
.del();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect the event IDs that actually landed in the DB (i.e. survived
|
||||||
|
// the archived/missing filter) so the post-commit notifier knows
|
||||||
|
// exactly which galleries to mention in the email. Empty by default.
|
||||||
|
let addedEventIds = [];
|
||||||
|
|
||||||
if (toAdd.length > 0) {
|
if (toAdd.length > 0) {
|
||||||
// Validate the events exist + are not archived before inserting.
|
// Validate the events exist + are not archived before inserting.
|
||||||
// Mirrors the customer-side check in setAssignmentsForEvent so an
|
// Mirrors the customer-side check in setAssignmentsForEvent so an
|
||||||
@@ -695,10 +700,121 @@ async function setAssignmentsForCustomer(customerId, targetEventIds, adminId, tr
|
|||||||
}));
|
}));
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
await trx('event_customer_assignments').insert(rows);
|
await trx('event_customer_assignments').insert(rows);
|
||||||
|
addedEventIds = rows.map((r) => r.event_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { added: toAdd.length, removed: toRemove.length };
|
// Notify the customer about newly-accessible galleries. Best-effort
|
||||||
|
// — a failure here must not roll back the assignment write, so we
|
||||||
|
// fire-and-forget after the transactional work is done and swallow
|
||||||
|
// any throw with a warn log. Skipped when no new rows were added.
|
||||||
|
if (addedEventIds.length > 0) {
|
||||||
|
notifyCustomerOfNewAssignments(customerId, addedEventIds).catch((err) => {
|
||||||
|
logger.warn('Failed to queue customer_gallery_assigned email', {
|
||||||
|
customerId, addedEventIds, error: err?.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { added: toAdd.length, removed: toRemove.length, addedEventIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a `customer_gallery_assigned` email summarising newly-granted
|
||||||
|
* gallery access for one customer. Called by setAssignmentsForCustomer
|
||||||
|
* after the transaction commits.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - One email per save (digest), not one per gallery.
|
||||||
|
* - Archived + expired events are filtered out — the customer would
|
||||||
|
* hit a "this gallery has expired" notice anyway, so naming them
|
||||||
|
* in the email just confuses people.
|
||||||
|
* - Deactivated customers (is_active=false) get no email — their
|
||||||
|
* login is off, so a "you have new access" message would be
|
||||||
|
* misleading.
|
||||||
|
* - Customers without an email on file are skipped (silently —
|
||||||
|
* should never happen for accepted accounts but defensive).
|
||||||
|
* - Email failures are logged but never bubble up; the caller's
|
||||||
|
* `.catch` handler logs again at a more specific call site.
|
||||||
|
*/
|
||||||
|
async function notifyCustomerOfNewAssignments(customerId, addedEventIds) {
|
||||||
|
if (!addedEventIds || addedEventIds.length === 0) return;
|
||||||
|
|
||||||
|
const customer = await db('customer_accounts')
|
||||||
|
.where({ id: customerId, is_active: formatBoolean(true) })
|
||||||
|
.select('id', 'email', 'display_name', 'first_name', 'preferred_language')
|
||||||
|
.first();
|
||||||
|
if (!customer || !customer.email) {
|
||||||
|
logger.info('Skip customer_gallery_assigned email: customer missing/inactive/no email', {
|
||||||
|
customerId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter the added events to those the customer can actually open.
|
||||||
|
// Archived events are hard-skipped; expired ones (expires_at in the
|
||||||
|
// past) would render as "Expired DD MMM" in the dashboard and lead
|
||||||
|
// to a confusing "I clicked the link in the email and got a 410"
|
||||||
|
// experience — drop those too.
|
||||||
|
const now = new Date();
|
||||||
|
const events = await db('events')
|
||||||
|
.whereIn('id', addedEventIds)
|
||||||
|
.where('is_archived', formatBoolean(false))
|
||||||
|
.andWhere(function() {
|
||||||
|
this.whereNull('expires_at').orWhere('expires_at', '>', now);
|
||||||
|
})
|
||||||
|
.orderBy('event_date', 'desc')
|
||||||
|
.select('id', 'slug', 'event_name', 'event_date');
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
logger.info('Skip customer_gallery_assigned email: all added events archived/expired', {
|
||||||
|
customerId, addedEventIds,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the gallery list block. HTML is whitelisted via
|
||||||
|
// HTML_PASSTHROUGH_KEYS in emailProcessor so the <ul> survives the
|
||||||
|
// body-html escaping pass. Names + dates come from admin-controlled
|
||||||
|
// DB rows; the date is server-rendered.
|
||||||
|
const { formatDate } = require('../utils/dateFormatter');
|
||||||
|
const { escapeHtml } = require('../utils/formatters');
|
||||||
|
const language = customer.preferred_language || 'en';
|
||||||
|
|
||||||
|
const formattedRows = await Promise.all(events.map(async (ev) => ({
|
||||||
|
name: ev.event_name || ev.slug,
|
||||||
|
date: ev.event_date ? await formatDate(ev.event_date, language) : '',
|
||||||
|
})));
|
||||||
|
|
||||||
|
const galleryListHtml = `<ul>\n${
|
||||||
|
formattedRows.map((r) => {
|
||||||
|
const safeName = escapeHtml(r.name);
|
||||||
|
const safeDate = r.date ? ` — ${escapeHtml(r.date)}` : '';
|
||||||
|
return ` <li>${safeName}${safeDate}</li>`;
|
||||||
|
}).join('\n')
|
||||||
|
}\n</ul>`;
|
||||||
|
|
||||||
|
const galleryListText = formattedRows
|
||||||
|
.map((r) => r.date ? `- ${r.name} (${r.date})` : `- ${r.name}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
|
||||||
|
const customerName = customer.display_name?.trim()
|
||||||
|
|| customer.first_name?.trim()
|
||||||
|
|| (customer.email ? customer.email.split('@')[0] : '');
|
||||||
|
|
||||||
|
await queueEmail(null, customer.email, 'customer_gallery_assigned', {
|
||||||
|
customer_name: customerName,
|
||||||
|
gallery_count: String(events.length),
|
||||||
|
// `singular` / `multiple` drive the {{#if}} blocks in the
|
||||||
|
// template — safeTemplateReplace treats anything non-empty +
|
||||||
|
// non-false as truthy, so passing literal 'true' / '' works.
|
||||||
|
singular: events.length === 1 ? 'true' : '',
|
||||||
|
multiple: events.length > 1 ? 'true' : '',
|
||||||
|
gallery_list_html: galleryListHtml,
|
||||||
|
gallery_list_text: galleryListText,
|
||||||
|
dashboard_link: `${frontendUrl}/customer/dashboard`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -388,9 +388,14 @@ function htmlToText(html) {
|
|||||||
// else (event_name, host_name, customer_name, …) is admin-supplied free
|
// else (event_name, host_name, customer_name, …) is admin-supplied free
|
||||||
// text and gets escaped to prevent stored-HTML injection in customer mail.
|
// text and gets escaped to prevent stored-HTML injection in customer mail.
|
||||||
const HTML_PASSTHROUGH_KEYS = new Set([
|
const HTML_PASSTHROUGH_KEYS = new Set([
|
||||||
'welcome_message', // already HTML (formatWelcomeMessage escapes + nl2br)
|
'welcome_message', // already HTML (formatWelcomeMessage escapes + nl2br)
|
||||||
'gallery_link', // server-generated URL (adminEvents.js)
|
'gallery_link', // server-generated URL (adminEvents.js)
|
||||||
'client_link', // server-generated URL (adminEvents.js)
|
'client_link', // server-generated URL (adminEvents.js)
|
||||||
|
// customer_gallery_assigned template (#354 follow-up): server-rendered
|
||||||
|
// <ul> of newly-added galleries. Built in customerAccountsService from
|
||||||
|
// trusted DB rows (event_name comes from admin-owned events; the date
|
||||||
|
// is server-rendered) — escaping it here would double-escape the markup.
|
||||||
|
'gallery_list_html',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { escapeHtml } = require('../utils/formatters');
|
const { escapeHtml } = require('../utils/formatters');
|
||||||
|
|||||||
Reference in New Issue
Block a user