Merge pull request 'Hotfixes' (#46) from dev into main
continuous-integration/drone/push Build is passing Details

Reviewed-on: #46
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
This commit is contained in:
Philipp Dormann 2021-07-19 15:53:40 +00:00
commit 8d00307170
4 changed files with 320 additions and 310 deletions

View File

@ -2,11 +2,19 @@
All notable changes to this project will be documented in this file. Dates are displayed in UTC. All notable changes to this project will be documented in this file. Dates are displayed in UTC.
#### [v0.5.2](https://git.odit.services/lfk/document-server/compare/v0.5.1...v0.5.2) #### [v0.5.3](https://git.odit.services/lfk/document-server/compare/v0.5.1...v0.5.3)
- Merge branch 'bugfix/44-runner-certificates-result-in-a-status-500' into dev [`#44`](https://git.odit.services/lfk/document-server/issues/44) - Merge branch 'bugfix/44-runner-certificates-result-in-a-status-500' into dev [`#44`](https://git.odit.services/lfk/document-server/issues/44)
- Fixed Locale comma format [`2a33226`](https://git.odit.services/lfk/document-server/commit/2a3322612d473bd9002cf8d6f9807f9dc7d687da)
- wrap distanceDonations.reduce in array length check [`bac004d`](https://git.odit.services/lfk/document-server/commit/bac004d74eb954d1753d4efcdb927822b89fa757) - wrap distanceDonations.reduce in array length check [`bac004d`](https://git.odit.services/lfk/document-server/commit/bac004d74eb954d1753d4efcdb927822b89fa757)
- 🧾New changelog file version [CI SKIP] [skip ci] [`ff0421d`](https://git.odit.services/lfk/document-server/commit/ff0421da2f16a8f79f9987dabea7bdcb4ef88c05) - 🧾New changelog file version [CI SKIP] [skip ci] [`ff0421d`](https://git.odit.services/lfk/document-server/commit/ff0421da2f16a8f79f9987dabea7bdcb4ef88c05)
- 🧾New changelog file version [CI SKIP] [skip ci] [`f846572`](https://git.odit.services/lfk/document-server/commit/f8465721cddfb55d51eb30d29d74ef63d825b5ac)
- Fix for runner donation array [`72303b1`](https://git.odit.services/lfk/document-server/commit/72303b11052276ad15373887f9e04183841f56f4)
- 🧾New changelog file version [CI SKIP] [skip ci] [`451b7fb`](https://git.odit.services/lfk/document-server/commit/451b7fbe0543991e8a203e38daa350a954ae0e11)
- 🧾New changelog file version [CI SKIP] [skip ci] [`573b921`](https://git.odit.services/lfk/document-server/commit/573b9211972a55df0a38742cb6eb789d6fd3717b)
- 🚀Bumped version to v0.5.3 [`01e1323`](https://git.odit.services/lfk/document-server/commit/01e1323555fe67f6f0ce3c18163e475035bd1cdd)
- 🧾New changelog file version [CI SKIP] [skip ci] [`4b4d66a`](https://git.odit.services/lfk/document-server/commit/4b4d66ae784150f7e1cc491a3fc5d84c93273aee)
- Merge pull request 'v0.5.2: hotfix TypeError in Runner Certificate generation' (#45) from dev into main [`c935950`](https://git.odit.services/lfk/document-server/commit/c935950eb052bce71185fc74c750ec77f081e7df)
- 🚀Bumped version to v0.5.2 [`274c13e`](https://git.odit.services/lfk/document-server/commit/274c13e358f16207fe8bb5cdc1b9ede0582ecb46) - 🚀Bumped version to v0.5.2 [`274c13e`](https://git.odit.services/lfk/document-server/commit/274c13e358f16207fe8bb5cdc1b9ede0582ecb46)
- 🧾New changelog file version [CI SKIP] [skip ci] [`b7b7f6a`](https://git.odit.services/lfk/document-server/commit/b7b7f6a0ae304d24f90a3de3931f53cf08770060) - 🧾New changelog file version [CI SKIP] [skip ci] [`b7b7f6a`](https://git.odit.services/lfk/document-server/commit/b7b7f6a0ae304d24f90a3de3931f53cf08770060)

View File

@ -1,6 +1,6 @@
{ {
"name": "@odit/lfk-document-server", "name": "@odit/lfk-document-server",
"version": "0.5.2", "version": "0.5.3",
"description": "The document generation server for the LfK! runner system. This generates certificates, sponsoring aggreements and more", "description": "The document generation server for the LfK! runner system. This generates certificates, sponsoring aggreements and more",
"main": "src/app.ts", "main": "src/app.ts",
"scripts": { "scripts": {

View File

@ -1,308 +1,308 @@
import axios from 'axios'; import axios from 'axios';
import cheerio from "cheerio"; import cheerio from "cheerio";
import fs from "fs"; import fs from "fs";
import Handlebars from 'handlebars'; import Handlebars from 'handlebars';
import i18next from "i18next"; import i18next from "i18next";
import Backend from 'i18next-fs-backend'; import Backend from 'i18next-fs-backend';
import mime from "mime-types"; import mime from "mime-types";
import path from 'path'; import path from 'path';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import puppeteer from "puppeteer"; import puppeteer from "puppeteer";
import { awaitAsyncHandlebarHelpers, helpers } from './asyncHelpers'; import { awaitAsyncHandlebarHelpers, helpers } from './asyncHelpers';
import { config } from './config'; import { config } from './config';
import { CertificateRunner } from './models/CertificateRunner'; import { CertificateRunner } from './models/CertificateRunner';
import { Runner } from './models/Runner'; import { Runner } from './models/Runner';
import { RunnerCard } from './models/RunnerCard'; import { RunnerCard } from './models/RunnerCard';
import { RunnerGroup } from './models/RunnerGroup'; import { RunnerGroup } from './models/RunnerGroup';
/** /**
* This class is responsible for all things pdf creation. * This class is responsible for all things pdf creation.
* This uses the html templates from src/templates. * This uses the html templates from src/templates.
*/ */
export class PdfCreator { export class PdfCreator {
private templateDir = path.join(__dirname, '/templates'); private templateDir = path.join(__dirname, '/templates');
private browser; private browser;
private static interpolations = { eventname: config.eventname, sponsoring_receipt_minimum_amount: config.sponsoring_receipt_minimum_amount, currency_symbol: config.currency_symbol } private static interpolations = { eventname: config.eventname, sponsoring_receipt_minimum_amount: config.sponsoring_receipt_minimum_amount, currency_symbol: config.currency_symbol }
private static contractsPerRunner = config.contracts_per_runner; private static contractsPerRunner = config.contracts_per_runner;
/** /**
* Main constructor. * Main constructor.
* Initializes i18n(ext), Handlebars and puppeteer. * Initializes i18n(ext), Handlebars and puppeteer.
*/ */
constructor() { constructor() {
this.init(); this.init();
} }
/** /**
* Main constructor. * Main constructor.
* Initializes i18n(ext), Handlebars and puppeteer. * Initializes i18n(ext), Handlebars and puppeteer.
*/ */
public async init() { public async init() {
const minimal_args = [ const minimal_args = [
'--autoplay-policy=user-gesture-required', '--autoplay-policy=user-gesture-required',
'--disable-background-networking', '--disable-background-networking',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows', '--disable-backgrounding-occluded-windows',
'--disable-breakpad', '--disable-breakpad',
'--disable-client-side-phishing-detection', '--disable-client-side-phishing-detection',
'--disable-component-update', '--disable-component-update',
'--disable-default-apps', '--disable-default-apps',
'--disable-dev-shm-usage', '--disable-dev-shm-usage',
'--disable-domain-reliability', '--disable-domain-reliability',
'--disable-extensions', '--disable-extensions',
'--disable-features=AudioServiceOutOfProcess', '--disable-features=AudioServiceOutOfProcess',
'--disable-hang-monitor', '--disable-hang-monitor',
'--disable-ipc-flooding-protection', '--disable-ipc-flooding-protection',
'--disable-notifications', '--disable-notifications',
'--disable-offer-store-unmasked-wallet-cards', '--disable-offer-store-unmasked-wallet-cards',
'--disable-popup-blocking', '--disable-popup-blocking',
'--disable-print-preview', '--disable-print-preview',
'--disable-prompt-on-repost', '--disable-prompt-on-repost',
'--disable-renderer-backgrounding', '--disable-renderer-backgrounding',
'--disable-speech-api', '--disable-speech-api',
'--disable-sync', '--disable-sync',
'--hide-scrollbars', '--hide-scrollbars',
'--ignore-gpu-blacklist', '--ignore-gpu-blacklist',
'--metrics-recording-only', '--metrics-recording-only',
'--mute-audio', '--mute-audio',
'--no-default-browser-check', '--no-default-browser-check',
'--no-first-run', '--no-first-run',
'--no-pings', '--no-pings',
'--no-zygote', '--no-zygote',
'--password-store=basic', '--password-store=basic',
'--use-gl=swiftshader', '--use-gl=swiftshader',
'--no-sandbox' '--no-sandbox'
]; ];
await i18next await i18next
.use(Backend) .use(Backend)
.init({ .init({
fallbackLng: 'en', fallbackLng: 'en',
lng: 'en', lng: 'en',
backend: { backend: {
loadPath: path.join(__dirname, '/locales/{{lng}}.json') loadPath: path.join(__dirname, '/locales/{{lng}}.json')
} }
}); });
await Handlebars.registerHelper(helpers); await Handlebars.registerHelper(helpers);
await Handlebars.registerHelper('__', await Handlebars.registerHelper('__',
function (str) { function (str) {
return i18next.t(str, PdfCreator.interpolations).toString(); return i18next.t(str, PdfCreator.interpolations).toString();
} }
); );
await Handlebars.registerHelper('--sponsor', await Handlebars.registerHelper('--sponsor',
function (str) { function (str) {
const index = (parseInt(str) % config.sponor_logos.length); const index = (parseInt(str) % config.sponor_logos.length);
if (isNaN(index)) { if (isNaN(index)) {
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==" return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg=="
} }
return config.sponor_logos[index]; return config.sponor_logos[index];
} }
); );
await Handlebars.registerHelper('--format_kilometers', await Handlebars.registerHelper('--format_kilometers',
function (str) { function (str) {
let meters = parseInt(str); let meters = parseInt(str);
return ((meters / 1000).toFixed(3).toString()) return ((meters / 1000).toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 3 }))
} }
); );
await Handlebars.registerHelper('--format_currency', await Handlebars.registerHelper('--format_currency',
function (str) { function (str) {
let meters = parseInt(str); let meters = parseInt(str);
return ((meters / 100).toFixed(2).toString()) return ((meters / 100).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }))
} }
); );
this.browser = await puppeteer.launch({ headless: true, args: minimal_args }); this.browser = await puppeteer.launch({ headless: true, args: minimal_args });
} }
/** /**
* Generate sponsoring contract pdfs. * Generate sponsoring contract pdfs.
* @param runner The runner you want to generate the contracts for. * @param runner The runner you want to generate the contracts for.
* @param locale The locale used for the contracts (default:en) * @param locale The locale used for the contracts (default:en)
*/ */
public async generateSponsoringContract(runners: Runner[], locale: string = "en", codeformat: string = config.codeformat): Promise<Buffer> { public async generateSponsoringContract(runners: Runner[], locale: string = "en", codeformat: string = config.codeformat): Promise<Buffer> {
if (runners.length == 1 && Object.keys(runners[0]).length == 0) { if (runners.length == 1 && Object.keys(runners[0]).length == 0) {
runners[0] = this.generateEmptyRunner(); runners[0] = this.generateEmptyRunner();
} }
if (runners.length > 50) { if (runners.length > 50) {
let pdf_promises = new Array<Promise<Buffer>>(); let pdf_promises = new Array<Promise<Buffer>>();
let i, j; let i, j;
for (i = 0, j = runners.length; i < j; i += 50) { for (i = 0, j = runners.length; i < j; i += 50) {
let chunk = runners.slice(i, i + 50); let chunk = runners.slice(i, i + 50);
pdf_promises.push(this.generateSponsoringContract(chunk, locale)); pdf_promises.push(this.generateSponsoringContract(chunk, locale));
} }
const pdfs = await Promise.all(pdf_promises); const pdfs = await Promise.all(pdf_promises);
return await this.mergePdfs(pdfs); return await this.mergePdfs(pdfs);
} }
for (var i = 1; i < PdfCreator.contractsPerRunner; i++) { for (var i = 1; i < PdfCreator.contractsPerRunner; i++) {
runners = runners.reduce(function (res, current, index, array) { runners = runners.reduce(function (res, current, index, array) {
return res.concat([current, current]); return res.concat([current, current]);
}, []); }, []);
} }
await i18next.changeLanguage(locale); await i18next.changeLanguage(locale);
const template_source = fs.readFileSync(`${this.templateDir}/sponsoring_contract.html`, 'utf8'); const template_source = fs.readFileSync(`${this.templateDir}/sponsoring_contract.html`, 'utf8');
const template = Handlebars.compile(template_source); const template = Handlebars.compile(template_source);
let result = template({ runners, codeformat, disclaimer: config.disclaimer_text }); let result = template({ runners, codeformat, disclaimer: config.disclaimer_text });
result = await awaitAsyncHandlebarHelpers(result); result = await awaitAsyncHandlebarHelpers(result);
const pdf = await this.renderPdf(result, { format: "A5", landscape: true }); const pdf = await this.renderPdf(result, { format: "A5", landscape: true });
return pdf return pdf
} }
/** /**
* Generate runner card pdfs. * Generate runner card pdfs.
* @param cards The runner cars you want to generate the cards for. * @param cards The runner cars you want to generate the cards for.
* @param locale The locale used for the cards (default:en) * @param locale The locale used for the cards (default:en)
*/ */
public async generateRunnerCards(cards: RunnerCard[], locale: string = "en", codeformat: string = config.codeformat_cards): Promise<Buffer> { public async generateRunnerCards(cards: RunnerCard[], locale: string = "en", codeformat: string = config.codeformat_cards): Promise<Buffer> {
if (cards.length > 10) { if (cards.length > 10) {
let pdf_promises = new Array<Promise<Buffer>>(); let pdf_promises = new Array<Promise<Buffer>>();
let i, j; let i, j;
for (i = 0, j = cards.length; i < j; i += 10) { for (i = 0, j = cards.length; i < j; i += 10) {
let chunk = cards.slice(i, i + 10); let chunk = cards.slice(i, i + 10);
pdf_promises.push(this.generateRunnerCards(chunk, locale, codeformat)); pdf_promises.push(this.generateRunnerCards(chunk, locale, codeformat));
} }
const pdfs = await Promise.all(pdf_promises); const pdfs = await Promise.all(pdf_promises);
return await this.mergePdfs(pdfs); return await this.mergePdfs(pdfs);
} }
const cards_swapped = this.swapArrayPairs(cards); const cards_swapped = this.swapArrayPairs(cards);
await i18next.changeLanguage(locale); await i18next.changeLanguage(locale);
const template_source = fs.readFileSync(`${this.templateDir}/runner_card.html`, 'utf8'); const template_source = fs.readFileSync(`${this.templateDir}/runner_card.html`, 'utf8');
const template = Handlebars.compile(template_source); const template = Handlebars.compile(template_source);
let result = template({ cards, cards_swapped, eventname: config.eventname, codeformat: codeformat, card_subtitle: config.card_subtitle }) let result = template({ cards, cards_swapped, eventname: config.eventname, codeformat: codeformat, card_subtitle: config.card_subtitle })
result = await awaitAsyncHandlebarHelpers(result); result = await awaitAsyncHandlebarHelpers(result);
const pdf = await this.renderPdf(result, { format: "A4", landscape: false }); const pdf = await this.renderPdf(result, { format: "A4", landscape: false });
return pdf return pdf
} }
/** /**
* Generate sponsoring contract pdfs. * Generate sponsoring contract pdfs.
* @param runner The runner you want to generate the contracts for. * @param runner The runner you want to generate the contracts for.
* @param locale The locale used for the contracts (default:en) * @param locale The locale used for the contracts (default:en)
*/ */
public async generateRunnerCertficates(runners: CertificateRunner[], locale: string = "en"): Promise<Buffer> { public async generateRunnerCertficates(runners: CertificateRunner[], locale: string = "en"): Promise<Buffer> {
if (runners.length > 50) { if (runners.length > 50) {
let pdf_promises = new Array<Buffer>(); let pdf_promises = new Array<Buffer>();
let i, j; let i, j;
for (i = 0, j = runners.length; i < j; i += 50) { for (i = 0, j = runners.length; i < j; i += 50) {
let chunk = runners.slice(i, i + 50); let chunk = runners.slice(i, i + 50);
pdf_promises.push(await this.generateRunnerCertficates(chunk, locale)); pdf_promises.push(await this.generateRunnerCertficates(chunk, locale));
} }
return await this.mergePdfs(pdf_promises); return await this.mergePdfs(pdf_promises);
} }
await i18next.changeLanguage(locale); await i18next.changeLanguage(locale);
const template_source = fs.readFileSync(`${this.templateDir}/runner_certificate.html`, 'utf8'); const template_source = fs.readFileSync(`${this.templateDir}/runner_certificate.html`, 'utf8');
const template = Handlebars.compile(template_source); const template = Handlebars.compile(template_source);
let result = template({ runners, eventname: config.eventname, currency_symbol: config.currency_symbol, donations_footer_text: config.donations_footer_text }); let result = template({ runners, eventname: config.eventname, currency_symbol: config.currency_symbol, donations_footer_text: config.donations_footer_text });
result = await awaitAsyncHandlebarHelpers(result); result = await awaitAsyncHandlebarHelpers(result);
const pdf = await this.renderPdf(result, { format: "A4", landscape: false, printBackground: true }); const pdf = await this.renderPdf(result, { format: "A4", landscape: false, printBackground: true });
return pdf; return pdf;
} }
/** /**
* Converts all images in html to base64. * Converts all images in html to base64.
* Works with image files in the template directory or images from urls. * Works with image files in the template directory or images from urls.
* @param html The html string whoms images shall get replaced. * @param html The html string whoms images shall get replaced.
*/ */
public async imgToBase64(html): Promise<string> { public async imgToBase64(html): Promise<string> {
const $ = cheerio.load(html) const $ = cheerio.load(html)
$('img').each(async (index, element) => { $('img').each(async (index, element) => {
let imgsrc = $(element).attr("src"); let imgsrc = $(element).attr("src");
if (imgsrc.startsWith("data:image")) { if (imgsrc.startsWith("data:image")) {
return; return;
} }
const img_type = mime.lookup(imgsrc); const img_type = mime.lookup(imgsrc);
if (!(img_type.includes("image"))) { if (!(img_type.includes("image"))) {
throw new Error("File is not image mime type"); throw new Error("File is not image mime type");
} }
let image; let image;
if (imgsrc.startsWith("http")) { if (imgsrc.startsWith("http")) {
image = (await axios.get(imgsrc)).data; image = (await axios.get(imgsrc)).data;
image = Buffer.from(image).toString('base64'); image = Buffer.from(image).toString('base64');
} }
else { else {
if (imgsrc.startsWith("./")) { if (imgsrc.startsWith("./")) {
imgsrc = imgsrc.replace("./", ""); imgsrc = imgsrc.replace("./", "");
} }
image = fs.readFileSync(`${this.templateDir}/${imgsrc}`, { encoding: "base64" }); image = fs.readFileSync(`${this.templateDir}/${imgsrc}`, { encoding: "base64" });
} }
image = `data:${img_type};base64,${image}` image = `data:${img_type};base64,${image}`
$(element).attr("src", image) $(element).attr("src", image)
}); });
return $.html(); return $.html();
} }
/** /**
* This method manages the creation of pdfs via puppeteer. * This method manages the creation of pdfs via puppeteer.
* @param html The HTML that should get rendered. * @param html The HTML that should get rendered.
* @param options Puppeteer PDF option (eg: {format: "A4"}) * @param options Puppeteer PDF option (eg: {format: "A4"})
*/ */
public async renderPdf(html: string, options): Promise<any> { public async renderPdf(html: string, options): Promise<any> {
html = await this.imgToBase64(html); html = await this.imgToBase64(html);
let page = await this.browser.newPage(); let page = await this.browser.newPage();
await page.setContent(html); await page.setContent(html);
const pdf = await page.pdf(options); const pdf = await page.pdf(options);
await page.close(); await page.close();
return pdf; return pdf;
} }
/** /**
* Merges multiple pdfs into one. * Merges multiple pdfs into one.
* @param pdfs The pdfs you want to merge as an buffer array. * @param pdfs The pdfs you want to merge as an buffer array.
* @returns The merged pdf as a buffer. * @returns The merged pdf as a buffer.
*/ */
private async mergePdfs(pdfs: Buffer[]): Promise<Buffer> { private async mergePdfs(pdfs: Buffer[]): Promise<Buffer> {
const mergedPdf = await PDFDocument.create(); const mergedPdf = await PDFDocument.create();
for (const pdfBuffer of pdfs) { for (const pdfBuffer of pdfs) {
const pdf = await PDFDocument.load(pdfBuffer); const pdf = await PDFDocument.load(pdfBuffer);
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices()); const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach((page) => { copiedPages.forEach((page) => {
mergedPdf.addPage(page); mergedPdf.addPage(page);
}); });
} }
return <Buffer>(await mergedPdf.save()); return <Buffer>(await mergedPdf.save());
} }
/** /**
* Generates a new dummy runner with halfspaces for all strings. * Generates a new dummy runner with halfspaces for all strings.
* Can be used to generate empty sponsoring contracts. * Can be used to generate empty sponsoring contracts.
* @returns A new runner object that apears to be empty. * @returns A new runner object that apears to be empty.
*/ */
private generateEmptyRunner(): Runner { private generateEmptyRunner(): Runner {
let group = new RunnerGroup(); let group = new RunnerGroup();
group.id = 0; group.id = 0;
group.name = ""; group.name = "";
let runner = new Runner(); let runner = new Runner();
runner.id = 0; runner.id = 0;
runner.firstname = ""; runner.firstname = "";
runner.lastname = ""; runner.lastname = "";
runner.group = group; runner.group = group;
return runner; return runner;
} }
/** /**
* Swaps pairs (0/1, 2/3, ...) of elements in an array recursively. * Swaps pairs (0/1, 2/3, ...) of elements in an array recursively.
* If the last element has no partner it inserts an empty element at the end and swaps the two * If the last element has no partner it inserts an empty element at the end and swaps the two
* This is needed to generate pdfs with front- and backside that get printet on one paper. * This is needed to generate pdfs with front- and backside that get printet on one paper.
* @param array The array which's pairs shall get switched. * @param array The array which's pairs shall get switched.
* @returns Array with swapped pairs, * @returns Array with swapped pairs,
*/ */
private swapArrayPairs(array): Array<any> { private swapArrayPairs(array): Array<any> {
if (array.length == 1) { if (array.length == 1) {
return [null, array[0]]; return [null, array[0]];
} }
if (array.length == 0) { if (array.length == 0) {
return null; return null;
} }
const rest = this.swapArrayPairs(array.slice(2)) const rest = this.swapArrayPairs(array.slice(2))
if (!rest) { if (!rest) {
return [array[1], array[0]] return [array[1], array[0]]
} }
return [array[1], array[0]].concat(rest); return [array[1], array[0]].concat(rest);
} }
} }

View File

@ -98,8 +98,10 @@ export class PdfController {
runner.group.fullName = `${runner.group.parentGroup.name}/${runner.group.name}`; runner.group.fullName = `${runner.group.parentGroup.name}/${runner.group.name}`;
} }
runner.donationPerDistanceTotal = 0; runner.donationPerDistanceTotal = 0;
if (!Array.isArray(runner.distanceDonations)){
runner.distanceDonations = [].concat(runner.distanceDonations)
}
if (runner.distanceDonations.length > 0) { if (runner.distanceDonations.length > 0) {
console.log(typeof runner.distanceDonations);
runner.donationPerDistanceTotal += runner.distanceDonations.reduce(function (sum, current) { runner.donationPerDistanceTotal += runner.distanceDonations.reduce(function (sum, current) {
return sum + current.amountPerDistance; return sum + current.amountPerDistance;
}, 0); }, 0);