Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ assets/*
!assets/mail_onboarding.txt
!assets/template_onboarding.pdf
!assets/school_logo.png
!assets/Logo_GM.*
20 changes: 20 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Binary file added assets/Logo_GM.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/Logo_GM.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 45 additions & 14 deletions src/StudentsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -245,7 +246,7 @@ export class StudentsController {
return entries;
}

public async getOnboardingDataForStudent(student: Student): Promise<Result<{ pdf: Buffer; png: Buffer; link: string }>> {
public async getOnboardingDataForStudent(student: Student, schoolLogo?: string): Promise<Result<{ pdf: Buffer; png: Buffer; link: string }>> {
if (!student.correspondingRelationshipTemplateId || !student.givenname || !student.surname) {
throw new ApplicationError("error.schoolModule.studentAlreadyDeleted", "The student seems to be already deleted.");
}
Expand All @@ -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
);
Expand All @@ -273,6 +275,7 @@ export class StudentsController {
organizationDisplayName: string;
givenname: string;
surname: string;
schoolLogo?: string;
},
pngAsBuffer: Buffer
) {
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -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<PDFImage | undefined> {
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<Student[]> {
const docs = await this.#studentsCollection.find({});

Expand Down
24 changes: 23 additions & 1 deletion src/controllers/StudentsRESTController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -78,6 +78,28 @@ export class StudentsRESTController extends BaseController {
return this.noContent(Result.ok<unknown, ApplicationError>(undefined));
}

@POST
@Path(":id/onboarding")
@Accept("application/pdf")
public async createStudentOnboardingPDF(@PathParam("id") id: string, @ContextResponse response: express.Response, body: any): Promise<Envelope | void> {
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")
Expand Down
4 changes: 4 additions & 0 deletions src/controllers/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions src/utils/getFileTypeForBuffer.ts
Original file line number Diff line number Diff line change
@@ -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;
}