diff --git a/.gitignore b/.gitignore index 3ffc5e5..f488bb1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ assets/* !assets/mail_onboarding.txt !assets/template_onboarding.pdf !assets/school_logo.png +!assets/Logo_GM.* diff --git a/Taskfile.yml b/Taskfile.yml index 3ecd0bf..c0dc773 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -48,6 +48,26 @@ tasks: requires: vars: [STUDENT_ID] + create-onboarding-pdf-with-custom-png-logo: + desc: Gets the onboarding document as PDF + cmds: + - 'echo -n ''{"schoolLogo": "{{.LOGO_BASE64}}"}'' | http -b POST localhost:8099/students/{{ .STUDENT_ID }}/onboarding Accept:application/pdf ''X-API-KEY: This_is_a_test_APIKEY_with_30_chars+'' > {{ .STUDENT_ID }}_onboarding.pdf' + requires: + vars: [STUDENT_ID] + vars: + LOGO_BASE64: + sh: 'cat assets/Logo_GM.png | {{if eq OS "darwin"}}base64{{else}}base64 -w 0{{end}}' + + create-onboarding-pdf-with-custom-jpg-logo: + desc: Gets the onboarding document as PDF + cmds: + - 'echo -n ''{"schoolLogo": "{{.LOGO_BASE64}}"}'' | http -b POST localhost:8099/students/{{ .STUDENT_ID }}/onboarding Accept:application/pdf ''X-API-KEY: This_is_a_test_APIKEY_with_30_chars+'' > {{ .STUDENT_ID }}_onboarding.pdf' + requires: + vars: [STUDENT_ID] + vars: + LOGO_BASE64: + sh: 'cat assets/Logo_GM.jpg | {{if eq OS "darwin"}}base64{{else}}base64 -w 0{{end}}' + get-onboarding-png: desc: Gets the onboarding document as PNG cmds: diff --git a/assets/Logo_GM.jpg b/assets/Logo_GM.jpg new file mode 100644 index 0000000..4d532ee Binary files /dev/null and b/assets/Logo_GM.jpg differ diff --git a/assets/Logo_GM.png b/assets/Logo_GM.png new file mode 100644 index 0000000..57c1ad3 Binary files /dev/null and b/assets/Logo_GM.png differ diff --git a/src/StudentsController.ts b/src/StudentsController.ts index a2a2703..b60012c 100644 --- a/src/StudentsController.ts +++ b/src/StudentsController.ts @@ -22,6 +22,7 @@ import path from "path"; import { PDFDocument, PDFImage } from "pdf-lib"; import qrCodeLib from "qrcode"; import { SchoolFileDTO, Student, StudentAuditLog, StudentAuditLogEntry, StudentDTO, StudentStatus } from "./types"; +import { getFileTypeForBuffer } from "./utils/getFileTypeForBuffer"; export class StudentsController { #studentsCollection: IDatabaseCollection; @@ -245,7 +246,7 @@ export class StudentsController { return entries; } - public async getOnboardingDataForStudent(student: Student): Promise> { + public async getOnboardingDataForStudent(student: Student, schoolLogo?: string): Promise> { if (!student.correspondingRelationshipTemplateId || !student.givenname || !student.surname) { throw new ApplicationError("error.schoolModule.studentAlreadyDeleted", "The student seems to be already deleted."); } @@ -260,7 +261,8 @@ export class StudentsController { { organizationDisplayName: (this.displayName.content.value as DisplayNameJSON).value, givenname: student.givenname, - surname: student.surname + surname: student.surname, + schoolLogo: schoolLogo }, pngAsBuffer ); @@ -273,6 +275,7 @@ export class StudentsController { organizationDisplayName: string; givenname: string; surname: string; + schoolLogo?: string; }, pngAsBuffer: Buffer ) { @@ -298,17 +301,7 @@ export class StudentsController { form.getTextField("Ort_Datum").setText(""); form.getTextField("QR_Code_Schueler").setImage(qrImage); - const schoolLogoPNGPath = path.join(this.assetsLocation, "school_logo.png"); - const schoolLogoJPGPath = path.join(this.assetsLocation, "school_logo.jpg"); - if (fs.existsSync(schoolLogoPNGPath)) { - const schoolLogoBytes = await fs.promises.readFile(schoolLogoPNGPath); - const schoolLogoImage = await pdfDoc.embedPng(schoolLogoBytes); - this.embedImage(pdfDoc, schoolLogoImage); - } else if (fs.existsSync(schoolLogoJPGPath)) { - const schoolLogoBytes = await fs.promises.readFile(schoolLogoJPGPath); - const schoolLogoImage = await pdfDoc.embedJpg(schoolLogoBytes); - this.embedImage(pdfDoc, schoolLogoImage); - } + await this.embedImage(pdfDoc, data.schoolLogo); try { form.flatten(); @@ -327,7 +320,10 @@ export class StudentsController { return Buffer.from(pdfBytes); } - private embedImage(pdfDoc: PDFDocument, image: PDFImage) { + private async embedImage(pdfDoc: PDFDocument, schoolLogoBase64?: string) { + const image = await this.getImage(pdfDoc, schoolLogoBase64); + if (!image) return; + const page = pdfDoc.getPage(0); const maxWidth = ((page.getWidth() - 42 - 42) / 5) * 2; const maxHeight = 80; @@ -341,6 +337,41 @@ export class StudentsController { }); } + private async getImage(pdfDoc: PDFDocument, schoolLogoBase64?: string) { + if (schoolLogoBase64 === undefined) return await this.getAssetImage(pdfDoc); + + const bytes = Buffer.from(schoolLogoBase64, "base64"); + const filetype = getFileTypeForBuffer(bytes); + if (!filetype) throw new ApplicationError("error.schoolModule.onboardingInvalidLogo", "The logo is not a valid PNG or JPG file. Please check the logo and try again."); + + switch (filetype) { + case "png": + const pngImage = await pdfDoc.embedPng(bytes); + return pngImage; + case "jpg": + const jpgImage = await pdfDoc.embedJpg(bytes); + return jpgImage; + } + } + + private async getAssetImage(pdfDoc: PDFDocument): Promise { + const schoolLogoPNGPath = path.join(this.assetsLocation, "school_logo.png"); + if (fs.existsSync(schoolLogoPNGPath)) { + const schoolLogoBytes = await fs.promises.readFile(schoolLogoPNGPath); + const schoolLogoImage = await pdfDoc.embedPng(schoolLogoBytes); + return schoolLogoImage; + } + + const schoolLogoJPGPath = path.join(this.assetsLocation, "school_logo.jpg"); + if (fs.existsSync(schoolLogoJPGPath)) { + const schoolLogoBytes = await fs.promises.readFile(schoolLogoJPGPath); + const schoolLogoImage = await pdfDoc.embedJpg(schoolLogoBytes); + return schoolLogoImage; + } + + return undefined; + } + public async getStudents(): Promise { const docs = await this.#studentsCollection.find({}); diff --git a/src/controllers/StudentsRESTController.ts b/src/controllers/StudentsRESTController.ts index ac8fa05..8cdb7b0 100644 --- a/src/controllers/StudentsRESTController.ts +++ b/src/controllers/StudentsRESTController.ts @@ -7,7 +7,7 @@ import express from "express"; import { fromError } from "zod-validation-error"; import { StudentsController } from "../StudentsController"; import { Student, StudentAuditLog, StudentOnboardingDTO } from "../types"; -import { createStudentRequestSchema, sendAbiturzeugnisRequestSchema, sendFileRequestSchema, sendMailRequestSchema } from "./schemas"; +import { createStudentOnboardingPDFSchema, createStudentRequestSchema, sendAbiturzeugnisRequestSchema, sendFileRequestSchema, sendMailRequestSchema } from "./schemas"; @Path("/students") export class StudentsRESTController extends BaseController { @@ -78,6 +78,28 @@ export class StudentsRESTController extends BaseController { return this.noContent(Result.ok(undefined)); } + @POST + @Path(":id/onboarding") + @Accept("application/pdf") + public async createStudentOnboardingPDF(@PathParam("id") id: string, @ContextResponse response: express.Response, body: any): Promise { + const validationResult = createStudentOnboardingPDFSchema.safeParse(body); + if (!validationResult.success) throw new ApplicationError("error.schoolModule.invalidRequest", `The request is invalid: ${fromError(validationResult.error)}`); + const data = validationResult.data; + + const student = await this.studentsController.getStudent(id); + if (!student) throw RuntimeErrors.general.recordNotFound(Student); + + const result = await this.studentsController.getOnboardingDataForStudent(student, data.schoolLogo); + return this.file( + result, + (r) => r.value.pdf, + () => `${id}_onboarding.pdf`, + () => Mimetype.pdf(), + response, + 200 + ); + } + @GET @Path(":id/onboarding") @Accept("application/json", "application/pdf", "image/png") diff --git a/src/controllers/schemas.ts b/src/controllers/schemas.ts index 4173c76..431baa0 100644 --- a/src/controllers/schemas.ts +++ b/src/controllers/schemas.ts @@ -21,6 +21,10 @@ export const createStudentRequestSchema = z.object({ .default([]) }); +export const createStudentOnboardingPDFSchema = z.object({ + schoolLogo: z.string().base64() +}); + export const sendMailRequestSchema = z.object({ subject: z.string().min(3).max(255), body: z.string().min(5).max(4000) diff --git a/src/utils/getFileTypeForBuffer.ts b/src/utils/getFileTypeForBuffer.ts new file mode 100644 index 0000000..8eebc89 --- /dev/null +++ b/src/utils/getFileTypeForBuffer.ts @@ -0,0 +1,11 @@ +export function getFileTypeForBuffer(buffer: Buffer): "png" | "jpg" | undefined { + const fistBytesAsHex = buffer.toString("hex", 0, 4); + + const pngMagicBytes = "89504e47"; + if (fistBytesAsHex === pngMagicBytes) return "png"; + + const jpgMagicBytes = "ffd8"; + if (fistBytesAsHex.startsWith(jpgMagicBytes)) return "jpg"; + + return undefined; +}