diff --git a/README.md b/README.md index d3bd447..dfc827b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ The basic generation mechanism makes the templates and routes interchangeable (i | SPONOR_LOGOS | Array | Empty png | The sponsor images you want to loop through. You can provide them via http url, local file or base64-encoded image. | API_KEY | String(min length: 64) | Random generated string | The api key you want to use for auth (query-param `key`), has to be at least 64 chars long. | DISCLAIMER_TEXT | String | N/A | A disclaimer that will get displayed on the bottom of each sponsoring contract. R/N You can only provide the disclaimer for one language. +| DONATIONS_FOOTER_TEXT | String | N/A | A text that will get displayed on the bottom of each runner certificate's second page. R/N You can only provide the text for one language. | CONTRACTS_PER_RUNNER | Number | 1 | The amount of contracts that get created per runner (per request). ## Templates diff --git a/package.json b/package.json index c704978..4ea7299 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,7 @@ "build": "rimraf ./dist && tsc && cp-cli ./src/templates ./dist/templates && cp-cli ./src/locales ./dist/locales", "licenses:export": "license-exporter --markdown", "release": "release-it --only-version", - "translations:sort": "node sort_translations.js", - "test:speed": "start-server-and-test dev http://localhost:4010/docs/openapi.json test:speed:run", - "test:speed:run": "ts-node src/tests/speedtest.ts" + "translations:sort": "node sort_translations.js" }, "repository": { "type": "git", @@ -41,40 +39,40 @@ ], "license": "CC-BY-NC-SA-4.0", "dependencies": { - "@odit/class-validator-jsonschema": "^2.1.1", - "async-helpers": "^0.3.17", - "axios": "^0.21.1", - "bwip-js": "^2.0.12", - "cheerio": "^1.0.0-rc.5", + "@odit/class-validator-jsonschema": "2.1.1", + "async-helpers": "0.3.17", + "axios": "0.21.1", + "bwip-js": "2.1.1", + "cheerio": "1.0.0-rc.5", "class-transformer": "0.3.1", - "class-validator": "^0.13.1", - "consola": "^2.15.0", - "cors": "^2.8.5", - "dotenv": "^8.2.0", - "express": "^4.17.1", - "handlebars": "^4.7.6", - "i18next": "^19.8.7", - "i18next-fs-backend": "^1.0.8", - "mime-types": "^2.1.28", - "pdf-lib": "^1.16.0", - "puppeteer": "^7.0.1", - "reflect-metadata": "^0.1.13", + "class-validator": "0.13.1", + "consola": "2.15.3", + "cors": "2.8.5", + "dotenv": "8.2.0", + "express": "4.17.1", + "handlebars": "4.7.7", + "i18next": "20.1.0", + "i18next-fs-backend": "1.1.1", + "mime-types": "2.1.29", + "pdf-lib": "1.16.0", + "puppeteer": "8.0.0", + "reflect-metadata": "0.1.13", "routing-controllers": "0.9.0-alpha.6", "routing-controllers-openapi": "2.2.0" }, "devDependencies": { - "@odit/license-exporter": "^0.0.10", - "@types/express": "^4.17.11", - "@types/node": "^14.14.22", - "@types/puppeteer": "^5.4.3", - "cp-cli": "^2.0.0", - "faker": "^5.3.1", - "nodemon": "^2.0.7", + "@odit/license-exporter": "0.0.11", + "@types/express": "4.17.11", + "@types/node": "14.14.22", + "@types/puppeteer": "5.4.3", + "cp-cli": "2.0.0", + "faker": "5.3.1", + "nodemon": "2.0.7", "release-it": "^14.2.2", - "rimraf": "^3.0.2", - "start-server-and-test": "^1.12.0", - "ts-node": "^9.1.1", - "typescript": "^4.1.3" + "rimraf": "3.0.2", + "start-server-and-test": "1.12.0", + "ts-node": "9.1.1", + "typescript": "4.1.3" }, "release-it": { "git": { @@ -89,4 +87,4 @@ "publish": false } } -} +} \ No newline at end of file diff --git a/src/PdfCreator.ts b/src/PdfCreator.ts index 549cf4f..f929c83 100644 --- a/src/PdfCreator.ts +++ b/src/PdfCreator.ts @@ -10,6 +10,7 @@ import { PDFDocument } from 'pdf-lib'; import puppeteer from "puppeteer"; import { awaitAsyncHandlebarHelpers, helpers } from './asyncHelpers'; import { config } from './config'; +import { CertificateRunner } from './models/CertificateRunner'; import { Runner } from './models/Runner'; import { RunnerCard } from './models/RunnerCard'; import { RunnerGroup } from './models/RunnerGroup'; @@ -97,6 +98,18 @@ export class PdfCreator { return config.sponor_logos[index]; } ); + await Handlebars.registerHelper('--format_kilometers', + function (str) { + let meters = parseInt(str); + return ((meters / 1000).toFixed(3).toString()) + } + ); + await Handlebars.registerHelper('--format_currency', + function (str) { + let meters = parseInt(str); + return ((meters / 100).toFixed(2).toString()) + } + ); this.browser = await puppeteer.launch({ headless: true, args: minimal_args }); } @@ -153,13 +166,37 @@ export class PdfCreator { await i18next.changeLanguage(locale); const template_source = fs.readFileSync(`${this.templateDir}/runner_card.html`, 'utf8'); const template = Handlebars.compile(template_source); - let result = template({ cards, cards_swapped, eventname: "LfK! 2069", codeformat: "qrcode" }) + let result = template({ cards, cards_swapped, eventname: config.eventname, codeformat: codeformat }) result = await awaitAsyncHandlebarHelpers(result); - fs.writeFileSync("lelelelele.tmp", result); const pdf = await this.renderPdf(result, { format: "A4", landscape: false }); return pdf } + /** + * Generate sponsoring contract pdfs. + * @param runner The runner you want to generate the contracts for. + * @param locale The locale used for the contracts (default:en) + */ + public async generateRunnerCertficates(runners: CertificateRunner[], locale: string = "en"): Promise { + if (runners.length > 50) { + let pdf_promises = new Array>(); + let i, j; + for (i = 0, j = runners.length; i < j; i += 50) { + let chunk = runners.slice(i, i + 50); + pdf_promises.push(this.generateRunnerCertficates(chunk, locale)); + } + const pdfs = await Promise.all(pdf_promises); + return await this.mergePdfs(pdfs); + } + await i18next.changeLanguage(locale); + const template_source = fs.readFileSync(`${this.templateDir}/runner_certificate.html`, 'utf8'); + 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 }); + result = await awaitAsyncHandlebarHelpers(result); + const pdf = await this.renderPdf(result, { format: "A4", landscape: false, printBackground: true }); + return pdf; + } + /** * Converts all images in html to base64. * Works with image files in the template directory or images from urls. @@ -167,6 +204,7 @@ export class PdfCreator { */ public async imgToBase64(html): Promise { const $ = cheerio.load(html) + $('img').each(async (index, element) => { let imgsrc = $(element).attr("src"); if (imgsrc.startsWith("data:image")) { @@ -192,7 +230,7 @@ export class PdfCreator { image = `data:${img_type};base64,${image}` $(element).attr("src", image) - }) + }); return $.html(); } diff --git a/src/config.ts b/src/config.ts index ed07fd0..aea575a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,7 @@ export const config = { sponor_logos: getSponsorLogos(), api_key: getApiKey(), disclaimer_text: process.env.DISCLAIMER_TEXT || "", + donations_footer_text: process.env.DONATIONS_FOOTER_TEXT || "", contracts_per_runner: parseInt(process.env.CONTRACTS_PER_RUNNER) || 1, } let errors = 0 diff --git a/src/controllers/PdfController.ts b/src/controllers/PdfController.ts index 619dc81..665f36f 100644 --- a/src/controllers/PdfController.ts +++ b/src/controllers/PdfController.ts @@ -1,5 +1,6 @@ import { Authorized, Body, JsonController, Post, QueryParam, Res } from 'routing-controllers'; import { OpenAPI } from 'routing-controllers-openapi'; +import { CertificateRunner } from '../models/CertificateRunner'; import { Runner } from '../models/Runner'; import { RunnerCard } from '../models/RunnerCard'; import { PdfCreator } from '../PdfCreator'; @@ -54,6 +55,25 @@ export class PdfController { return contracts; } + @Post('/certificates') + @OpenAPI({ description: "Generate runner certificate pdfs from certificate runner objects.
You can choose your prefered locale by passing the 'locale' query-param.
If you provide more than 100 runenrs this could take a moment or two (we tested up to 1000 runners in about 70sec so far)." }) + async generateCertificates(@Body({ validate: true, options: { limit: "500mb" } }) runners: CertificateRunner[], @Res() res: any, @QueryParam("locale") locale: string, @QueryParam("download") download: boolean) { + if (!this.initialized) { + await this.pdf.init(); + this.initialized = true; + } + if (!Array.isArray(runners)) { + runners = [runners]; + } + runners = this.mapCertificatRunnersGroupNames(runners) + const certificates = await this.pdf.generateRunnerCertficates(runners, locale); + res.setHeader('content-type', 'application/pdf'); + if (download) { + res.setHeader('Content-Disposition', 'attachment; filename="certificates.pdf"') + } + return certificates; + } + private mapRunnerGroupNames(runners: Runner[]): Runner[] { let response = new Array(); for (let runner of runners) { @@ -68,6 +88,26 @@ export class PdfController { return response; } + private mapCertificatRunnersGroupNames(runners: CertificateRunner[]): CertificateRunner[] { + let response = new Array(); + for (let runner of runners) { + if (!runner.group.parentGroup) { + runner.group.fullName = runner.group.name; + } + else { + runner.group.fullName = `${runner.group.parentGroup.name}/${runner.group.name}`; + } + runner.donationPerDistanceTotal = runner.distanceDonations.reduce(function (sum, current) { + return sum + current.amountPerDistance; + }, 0); + runner.donationTotal = runner.distanceDonations.reduce(function (sum, current) { + return sum + current.amount; + }, 0); + response.push(runner) + } + return response; + } + private mapCardGroupNames(cards: RunnerCard[]): RunnerCard[] { let response = new Array(); for (let card of cards) { diff --git a/src/locales/de.json b/src/locales/de.json index 7fd4d49..abd0e37 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1,9 +1,14 @@ { "address": "Adresse", + "betrag-km": "Betrag/KM", "city": "Stadt", "date": "Datum", "firstname": "Vorname", + "fuer-den-guten-zweck-zurueckgelegt": "für den guten Zweck zurückgelegt", + "gesamt": "Gesamt", + "gesamtbetrag": "Gesamtbetrag", "group": "Team/Klasse", + "hat-beim-eventname": "Hat beim {{eventname}}", "house_number": "Hausnummer", "id": "ID", "lastname": "Nachname", @@ -12,9 +17,12 @@ "postalcode": "Postleitzahl", "signature": "Unterschrift", "sponsor": "Sponsor", + "sponsor-in": "Sponsor:in", "sponsoring_address_condition": "Muss ausgefüllt werden, wenn Sie eine Spendenquittung benötigen - Spendenquittungen können erst ab einem Gesamtbetrag von {{sponsoring_receipt_minimum_amount}}{{currency_symbol}} ausgestellt werden", "sponsoring_amount_per_distance": "mit einem Betrag von _____{{currency_symbol}} pro gelaufenem Kilometer zu unterstützen.", "sponsoring_subtitle": "Ich/Wir sind bereit anlässlich des {{eventname}}", "sponsoring_title": "Sponsoringerklärung", - "street": "Straße" + "sponsorings": "Sponsorings", + "street": "Straße", + "urkunde": "Urkunde" } \ No newline at end of file diff --git a/src/locales/en.json b/src/locales/en.json index 317b9b6..b1b0691 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,19 +1,28 @@ { "address": "Address", + "betrag-km": "Amount/KM", "city": "City", "date": "date", "firstname": "First name", + "fuer-den-guten-zweck-zurueckgelegt": "for our good cuse at the {{eventname}}", + "gesamt": "Combined", + "gesamtbetrag": "Total", "group": "Team/class", + "hat-beim-eventname": "Ran", "house_number": "House number", + "id": "ID", "lastname": "Last name", "location": "Location", "please_use_blockletters": "Please write in BLOCK LETTERS.", "postalcode": "Postal code", "signature": "Signature", "sponsor": "sponsor", + "sponsor-in": "Donor", "sponsoring_address_condition": "You have to provide an address if you want a donation receipt - Donation receipts can't be issued for total donation amounts under {{sponsoring_receipt_minimum_amount}}{{currency_symbol}}", "sponsoring_amount_per_distance": "with the amount of _____{{currency_symbol}} per kilometer run.", "sponsoring_subtitle": "On the ocation of the {{eventname}} I/We want to support", "sponsoring_title": "Sponsoring contract", - "street": "Street" + "sponsorings": "Donations", + "street": "Street", + "urkunde": "Certifcate" } \ No newline at end of file diff --git a/src/models/CertificateRunner.ts b/src/models/CertificateRunner.ts index a30b356..ef6168a 100644 --- a/src/models/CertificateRunner.ts +++ b/src/models/CertificateRunner.ts @@ -1,5 +1,5 @@ import { - IsArray + IsArray, IsNumber, IsOptional } from "class-validator"; import { DistanceDonation } from './DistanceDonation'; import { Runner } from './Runner'; @@ -13,4 +13,13 @@ export class CertificateRunner extends Runner { */ @IsArray() distanceDonations: DistanceDonation[]; + + @IsNumber() + @IsOptional() + donationPerDistanceTotal?: number = 0; + + @IsNumber() + @IsOptional() + donationTotal?: number = 0; + } diff --git a/src/templates/certficate_background.png b/src/templates/certficate_background.png new file mode 100644 index 0000000..1190792 Binary files /dev/null and b/src/templates/certficate_background.png differ diff --git a/src/templates/certificate_footer.png b/src/templates/certificate_footer.png new file mode 100644 index 0000000..9472ac4 Binary files /dev/null and b/src/templates/certificate_footer.png differ diff --git a/src/templates/runner_certificate.html b/src/templates/runner_certificate.html new file mode 100644 index 0000000..16f3412 --- /dev/null +++ b/src/templates/runner_certificate.html @@ -0,0 +1,102 @@ + + + + + Sponsoring contract + + + + + + {{#each runners}} +
+
+

{{__ "urkunde"}}

+
+
+

{{this.firstname}} + {{this.middlename}} {{this.lastname}} +

+

{{__ "hat-beim-eventname"}}

+

{{--format_kilometers this.distance}}km

+

{{__ "fuer-den-guten-zweck-zurueckgelegt"}}

+
+
+ +
+
+
+
+

{{__ "sponsorings"}}

+
+
+ + + + + + + + {{#each this.distanceDonations}} + + + + + + {{/each}} + + + + + + +
{{__ "sponsor-in"}}{{__ "betrag-km"}}{{__ "gesamtbetrag"}}
{{this.donor.firstname}} {{this.donor.middlename}} {{this.donor.lastname}}{{--format_currency this.amountPerDistance}} {{../../currency_symbol}}{{--format_currency this.amount}} {{../../currency_symbol}}
{{__ "gesamt"}}{{--format_currency this.donationPerDistanceTotal}} {{../currency_symbol}}{{--format_currency this.donationTotal}} {{../currency_symbol}}
+
+
+

+ {{../donations_footer_text}} +

+
+
+ {{/each}} + + + \ No newline at end of file diff --git a/src/tests/speedtest.ts b/src/tests/speedtest.ts index 0ed4372..2899da0 100644 --- a/src/tests/speedtest.ts +++ b/src/tests/speedtest.ts @@ -1,10 +1,15 @@ import axios from "axios" import faker from "faker" +import { config } from '../config' +import { CertificateRunner } from '../models/CertificateRunner' +import { DistanceDonation } from '../models/DistanceDonation' +import { Donor } from '../models/Donor' import { Runner } from '../models/Runner' import { RunnerCard } from '../models/RunnerCard' import { RunnerGroup } from '../models/RunnerGroup' const baseurl = "http://localhost:4010" +const key = config.api_key; axios.interceptors.request.use((config) => { config.headers['request-startTime'] = process.hrtime() @@ -46,6 +51,36 @@ function generateCards(amount: number): RunnerCard[] { return cards; } +function generateCertificateRunners(amount: number): CertificateRunner[] { + let runners: CertificateRunner[] = new Array(); + let group = new RunnerGroup(); + let runner = new CertificateRunner(); + let donor = new Donor(); + let donation = new DistanceDonation(); + for (var i = 0; i < amount; i++) { + group.name = faker.company.bsBuzz(); + group.id = Math.floor(Math.random() * (9999999 - 1) + 1); + + donor.firstname = faker.name.firstName(); + donor.lastname = faker.name.lastName(); + donor.id = Math.floor(Math.random() * (9999999 - 1) + 1); + + runner.firstname = faker.name.firstName(); + runner.lastname = faker.name.lastName(); + runner.id = Math.floor(Math.random() * (9999999 - 1) + 1); + runner.distance = Math.floor(Math.random() * (9999999 - 1) + 1); + + donation.id = Math.floor(Math.random() * (9999999 - 1) + 1); + donation.donor = donor; + donation.runner = runner; + donation.amountPerDistance = Math.floor(Math.random() * (10000 - 1) + 1); + + runner.distanceDonations = [donation, donation] + runners.push(runner); + } + return runners; +} + function idToEan13(id): string { const multiply = [1, 3]; id = id.toString(); @@ -64,15 +99,20 @@ function idToEan13(id): string { } async function postContracts(runners: Runner[]): Promise { - const res = await axios.post(`${baseurl}/contracts`, runners); + const res = await axios.post(`${baseurl}/contracts?key=${key}`, runners); return new Measurement("contract", runners.length, parseInt(res.headers['request-duration'])) } async function postCards(cards: RunnerCard[]): Promise { - const res = await axios.post(`${baseurl}/cards`, cards); + const res = await axios.post(`${baseurl}/cards?key=${key}`, cards); return new Measurement("card", cards.length, parseInt(res.headers['request-duration'])) } +async function postCertificates(runners: CertificateRunner[]): Promise { + const res = await axios.post(`${baseurl}/certificates?key=${key}`, runners); + return new Measurement("certificate", runners.length, parseInt(res.headers['request-duration'])) +} + async function testContracts(sizes): Promise { let measurements = new Array(); console.log("#### Testing contracts ####"); @@ -97,16 +137,30 @@ async function testCards(sizes): Promise { return measurements; } +async function testCertificates(sizes): Promise { + let measurements = new Array(); + console.log("#### Testing Certificates ####"); + + for (let size of sizes) { + const m = await postCertificates(generateCertificateRunners(size)); + console.log(m.toString()); + measurements.push(m); + } + return measurements; +} + async function main() { - const sizes = [0, 1, 10, 50, 100, 200, 500, 1000] + const sizes = [1, 10, 50, 100] console.log("########### Speedtest ###########"); console.log(`Document server version (according to the api): ${(await axios.get("http://localhost:4010/version")).data.version}`); console.log("####### Running tests #######"); const contractResults = await testContracts(sizes); const cardResults = await testCards(sizes); + const certificateResults = await testCertificates(sizes); console.log("####### Results #######"); console.table(contractResults); console.table(cardResults); + console.table(certificateResults); } main();