Commit 6abaf27a authored by Benjamin Bouvier's avatar Benjamin Bouvier
Browse files

Translate content by user, not globally, on the server

parent 38703b07
Pipeline #432318 passed with stage
in 4 minutes
......@@ -5,14 +5,10 @@
/* eslint no-console: 0 */
export {
maybeHas,
setupTranslator,
translate,
currency,
localeComparator,
UNKNOWN_ACCOUNT_TYPE,
UNKNOWN_OPERATION_TYPE,
INTERNAL_TRANSFER_TYPE,
formatDate,
MIN_WOOB_VERSION,
UNKNOWN_WOOB_VERSION,
validatePassword,
......@@ -27,7 +23,15 @@ import { toast } from 'react-toastify';
import { useSelector } from 'react-redux';
import { GlobalState } from '../store';
import { maybeHas, translate } from '../../shared/helpers';
import {
setupTranslator as sharedSetupTranslator,
getDefaultEnglishTranslator,
maybeHas,
translate as sharedTranslate,
localeComparator as sharedLocaleComparator,
formatDate as sharedFormatDate,
} from '../../shared/helpers';
import moment from 'moment';
export const AlertTypes = ['balance', 'transaction'];
......@@ -260,3 +264,32 @@ export function getChartsDefaultColors(theme: string) {
assert(!!cachedTheme, 'theme reloaded');
return cachedTheme.chartsColors;
}
// Global state for internationalization: there's only one active language per client.
let I18N = getDefaultEnglishTranslator();
type FORMAT_DATE_TYPE = ReturnType<typeof sharedFormatDate>;
// This little trick allows exported mutable bindings: the actual export
// binding is constant, so it can be referred to in other modules; but it's
// just a proxy to an internal object which value can change over time.
const FORMAT_DATE_CONTAINER = { inner: sharedFormatDate('en') };
export const formatDate: FORMAT_DATE_TYPE = (new Proxy(FORMAT_DATE_CONTAINER, {
get(obj, prop) {
return (obj.inner as any)[prop];
},
}) as any) as FORMAT_DATE_TYPE; // ts sucks
export function setupTranslator(locale: string): void {
I18N = sharedSetupTranslator(locale);
FORMAT_DATE_CONTAINER.inner = sharedFormatDate(locale);
moment.locale(locale);
}
export function translate(format: string, bindings: any = null): string {
return sharedTranslate(I18N, format, bindings);
}
export function localeComparator(a: string, b: string): number {
return sharedLocaleComparator(I18N, a, b);
}
......@@ -14,16 +14,19 @@ import {
CreateAndRetrieveDataResult,
destroyWithData as destroyAccessWithData,
} from './accesses';
import { getTranslator } from '../lib/translator';
export async function setupDemoMode(userId: number): Promise<CreateAndRetrieveDataResult> {
const i18n = await getTranslator(userId);
// Create default categories, unless they already existed.
const existingCategories = new Set((await Category.all(userId)).map(cat => cat.label));
for (const category of DefaultCategories) {
if (existingCategories.has($t(category.label))) {
if (existingCategories.has($t(i18n, category.label))) {
continue;
}
await Category.create(userId, {
label: $t(category.label),
label: $t(i18n, category.label),
color: category.color,
});
}
......
......@@ -9,6 +9,7 @@ import { WOOB_NOT_INSTALLED } from '../shared/errors.json';
import { KError, asyncErr, checkMinimalWoobVersion, UNKNOWN_WOOB_VERSION } from '../helpers';
import { DEMO_MODE } from '../shared/settings';
import { IdentifiedRequest } from './routes';
export async function getWoobVersion(_req: express.Request, res: express.Response) {
try {
......@@ -34,8 +35,10 @@ export async function updateWoob(_req: express.Request, res: express.Response) {
}
}
export async function testEmail(req: express.Request, res: express.Response) {
export async function testEmail(req: IdentifiedRequest<void>, res: express.Response) {
try {
const { id: userId } = req.user;
const { email } = req.body;
if (!email) {
throw new KError('Missing email recipient address when sending a test email', 400);
......@@ -43,7 +46,7 @@ export async function testEmail(req: express.Request, res: express.Response) {
const emailer = getEmailer();
if (emailer !== null) {
await emailer.sendTestEmail(email);
await emailer.sendTestEmail(userId, email);
} else {
throw new KError('No emailer found');
}
......@@ -53,13 +56,14 @@ export async function testEmail(req: express.Request, res: express.Response) {
}
}
export async function testNotification(req: express.Request, res: express.Response) {
export async function testNotification(req: IdentifiedRequest<void>, res: express.Response) {
try {
const { id: userId } = req.user;
const { appriseUrl } = req.body;
if (!appriseUrl) {
throw new KError('Missing apprise url when sending a notification', 400);
}
await sendTestNotification(appriseUrl);
await sendTestNotification(userId, appriseUrl);
res.status(200).end();
} catch (err) {
asyncErr(res, err, 'when trying to send a notification');
......
......@@ -7,8 +7,9 @@ import getNotifier from '../lib/notifications';
import { IdentifiedRequest } from './routes';
import { KError, asyncErr, setupTranslator } from '../helpers';
import { KError, asyncErr } from '../helpers';
import { APPRISE_URL, EMAIL_RECIPIENT, LOCALE } from '../shared/settings';
import { resetTranslator } from '../lib/translator';
function postSave(userId: number, key: string, value: string) {
switch (key) {
......@@ -27,7 +28,7 @@ function postSave(userId: number, key: string, value: string) {
break;
}
case LOCALE:
setupTranslator(value);
resetTranslator(userId, value);
break;
default:
break;
......
......@@ -3,7 +3,6 @@ import semver from 'semver';
import {
maybeHas as has,
setupTranslator,
translate,
currency,
UNKNOWN_OPERATION_TYPE,
......@@ -28,7 +27,6 @@ export {
currency,
UNKNOWN_OPERATION_TYPE,
UNKNOWN_ACCOUNT_TYPE,
setupTranslator,
formatDate,
MIN_WOOB_VERSION,
UNKNOWN_WOOB_VERSION,
......
import { makeLogger, setupTranslator } from './helpers';
import { makeLogger } from './helpers';
import { DEMO_MODE } from './shared/settings';
import { initModels, Setting } from './models';
......@@ -32,11 +32,6 @@ export default async function init() {
await checkDemoMode();
// Localize Kresus
// TODO : do not localize Kresus globally when Kresus is multi-user.
const locale = await Setting.getLocale(process.kresus.user.id);
setupTranslator(locale);
// Start bank polling
log.info('Starting bank accounts polling et al...');
await Poller.runAtStartup();
......
......@@ -4,22 +4,25 @@ import { Account, Access, Alert, Transaction } from '../models';
import getNotifier from './notifications';
import getEmailer from './emailer';
import { I18NObject } from '../shared/helpers';
import { getTranslator } from './translator';
const log = makeLogger('alert-manager');
class AlertManager {
wrapContent(content: string): string {
return `${$t('server.email.hello')}
private wrapContent(i18n: I18NObject, content: string): string {
return `${$t(i18n, 'server.email.hello')}
${content}
${$t('server.email.seeyoulater.notifications')},
${$t('server.email.signature')}
${$t(i18n, 'server.email.seeyoulater.notifications')},
${$t(i18n, 'server.email.signature')}
`;
}
async send(
userId: number,
i18n: I18NObject,
{ subject, text }: { subject: string; text: string }
): Promise<void> {
let sentNotifications = false;
......@@ -33,7 +36,7 @@ ${$t('server.email.signature')}
const emailer = getEmailer();
if (emailer !== null) {
// Send email notification
const content = this.wrapContent(text);
const content = this.wrapContent(i18n, text);
const fullSubject = `Kresus - ${subject}`;
await emailer.sendToUser(userId, {
......@@ -62,6 +65,8 @@ ${$t('server.email.signature')}
return;
}
const i18n = await getTranslator(userId);
// Map account to names
const accessLabel = access.getLabel();
const accounts = await Account.byAccess(userId, access);
......@@ -104,12 +109,13 @@ ${$t('server.email.signature')}
}
const text = alert.formatOperationMessage(
i18n,
operation,
accountName,
formatCurrency
);
await this.send(userId, {
subject: $t('server.alert.operation.title'),
await this.send(userId, i18n, {
subject: $t(i18n, 'server.alert.operation.title'),
text,
});
}
......@@ -126,6 +132,8 @@ ${$t('server.email.signature')}
return;
}
const i18n = await getTranslator(userId);
const accounts = await Account.byAccess(userId, access);
const accessLabel = access.getLabel();
for (const account of accounts) {
......@@ -143,12 +151,13 @@ ${$t('server.email.signature')}
// Set the currency formatter
const formatCurrency = await account.getCurrencyFormatter();
const text = alert.formatAccountMessage(
i18n,
`${accessLabel}${displayLabel(account)}`,
balance,
formatCurrency
);
await this.send(userId, {
subject: $t('server.alert.balance.title'),
await this.send(userId, i18n, {
subject: $t(i18n, 'server.alert.balance.title'),
text,
});
}
......
......@@ -7,6 +7,7 @@ import { assert, makeLogger, translate as $t, isEmailEnabled } from '../helpers'
import { EMAIL_RECIPIENT } from '../shared/settings';
import { Setting } from '../models';
import { getTranslator } from './translator';
const log = makeLogger('emailer');
......@@ -138,7 +139,8 @@ export class Emailer {
await this._send(opts);
}
async sendTestEmail(recipientEmail: string) {
async sendTestEmail(userId: number, recipientEmail: string) {
const i18n = await getTranslator(userId);
assert(
this.fromEmail !== null,
'fromEmail must have been initialized before sending emails'
......@@ -146,8 +148,8 @@ export class Emailer {
await this._send({
from: this.fromEmail,
to: recipientEmail,
subject: $t('server.email.test_email.subject'),
content: $t('server.email.test_email.content'),
subject: $t(i18n, 'server.email.test_email.subject'),
content: $t(i18n, 'server.email.test_email.content'),
});
}
}
......
......@@ -6,6 +6,7 @@ import { APPRISE_URL } from '../shared/settings';
import Settings from '../models/entities/settings';
import fetch from 'node-fetch';
import { getTranslator } from './translator';
const log = makeLogger('notifications');
......@@ -57,11 +58,12 @@ class Notifier {
});
}
async sendTestNotification(appriseUrl: string): Promise<void> {
async sendTestNotification(userId: number, appriseUrl: string): Promise<void> {
const i18n = await getTranslator(userId);
await this._send({
appriseUrl,
subject: $t('server.notification.test_notification.subject'),
content: $t('server.notification.test_notification.content'),
subject: $t(i18n, 'server.notification.test_notification.subject'),
content: $t(i18n, 'server.notification.test_notification.content'),
});
}
}
......@@ -127,10 +129,10 @@ function getNotifier(userId: number): UserNotifier | null {
return NOTIFIER_PER_USER_ID[userId];
}
export async function sendTestNotification(appriseUrl: string): Promise<void> {
export async function sendTestNotification(userId: number, appriseUrl: string): Promise<void> {
const notifier = _getBaseNotifier();
if (notifier) {
await notifier.sendTestNotification(appriseUrl);
await notifier.sendTestNotification(userId, appriseUrl);
}
}
......
......@@ -19,29 +19,32 @@ import {
getErrorCode,
} from '../helpers';
import { WOOB_AUTO_UPDATE } from '../../shared/settings';
import { getTranslator } from './translator';
const log = makeLogger('poller');
async function managePollingErrors(userId: number, access: Access, err: KError): Promise<void> {
assert(!!err.errCode, 'should have an error code to call managePollingErrors');
const i18n = await getTranslator(userId);
// Retrieve the human readable error code.
const error = $t(`server.email.fetch_error.${err.errCode}`);
const subject = $t('server.email.fetch_error.subject');
const error = $t(i18n, `server.email.fetch_error.${err.errCode}`);
const subject = $t(i18n, 'server.email.fetch_error.subject');
let content = $t('server.email.fetch_error.text', {
let content = $t(i18n, 'server.email.fetch_error.text', {
bank: access.getLabel(),
error,
});
if (errorRequiresUserAction(err)) {
content += '\n';
content += $t('server.email.fetch_error.pause_poll');
content += $t(i18n, 'server.email.fetch_error.pause_poll');
}
log.info('Warning the user that an error was detected');
try {
await AlertManager.send(userId, {
await AlertManager.send(userId, i18n, {
subject,
text: content,
});
......
......@@ -12,7 +12,9 @@ import {
} from '../helpers';
import { Access, Account, Alert, Category, Transaction } from '../models';
import { I18NObject } from '../shared/helpers';
import getEmailer, { Emailer } from './emailer';
import { getTranslator } from './translator';
const log = makeLogger('report-manager');
......@@ -69,6 +71,8 @@ class ReportManager {
): Promise<void> {
log.info(`Checking if user has enabled ${frequencyKey} report...`);
const i18n = await getTranslator(userId);
let reports = await Alert.reportsByFrequency(userId, frequencyKey);
if (!reports || !reports.length) {
return log.info(`User hasn't enabled ${frequencyKey} report.`);
......@@ -140,6 +144,7 @@ class ReportManager {
const email = await this.getTextContent(
userId,
i18n,
accounts,
categoryToName,
transactionsByAccount,
......@@ -162,6 +167,7 @@ class ReportManager {
private async getTextContent(
userId: number,
i18n: I18NObject,
accounts: Account[],
categoryToName: Map<number | null, string>,
transactionsByAccount: Map<number, { account: Account; transactions: Transaction[] }>,
......@@ -170,24 +176,24 @@ class ReportManager {
let frequency;
switch (frequencyKey) {
case 'daily':
frequency = $t('server.email.report.daily');
frequency = $t(i18n, 'server.email.report.daily');
break;
case 'weekly':
frequency = $t('server.email.report.weekly');
frequency = $t(i18n, 'server.email.report.weekly');
break;
case 'monthly':
frequency = $t('server.email.report.monthly');
frequency = $t(i18n, 'server.email.report.monthly');
break;
default:
break;
}
const today = formatDate.toShortString(new Date());
const today = formatDate(i18n.localeId).toShortString(new Date());
let content;
content = $t('server.email.hello');
content = $t(i18n, 'server.email.hello');
content += '\n\n';
content += $t('server.email.report.pre', { today });
content += $t(i18n, 'server.email.report.pre', { today });
content += '\n';
const accountsNameMap: Map<number, string> = new Map();
......@@ -212,17 +218,17 @@ class ReportManager {
const formatCurrency = await account.getCurrencyFormatter();
const lastCheckDate = formatDate.toShortString(account.lastCheckDate);
const lastCheckDate = formatDate(i18n.localeId).toShortString(account.lastCheckDate);
const balance = await account.computeBalance();
content += `\t* ${accountsNameMap.get(account.id)} : `;
content += `${formatCurrency(balance)} (`;
content += $t('server.email.report.last_sync');
content += $t(i18n, 'server.email.report.last_sync');
content += ` ${lastCheckDate})\n`;
}
if (transactionsByAccount.size) {
content += '\n';
content += $t('server.email.report.new_operations');
content += $t(i18n, 'server.email.report.new_operations');
content += '\n';
for (const pair of transactionsByAccount.values()) {
// Sort transactions by date or import date
......@@ -234,21 +240,21 @@ class ReportManager {
for (const transaction of transactions) {
const categoryString = categoryToName.get(transaction.categoryId);
const maybeCategory = categoryString ? `(${categoryString}) ` : '';
const date = formatDate.toShortString(transaction.date);
const date = formatDate(i18n.localeId).toShortString(transaction.date);
content += `\t* ${date} - ${transaction.label} ${maybeCategory}: `;
content += `${formatCurrency(transaction.amount)}\n`;
}
}
} else {
content += $t('server.email.report.no_new_operations');
content += $t(i18n, 'server.email.report.no_new_operations');
}
content += '\n';
content += $t('server.email.seeyoulater.report');
content += $t(i18n, 'server.email.seeyoulater.report');
content += '\n\n';
content += $t('server.email.signature');
content += $t(i18n, 'server.email.signature');
const subject = `Kresus - ${$t('server.email.report.subject', { frequency })}`;
const subject = `Kresus - ${$t(i18n, 'server.email.report.subject', { frequency })}`;
return {
subject,
......
import { unwrap } from '../helpers';
import { Setting } from '../models';
import { I18NObject, setupTranslator } from '../shared/helpers';
const LOCALE_ID_TO_TRANSLATOR: Map<string, I18NObject> = new Map();
const USER_TO_TRANSLATOR: Map<number, I18NObject> = new Map();
function ensureSharedTranslator(locale: string) {
if (!LOCALE_ID_TO_TRANSLATOR.has(locale)) {
// Initialize local object for every user of this locale.
LOCALE_ID_TO_TRANSLATOR.set(locale, setupTranslator(locale));
}
}
export async function getTranslator(userId: number): Promise<I18NObject> {
if (!USER_TO_TRANSLATOR.has(userId)) {
// Initialize translator.
const locale = await Setting.getLocale(userId);
ensureSharedTranslator(locale);
const i18n = unwrap(LOCALE_ID_TO_TRANSLATOR.get(locale));
USER_TO_TRANSLATOR.set(userId, i18n);
}
return unwrap(USER_TO_TRANSLATOR.get(userId));
}
export function resetTranslator(userId: number, locale: string) {
ensureSharedTranslator(locale);
USER_TO_TRANSLATOR.set(userId, unwrap(LOCALE_ID_TO_TRANSLATOR.get(locale)));
}
......@@ -11,6 +11,7 @@ import {
import { Transaction, Account, User } from '../';
import { assert, formatDate, translate as $t, unwrap } from '../../helpers';
import { I18NObject } from '../../shared/helpers';
import { ForceNumericColumn, DatetimeType } from '../helpers';
@Entity('alert')
......@@ -88,23 +89,24 @@ export default class Alert {
}
formatOperationMessage(
operation: Transaction,
i18n: I18NObject,
transaction: Transaction,
accountName: string,
formatCurrency: (x: number) => string
): string {
const cmp =
this.order === 'lt'
? $t('server.alert.operation.lessThan')
: $t('server.alert.operation.greaterThan');
? $t(i18n, 'server.alert.operation.lessThan')
: $t(i18n, 'server.alert.operation.greaterThan');
const amount = formatCurrency(operation.amount);
const date = formatDate.toShortString(operation.date);
const amount = formatCurrency(transaction.amount);
const date = formatDate(i18n.localeId).toShortString(transaction.date);
assert(this.limit !== null, 'limit must be set for formatOperationMessage');
const limit = formatCurrency(this.limit);
return $t('server.alert.operation.content', {
label: operation.label,
return $t(i18n, 'server.alert.operation.content', {
label: transaction.label,
account: accountName,
amount,
cmp,
......@@ -114,20 +116,21 @@ export default class Alert {
}
formatAccountMessage(
i18n: I18NObject,
label: string,
balance: number,
formatCurrency: (x: number) => string
): string {
const cmp =
this.order === 'lt'
? $t('server.alert.balance.lessThan')
: $t('server.alert.balance.greaterThan');
? $t(i18n, 'server.alert.balance.lessThan')
: $t(i18n, 'server.alert.balance.greaterThan');
assert(this.limit !== null, 'limit must be set for formatAccountMessage');
const limit = formatCurrency(this.limit);
const formattedBalance = formatCurrency(balance);
return $t('server.alert.balance.content', {
return $t(i18n, 'server.alert.balance.content', {
label,
cmp,
limit,
......
......@@ -2,12 +2,21 @@
import { accountTypeNameToId } from '../lib/account-types';
import { translate as $t } from '../helpers';
import { Provider, ProviderAccountResponse, ProviderTransactionResponse } from '.';
import {
FetchAccountsOptions,
Provider,
ProviderAccountResponse,
ProviderTransactionResponse,
} from '.';
import { getTranslator } from '../lib/translator';
export const SOURCE_NAME = 'manual';
export const fetchAccounts = async (): Promise<ProviderAccountResponse> => {
const manualAccountLabel = $t('server.banks.manual_account');