Skip to content

Commit b5a6e13

Browse files
authored
feat: export Camp pataticipation information (#75)
This pull request introduces a new staff export feature allowing staff to export student application data as an Excel file, and makes a minor access control adjustment to file downloads. The most significant changes are the addition of the `StaffExportModule` with its controller and service, the integration of the `exceljs` library, and the update to file access permissions. **Staff Export Feature Implementation:** * Added the `StaffExportModule`, including `staff-export.controller.ts` and `staff-export.service.ts`, which provides an endpoint (`/api/staff/export/`) for staff to export all student application data as a multi-sheet Excel file. The export includes detailed student information and splits data into sheets by gender and education level. [[1]](diffhunk://#diff-089f4f2474b64391c42b6e66aed33977e132058d92108f0a63234a7862e1f8b8R35) [[2]](diffhunk://#diff-089f4f2474b64391c42b6e66aed33977e132058d92108f0a63234a7862e1f8b8R95) [[3]](diffhunk://#diff-a2d1c2317721cea3c298a4feb3d3c7dd82c587b96c4b5339ccdbd84c6d0b9370R1-R11) [[4]](diffhunk://#diff-27118880e50b36e4acf641f4e993ab4ef93d14cc2f38afdb443a4b39c641e587R1-R18) [[5]](diffhunk://#diff-ba41ee2bb81d14f3bb785a990ef0a1a4fae7ea971ad72841c3fee23bfd44dda0R1-R250) * Integrated the `exceljs` library into the project dependencies to enable Excel file creation and manipulation. **Access Control Update:** * Changed the file download endpoint in `StaffFileController` to use `StaffGuard` instead of `RegisGuard`, restricting file access to staff users only.
2 parents 42396fd + ea21263 commit b5a6e13

7 files changed

Lines changed: 700 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"class-validator": "^0.14.3",
5454
"cookie-parser": "^1.4.7",
5555
"cors": "^2.8.5",
56+
"exceljs": "^4.4.0",
5657
"graphql": "^16.12.0",
5758
"jsonwebtoken": "^9.0.3",
5859
"morgan": "^1.10.1",

pnpm-lock.yaml

Lines changed: 417 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { StaffAcademicQuestionModule } from "./modules/staff-academic-question/s
3232
import { StaffAccountModule } from "./modules/staff-account/staff-account.module";
3333
import { StaffApplicationModule } from "./modules/staff-application/staff-application.module";
3434
import { StaffEmailModule } from "./modules/staff-email/staff-email.module";
35+
import { StaffExportModule } from "./modules/staff-export/staff-export.module";
3536
import { StaffFileModule } from "./modules/staff-file/staff-file.module";
3637
import { StaffLeaderboardModule } from "./modules/staff-leaderboard/staff-leaderboard.module";
3738
import { StaffRegisGradingModule } from "./modules/staff-regis-grading/staff-regis-grading.module";
@@ -91,6 +92,7 @@ import { UtilModule } from "./modules/util/util.module";
9192
StaffTotalScoreModule,
9293
StaffLeaderboardModule,
9394
ApplicationPaymentEvidenceModule,
95+
StaffExportModule,
9496
],
9597

9698
controllers: [AppController],
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Controller, Get, Res, UseGuards } from "@nestjs/common";
2+
import { AllowAnonymous } from "@thallesp/nestjs-better-auth";
3+
import { type Response } from "express";
4+
import { RegisGuard } from "src/common/guards/regis.guard";
5+
import { StaffGuard } from "src/common/guards/staff.guard";
6+
import { PrismaService } from "src/core/prisma/prisma.service";
7+
import { StaffExportService } from "./staff-export.service";
8+
9+
@Controller("/api/staff/export")
10+
export class StaffExportController {
11+
constructor(private readonly staffExportService: StaffExportService) {}
12+
13+
@Get("/")
14+
@UseGuards(StaffGuard)
15+
exportAll(@Res() res: Response) {
16+
return this.staffExportService.exportAll(res);
17+
}
18+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Module } from "@nestjs/common";
2+
import { S3Module } from "src/core/s3/s3.module";
3+
import { StaffExportController } from "./staff-export.controller";
4+
import { StaffExportService } from "./staff-export.service";
5+
6+
@Module({
7+
imports: [S3Module],
8+
controllers: [StaffExportController],
9+
providers: [StaffExportService],
10+
})
11+
export class StaffExportModule {}
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import { Injectable } from "@nestjs/common";
2+
import ExcelJS from "exceljs";
3+
import { type Response } from "express";
4+
import { PrismaService } from "src/core/prisma/prisma.service";
5+
import { S3Service } from "src/core/s3/s3.service";
6+
7+
@Injectable()
8+
export class StaffExportService {
9+
constructor(
10+
private readonly prisma: PrismaService,
11+
private readonly s3: S3Service,
12+
) {}
13+
14+
private decodeQueryStrings(data: Record<string, unknown> | null | undefined): Record<string, unknown> {
15+
if (!data) return {};
16+
17+
return Object.fromEntries(
18+
Object.entries(data).map(([key, value]) => {
19+
if (typeof value !== "string") return [key, value];
20+
try {
21+
return [key, decodeURI(value)];
22+
} catch {
23+
return [key, value];
24+
}
25+
}),
26+
);
27+
}
28+
29+
async exportAll(res: Response) {
30+
const applications = await this.prisma.studentApplication.findMany({
31+
where: {
32+
std_application_confirm: true,
33+
},
34+
select: {
35+
std_application_id: true,
36+
std_user: {
37+
select: {
38+
id: true,
39+
email: true,
40+
},
41+
},
42+
std_total_score: {
43+
select: {
44+
std_regis_score: true,
45+
std_academic_score: true,
46+
std_academic_chaos_score: true,
47+
std_total_score: true,
48+
},
49+
},
50+
std_file: {
51+
where: {
52+
std_file_disabled: false,
53+
},
54+
select: {
55+
std_file_type: true,
56+
std_file_key: true,
57+
pe_payment_evidence: {
58+
select: {
59+
pe_transaction_actual_amount: true,
60+
pe_transaction_date: true,
61+
},
62+
},
63+
},
64+
},
65+
std_info: {
66+
select: {
67+
std_info_prefix: true,
68+
std_info_first_name: true,
69+
std_info_last_name: true,
70+
std_info_nick_name: true,
71+
std_info_age: true,
72+
std_info_birthdate: true,
73+
std_info_gender: true,
74+
std_info_sexuality: true,
75+
std_info_religion: true,
76+
std_info_phone_number: true,
77+
std_info_education_level: true,
78+
std_info_education_institute: true,
79+
std_info_education_plan: true,
80+
std_info_grade_gpax: true,
81+
std_info_grade_math: true,
82+
std_info_grade_sci: true,
83+
std_info_grade_eng: true,
84+
std_info_parent_fullname: true,
85+
std_info_parent_relation: true,
86+
std_info_parent_phone_number: true,
87+
std_info_have_participated: true,
88+
std_info_have_laptop: true,
89+
std_info_can_participate_every_day: true,
90+
std_info_medical_insurance: true,
91+
std_info_chronic_disease: true,
92+
std_info_drug_allergy: true,
93+
std_info_food_allergy: true,
94+
std_info_blood_group: true,
95+
std_info_address: true,
96+
std_info_shirt_size: true,
97+
std_info_travel_plan: true,
98+
std_info_laptop_os: true,
99+
std_info_have_tablet: true,
100+
std_info_have_mouse: true,
101+
},
102+
},
103+
},
104+
});
105+
106+
const data = await Promise.all(
107+
applications.map(async (app) => {
108+
const entries = await Promise.all(
109+
app.std_file.map(async (file) => {
110+
return [file.std_file_type, `https://staff.comcamp.io/file/${file.std_file_key}` || ""] as const;
111+
}),
112+
);
113+
const result = this.decodeQueryStrings(Object.fromEntries(entries) as Record<string, string>);
114+
const userData = this.decodeQueryStrings(app.std_user);
115+
const infoData = this.decodeQueryStrings(app.std_info);
116+
const scoreData = this.decodeQueryStrings(app.std_total_score);
117+
118+
return {
119+
...app,
120+
...userData,
121+
...infoData,
122+
...scoreData,
123+
...result,
124+
};
125+
}),
126+
);
127+
128+
const workbook = new ExcelJS.Workbook();
129+
const worksheetAll = workbook.addWorksheet("All");
130+
const worksheetMaleM4 = workbook.addWorksheet("Male M4");
131+
const worksheetMaleM5 = workbook.addWorksheet("Male M5");
132+
const worksheetFemaleM4 = workbook.addWorksheet("Female M4");
133+
const worksheetFemaleM5 = workbook.addWorksheet("FeMale M5");
134+
135+
const columns = [
136+
{ header: "Application ID", key: "std_application_id" },
137+
{ header: "User ID", key: "id" },
138+
{ header: "Email", key: "email" },
139+
{ header: "Prefix", key: "std_info_prefix" },
140+
{ header: "Firstname", key: "std_info_first_name" },
141+
{ header: "Lastname", key: "std_info_last_name" },
142+
{ header: "Nickname", key: "std_info_nick_name" },
143+
{ header: "Age", key: "std_info_age" },
144+
{ header: "Birthdate", key: "std_info_birthdate" },
145+
{ header: "Gender", key: "std_info_gender" },
146+
{ header: "Sexuality", key: "std_info_sexuality" },
147+
{ header: "Religion", key: "std_info_religion" },
148+
{ header: "Phone Number", key: "std_info_phone_number" },
149+
{ header: "Address", key: "std_info_address" },
150+
{ header: "Shirt Size", key: "std_info_shirt_size" },
151+
{ header: "Travel Plan", key: "std_info_travel_plan" },
152+
{ header: "Grade", key: "std_info_education_level" },
153+
{ header: "Institute", key: "std_info_education_institute" },
154+
{ header: "Plan", key: "std_info_education_plan" },
155+
{ header: "GPAX", key: "std_info_grade_gpax" },
156+
{ header: "GPA Math", key: "std_info_grade_math" },
157+
{ header: "GPA Science", key: "std_info_grade_sci" },
158+
{ header: "GPA English", key: "std_info_grade_eng" },
159+
{ header: "Medical Insurance", key: "std_info_medical_insurance" },
160+
{ header: "Chronic Disease", key: "std_info_chronic_disease" },
161+
{ header: "Drug Allergy", key: "std_info_drug_allergy" },
162+
{ header: "Food Allergy", key: "std_info_food_allergy" },
163+
{ header: "Parent Name", key: "std_info_parent_fullname" },
164+
{ header: "Parent Relation", key: "std_info_parent_relation" },
165+
166+
{ header: "Have participated before", key: "std_info_have_participated" },
167+
{ header: "Have laptop", key: "std_info_have_laptop" },
168+
{ header: "Laptop OS", key: "std_info_laptop_os" },
169+
{ header: "Have mouse", key: "std_info_have_mouse" },
170+
{ header: "Have tablet", key: "std_info_have_tablet" },
171+
{ header: "Can participate every day", key: "std_info_can_participate_every_day" },
172+
173+
{ header: "Regis Question Score", key: "std_regis_score" },
174+
{ header: "Academic Question Score", key: "std_academic_score" },
175+
{ header: "Academic Chaos Question Score", key: "std_academic_chaos_score" },
176+
{ header: "Total Score (Regis + Academic Chaos)", key: "std_total_score" },
177+
178+
{ header: "Face", key: "file_face" },
179+
{ header: "National ID", key: "file_national_id" },
180+
{ header: "Transcript", key: "file_pp_1" },
181+
{ header: "Student Certification", key: "file_pp_7" },
182+
{ header: "Parent Permission", key: "file_parent_permission" },
183+
];
184+
185+
worksheetAll.columns = columns;
186+
worksheetMaleM4.columns = columns;
187+
worksheetMaleM5.columns = columns;
188+
worksheetFemaleM4.columns = columns;
189+
worksheetFemaleM5.columns = columns;
190+
191+
worksheetAll.addRows(data);
192+
worksheetMaleM4.addRows(data.filter((d: any) => (d.std_info_education_level === "มัธยมศึกษาปีที่ 4" || d.std_info_education_level === "ปวช. 1") && d.std_info_gender === "male"));
193+
worksheetMaleM5.addRows(data.filter((d: any) => (d.std_info_education_level === "มัธยมศึกษาปีที่ 5" || d.std_info_education_level === "ปวช. 2") && d.std_info_gender === "male"));
194+
worksheetFemaleM4.addRows(data.filter((d: any) => (d.std_info_education_level === "มัธยมศึกษาปีที่ 4" || d.std_info_education_level === "ปวช. 1") && d.std_info_gender === "female"));
195+
worksheetFemaleM5.addRows(data.filter((d: any) => (d.std_info_education_level === "มัธยมศึกษาปีที่ 5" || d.std_info_education_level === "ปวช. 2") && d.std_info_gender === "female"));
196+
197+
res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
198+
res.setHeader("Content-Disposition", "attachment; filename=ComCamp37-khomul-nongnong.xlsx");
199+
200+
// // asked chat dont ask me how it work
201+
worksheetAll.columns.forEach((column) => {
202+
let maxLength = 0;
203+
if (!column.eachCell) return;
204+
column.eachCell({ includeEmpty: true }, (cell) => {
205+
const value = cell.value ? cell.value.toString() : "";
206+
maxLength = Math.max(maxLength, value.length);
207+
});
208+
column.width = maxLength + 2;
209+
});
210+
worksheetMaleM4.columns.forEach((column) => {
211+
let maxLength = 0;
212+
if (!column.eachCell) return;
213+
column.eachCell({ includeEmpty: true }, (cell) => {
214+
const value = cell.value ? cell.value.toString() : "";
215+
maxLength = Math.max(maxLength, value.length);
216+
});
217+
column.width = maxLength + 2;
218+
});
219+
worksheetMaleM5.columns.forEach((column) => {
220+
let maxLength = 0;
221+
if (!column.eachCell) return;
222+
column.eachCell({ includeEmpty: true }, (cell) => {
223+
const value = cell.value ? cell.value.toString() : "";
224+
maxLength = Math.max(maxLength, value.length);
225+
});
226+
column.width = maxLength + 2;
227+
});
228+
worksheetFemaleM4.columns.forEach((column) => {
229+
let maxLength = 0;
230+
if (!column.eachCell) return;
231+
column.eachCell({ includeEmpty: true }, (cell) => {
232+
const value = cell.value ? cell.value.toString() : "";
233+
maxLength = Math.max(maxLength, value.length);
234+
});
235+
column.width = maxLength + 2;
236+
});
237+
worksheetFemaleM5.columns.forEach((column) => {
238+
let maxLength = 0;
239+
if (!column.eachCell) return;
240+
column.eachCell({ includeEmpty: true }, (cell) => {
241+
const value = cell.value ? cell.value.toString() : "";
242+
maxLength = Math.max(maxLength, value.length);
243+
});
244+
column.width = maxLength + 2;
245+
});
246+
247+
await workbook.xlsx.write(res);
248+
res.end();
249+
}
250+
}

src/modules/staff-file/staff-file.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export class StaffFileController {
99
constructor(private readonly staffFileService: StaffFileService) {}
1010

1111
@Get("/:id")
12-
@UseGuards(RegisGuard)
12+
@UseGuards(StaffGuard)
1313
getFileById(@Param("id") fileId: string) {
1414
return this.staffFileService.getFileById(fileId);
1515
}

0 commit comments

Comments
 (0)