All files / src/utils renderTemplate.ts

76% Statements 38/50
50% Branches 3/6
37.5% Functions 6/16
83.72% Lines 36/43

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177                          6x 6x 6x 6x 6x 6x                                                                                                                   6x   6x                         6x           6x         6x   6x   6x   6x   6x         6x           6x           6x         6x   6x   6x   6x 1x 1x 1x 1x 1x           6x 24x 24x       6x                           3x   3x 3x 3x 3x    
import path from "path"
 
import { BudgetRead, CategoryRead, TransactionRead, TransactionSplit } from "@billos/firefly-iii-sdk"
import i18next from "i18next"
import nunjucks from "nunjucks"
 
import { env } from "../config"
import DynamicConfig, { VConfig } from "../modules/config/dynamic"
import { BudgetSumUpData } from "./types/budgetSumUp"
 
// Available notification templates. Each value matches a file in
// templates/notifications. Using an enum keeps template references type-safe
// and avoids stringly-typed template names spread across the codebase.
export enum TemplateName {
  AutoImport = "auto-import.njk",
  BudgetOverspent = "budget-overspent.njk",
  BudgetSumUp = "budget-sumup.njk",
  UnbudgetedTransaction = "unbudgeted-transaction.njk",
  UncategorizedTransaction = "uncategorized-transaction.njk",
}
 
// Each template has its own context shape describing exactly the variables it
// expects. The TemplateContextMap binds every TemplateName to its context, so
// renderTemplate can enforce that callers pass the right data per template.
export type AutoImportContext = {
  diffExpenses: number
  diffDeposits: number
  diffTransfers: number
  expenses: TransactionRead[]
  deposits: TransactionRead[]
  transfers: TransactionRead[]
  accountBalance: string
  accountCurrency: string
}
 
export type BudgetOverspentContext = {
  budgetName: string
  spent: number
  limit: number
  currencySymbol: string
}
 
export type BudgetSumUpContext = {
  insights: BudgetSumUpData[]
  accountBalance: string
  accountCurrency: string
  remainingDays: number
  startDay: string
  endDay: string
}
 
export type UnbudgetedTransactionContext = {
  transaction: TransactionSplit
  transactionId: string
  budgets: BudgetRead[]
}
 
export type UncategorizedTransactionContext = {
  transaction: TransactionSplit
  transactionId: string
  categories: CategoryRead[]
  categoriesGroups: CategoryRead[][]
}
 
export type TemplateContextMap = {
  [TemplateName.AutoImport]: AutoImportContext
  [TemplateName.BudgetOverspent]: BudgetOverspentContext
  [TemplateName.BudgetSumUp]: BudgetSumUpContext
  [TemplateName.UnbudgetedTransaction]: UnbudgetedTransactionContext
  [TemplateName.UncategorizedTransaction]: UncategorizedTransactionContext
}
 
// Resolve the templates/notifications directory relative to this source file.
// __dirname is src/utils (tsx dev mode) or build/utils (compiled production mode).
// Going two levels up from either location reaches the project root, where
// templates/ lives alongside src/ and build/.
const templatesDir = path.join(__dirname, "..", "..", "templates", "notifications")
 
const njkEnv = nunjucks.configure(templatesDir, {
  // autoescape is intentionally disabled: the output is plain-text Markdown sent
  // to Discord / Gotify, not HTML. Enabling autoescape would corrupt Markdown
  // syntax (e.g. & → &amp;, < → &lt;) without providing any security benefit.
  autoescape: false,
  trimBlocks: true,
  lstripBlocks: true,
})
 
// Expose a subset of the application env config as a dedicated namespace in all templates.
// Only values that templates legitimately need are included; sensitive credentials
// (fireflyToken, webhookSecret, redis password, etc.) are intentionally excluded.
// Templates can reference these values as {{ env.fireflyUrl }}, {{ env.apiToken }}, etc.
njkEnv.addGlobal("env", {
  fireflyUrl: env.fireflyUrl,
  serviceUrl: env.serviceUrl,
  apiToken: env.apiToken,
})
 
njkEnv.addFilter("toFixed", (value: number | string, decimals: number) => {
  const num = parseFloat(String(value))
  return isNaN(num) ? String(value) : num.toFixed(decimals)
})
 
njkEnv.addFilter("UrlFireflyTransactionShow", (transactionId: string) => `${env.fireflyUrl}/transactions/show/${transactionId}`)
 
njkEnv.addFilter("UrlFireflyBudgets", () => `${env.fireflyUrl}/budgets`)
 
njkEnv.addFilter("UrlFireflyTransactions", () => `${env.fireflyUrl}/transactions/withdrawal`)
 
njkEnv.addFilter("UrlFireflyRules", () => `${env.fireflyUrl}/rules`)
 
njkEnv.addFilter(
  "UrlFireflyNewRule",
  (transaction: TransactionSplit) => `${env.fireflyUrl}/rules/create-from-journal/${transaction.transaction_journal_id}`,
)
 
njkEnv.addFilter(
  "UrlSparkleftCategorySet",
  (transactionId: string, categoryId: string) =>
    `${env.serviceUrl}/transaction/${transactionId}/category/${categoryId}?api_token=${env.apiToken}`,
)
 
njkEnv.addFilter(
  "UrlSparkleftBudgetSet",
  (transactionId: string, budgetId: string) =>
    `${env.serviceUrl}/transaction/${transactionId}/budget/${budgetId}?api_token=${env.apiToken}`,
)
 
njkEnv.addFilter(
  "UrlSparkleftCategorySetUI",
  (transactionId: string) => `${env.serviceUrl}/transaction/${transactionId}/categories?api_token=${env.apiToken}`,
)
 
njkEnv.addFilter("UrlSparkleftAutoImport", () => `${env.serviceUrl}/autoimport?api_token=${env.apiToken}`)
 
njkEnv.addFilter("UrlSparkleftBudgetSumUp", () => `${env.serviceUrl}/budget-sumup?api_token=${env.apiToken}`)
 
njkEnv.addFilter("UrlSparkleftControlPage", () => `${env.serviceUrl}/control?api_token=${env.apiToken}`)
 
njkEnv.addFilter("TransactionSummary", ({ attributes }: TransactionRead) => {
  try {
    const [{ amount, description, currency_symbol, currency_decimal_places }] = attributes.transactions
    const beforeDecimal = 4
    const pad = (currency_decimal_places ?? 0) + beforeDecimal
    return `${Number(amount).toFixed(currency_decimal_places).padStart(pad, "\u00A0")} ${currency_symbol} - ${description}`
  } catch (error) {
    return `Invalid transaction data: ${(error as Error).message}`
  }
})
 
njkEnv.addFilter("i18n", function (this: { ctx: object }, str: string) {
  const translated = i18next.t(str)
  return njkEnv.renderString(translated, this.ctx)
})
 
// Maps each template to its i18next title translation key.
const templateTitleKeys: Record<TemplateName, string> = {
  [TemplateName.AutoImport]: "notif_title_auto_import",
  [TemplateName.BudgetOverspent]: "notif_title_budget_overspent",
  [TemplateName.BudgetSumUp]: "notif_title_budget_sumup",
  [TemplateName.UnbudgetedTransaction]: "notif_title_unbudgeted_transaction",
  [TemplateName.UncategorizedTransaction]: "notif_title_uncategorized_transaction",
}
 
export type RenderedTemplate = {
  title: string
  content: string
}
 
export async function renderTemplate<T extends TemplateName>(templateName: T, context: TemplateContextMap[T]): Promise<RenderedTemplate> {
  const locale = await DynamicConfig.get(VConfig.Locale)
 
  i18next.changeLanguage(locale || "en")
  const title = i18next.t(templateTitleKeys[templateName])
  const content = njkEnv.render(templateName, context).trim()
  return { title, content }
}