Merge pull request 'fix: 上传与路由校验加固、甘特图时间线修复' (#63) from fix/quick-wins-and-gantt into main
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router": "^8.3.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"use-immer": "^0.11.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"zod": "^4.4.3",
|
||||
|
||||
@@ -31,9 +31,7 @@ export async function createImportRun(
|
||||
if (options.mapping && Object.keys(options.mapping).length > 0) {
|
||||
form.append('mapping', JSON.stringify(options.mapping));
|
||||
}
|
||||
const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
44
apps/admin/src/api/schemas/dashboard.integration.test.ts
Normal file
44
apps/admin/src/api/schemas/dashboard.integration.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ganttRoomsSchema } from './dashboard';
|
||||
|
||||
describe('ganttRoomsSchema 接口校验', () => {
|
||||
const validPayload = [
|
||||
{
|
||||
roomNumber: 'A101',
|
||||
occupancies: [
|
||||
{
|
||||
studentName: '张三',
|
||||
studentId: 3,
|
||||
checkInDate: '2026-05-01',
|
||||
checkOutDate: null,
|
||||
billingStartDate: '2026-05-01',
|
||||
billingEndDate: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it('接受合法的甘特图数据(studentId 为数字)', () => {
|
||||
expect(ganttRoomsSchema.safeParse(validPayload).success).toBe(true);
|
||||
});
|
||||
|
||||
it('拒绝缺少 checkInDate 的入住记录', () => {
|
||||
const payload = [
|
||||
{
|
||||
roomNumber: 'A101',
|
||||
occupancies: [{ studentName: '张三', checkOutDate: null }],
|
||||
},
|
||||
];
|
||||
expect(ganttRoomsSchema.safeParse(payload).success).toBe(false);
|
||||
});
|
||||
|
||||
it('拒绝缺少 studentName 的入住记录', () => {
|
||||
const payload = [
|
||||
{
|
||||
roomNumber: 'A101',
|
||||
occupancies: [{ checkInDate: '2026-05-01', checkOutDate: null }],
|
||||
},
|
||||
];
|
||||
expect(ganttRoomsSchema.safeParse(payload).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -59,8 +59,19 @@ export const classAttendanceRankingSchema = z
|
||||
|
||||
export const ganttRoomsSchema = z.array(
|
||||
z
|
||||
.object({ roomNumber: z.string(), occupancies: z.array(z.record(z.string(), z.unknown())) })
|
||||
.passthrough(),
|
||||
.object({
|
||||
roomNumber: z.string(),
|
||||
occupancies: z.array(
|
||||
z.object({
|
||||
studentName: z.string(),
|
||||
studentId: z.union([z.string(), z.number()]).optional(),
|
||||
checkInDate: z.string(),
|
||||
checkOutDate: z.string().nullable(),
|
||||
billingStartDate: z.string().optional(),
|
||||
billingEndDate: z.string().nullable().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const classroomOccupanciesSchema = z.array(
|
||||
|
||||
@@ -35,7 +35,6 @@ export const aiChatApi = {
|
||||
form.append('file', file);
|
||||
return (
|
||||
await api.post<AiApiResponse<AiAttachment>>('/ai/chat/attachments', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120_000,
|
||||
})
|
||||
).data;
|
||||
|
||||
@@ -29,9 +29,7 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
|
||||
);
|
||||
const uploadAttachmentMutation = useApiMutation(
|
||||
async (formData: FormData) =>
|
||||
api.post(`/archive/${studentId}/attachments`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
api.post(`/archive/${studentId}/attachments`, formData),
|
||||
{ invalidate: [['archive', studentId]] },
|
||||
);
|
||||
|
||||
|
||||
@@ -196,7 +196,6 @@ export const AttendanceAdminHeader: React.FC<{
|
||||
<section className="student-class-overview" aria-label="班级考勤汇总">
|
||||
<div className="student-class-identity">
|
||||
<div className="student-class-heading">
|
||||
<span className="student-overview-kicker">班级考勤概览</span>
|
||||
<h2>{selectedClass}</h2>
|
||||
<p>
|
||||
{dateLabel} · 当前展示 {visibleStudentCount} 名学生 / {total} 条记录
|
||||
|
||||
@@ -148,7 +148,6 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="lesson-record-header">
|
||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||||
<h2>{displayedSchedule?.subject || '课程考勤'}</h2>
|
||||
<p>
|
||||
{className} · {displayedSchedule?.startTime}–{displayedSchedule?.endTime} ·{' '}
|
||||
|
||||
@@ -761,7 +761,6 @@
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--student-line);
|
||||
background: rgb(255 255 255 / 96%);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.student-center-title {
|
||||
@@ -911,21 +910,7 @@
|
||||
min-height: 176px;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
background:
|
||||
radial-gradient(circle at 100% 0%, rgb(21 122 101 / 10%), transparent 34%),
|
||||
linear-gradient(135deg, #ffffff 0%, #f8fcfa 100%);
|
||||
}
|
||||
|
||||
.student-class-identity::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -36px;
|
||||
bottom: -44px;
|
||||
width: 118px;
|
||||
height: 118px;
|
||||
border: 18px solid rgb(21 122 101 / 7%);
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
background: var(--student-surface);
|
||||
}
|
||||
|
||||
.student-class-heading {
|
||||
@@ -933,19 +918,6 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.student-overview-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
background: rgb(21 122 101 / 10%);
|
||||
color: var(--student-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.student-class-identity h2 {
|
||||
margin: 0 0 6px;
|
||||
color: #111c18;
|
||||
|
||||
@@ -140,9 +140,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
);
|
||||
const uploadContractMutation = useApiMutation(
|
||||
async ({ id, formData }: { id: number; formData: FormData }) =>
|
||||
api.post(`/classroom-rentals/${id}/contract`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
api.post(`/classroom-rentals/${id}/contract`, formData),
|
||||
{ invalidate: [['classroom-rentals']] },
|
||||
);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Empty,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { CalendarOutlined, FileTextOutlined, ReadOutlined } from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
@@ -314,7 +314,13 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
|
||||
{isInternal ? '📖' : cell.hasContract ? '📄' : ''}
|
||||
{isInternal ? (
|
||||
<ReadOutlined style={{ fontSize: 12 }} />
|
||||
) : cell.hasContract ? (
|
||||
<FileTextOutlined style={{ fontSize: 12 }} />
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -110,9 +110,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
);
|
||||
const importMutation = useApiMutation(
|
||||
async (formData: FormData) =>
|
||||
api.post('/classrooms/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
api.post('/classrooms/import', formData),
|
||||
{ invalidate: [['classrooms']] },
|
||||
);
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export interface ExpenseByTypeRow {
|
||||
}
|
||||
export interface GanttOccupancy {
|
||||
studentName: string;
|
||||
studentId?: string;
|
||||
studentId?: string | number;
|
||||
checkInDate: string;
|
||||
checkOutDate: string | null;
|
||||
billingStartDate?: string;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildGanttOption } from './DashboardCharts';
|
||||
import type { GanttOccupancy } from './Dashboard.types';
|
||||
|
||||
const ganttRoom = (occupancies: GanttOccupancy[]) => [
|
||||
{ roomNumber: 'A101', occupancies },
|
||||
];
|
||||
|
||||
describe('buildGanttOption 甘特图时间线', () => {
|
||||
it('未退宿的入住条在查看过去月份时截断到 periodEnd,而不是画到今天', () => {
|
||||
const option = buildGanttOption(
|
||||
ganttRoom([
|
||||
{
|
||||
studentName: '张三',
|
||||
checkInDate: '2026-05-01',
|
||||
checkOutDate: null,
|
||||
},
|
||||
]),
|
||||
{ periodEnd: '2026-06-30', today: '2026-08-07' },
|
||||
);
|
||||
|
||||
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
|
||||
expect(series[0].data[0].value[2]).toBe('2026-06-30');
|
||||
expect(series[0].data[0].value[3]).toBe(true);
|
||||
});
|
||||
|
||||
it('未退宿的入住条在查看当前月时截断到今天', () => {
|
||||
const option = buildGanttOption(
|
||||
ganttRoom([
|
||||
{
|
||||
studentName: '张三',
|
||||
checkInDate: '2026-07-01',
|
||||
checkOutDate: null,
|
||||
},
|
||||
]),
|
||||
{ periodEnd: '2026-08-31', today: '2026-08-07' },
|
||||
);
|
||||
|
||||
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
|
||||
expect(series[0].data[0].value[2]).toBe('2026-08-07');
|
||||
});
|
||||
|
||||
it('已退宿的入住条保留真实退宿日期', () => {
|
||||
const option = buildGanttOption(
|
||||
ganttRoom([
|
||||
{
|
||||
studentName: '李四',
|
||||
checkInDate: '2026-05-01',
|
||||
checkOutDate: '2026-06-15',
|
||||
},
|
||||
]),
|
||||
{ periodEnd: '2026-06-30', today: '2026-08-07' },
|
||||
);
|
||||
|
||||
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
|
||||
expect(series[0].data[0].value[2]).toBe('2026-06-15');
|
||||
expect(series[0].data[0].value[3]).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EChartsOption } from '../../components/ECharts';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
attendanceLabelMap,
|
||||
COLORS,
|
||||
@@ -201,7 +202,13 @@ export function buildClassroomHeatmapOption(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption {
|
||||
export function buildGanttOption(
|
||||
ganttData: GanttRoom[],
|
||||
options?: { periodEnd?: string; today?: string },
|
||||
): EChartsOption {
|
||||
const today = options?.today ?? dayjs().format('YYYY-MM-DD');
|
||||
const periodEnd = options?.periodEnd;
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
|
||||
@@ -246,15 +253,18 @@ export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption {
|
||||
},
|
||||
encode: { x: [1, 2], y: 0 },
|
||||
data: ganttData.flatMap((r) =>
|
||||
(r.occupancies || []).map((o) => ({
|
||||
name: o.studentName,
|
||||
value: [
|
||||
r.roomNumber,
|
||||
o.checkInDate,
|
||||
o.checkOutDate || new Date().toISOString().slice(0, 10),
|
||||
!o.checkOutDate,
|
||||
] as [string, string, string, boolean],
|
||||
})),
|
||||
(r.occupancies || []).map((o) => {
|
||||
const activeEnd = periodEnd && periodEnd < today ? periodEnd : today;
|
||||
return {
|
||||
name: o.studentName,
|
||||
value: [
|
||||
r.roomNumber,
|
||||
o.checkInDate,
|
||||
o.checkOutDate || activeEnd,
|
||||
!o.checkOutDate,
|
||||
] as [string, string, string, boolean],
|
||||
};
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -61,10 +61,11 @@ export const ClassroomHeatmapCard: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
|
||||
data,
|
||||
isMobile,
|
||||
}) => {
|
||||
export const GanttCard: React.FC<{
|
||||
data: GanttRoom[];
|
||||
isMobile: boolean;
|
||||
periodEnd?: string;
|
||||
}> = ({ data, isMobile, periodEnd }) => {
|
||||
const vp = useInViewport('200px');
|
||||
return (
|
||||
<LazySection
|
||||
@@ -74,7 +75,7 @@ export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
|
||||
>
|
||||
{data.length > 0 ? (
|
||||
<ReactECharts
|
||||
option={buildGanttOption(data)}
|
||||
option={buildGanttOption(data, { periodEnd })}
|
||||
style={{ width: '100%', height: isMobile ? 300 : 450 }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -495,7 +495,7 @@ const DashboardPage: React.FC = () => {
|
||||
<ClassroomHeatmapCard data={classroomOccupancy} isMobile={isMobile} />
|
||||
|
||||
{/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */}
|
||||
<GanttCard data={ganttData} isMobile={isMobile} />
|
||||
<GanttCard data={ganttData} isMobile={isMobile} periodEnd={period[1]} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -137,16 +137,12 @@ const ExpensesPage: React.FC = () => {
|
||||
),
|
||||
importUtility: useApiMutation(
|
||||
async (formData: FormData) =>
|
||||
api.post('/expenses/utility/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
api.post('/expenses/utility/import', formData),
|
||||
{ invalidate: [['expenses']] },
|
||||
),
|
||||
importPersonal: useApiMutation(
|
||||
async (formData: FormData) =>
|
||||
api.post('/expenses/personal/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
api.post('/expenses/personal/import', formData),
|
||||
{ invalidate: [['expenses']] },
|
||||
),
|
||||
archiveRoom: useApiMutation(
|
||||
|
||||
@@ -44,9 +44,7 @@ export function useOccupancyMutations() {
|
||||
);
|
||||
const importMutation = useApiMutation(
|
||||
async ({ formData, params }: { formData: FormData; params: string }) =>
|
||||
api.post(`/occupancies/import?${params}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
api.post(`/occupancies/import?${params}`, formData),
|
||||
{ invalidate: invalidateOccupancies },
|
||||
);
|
||||
|
||||
|
||||
@@ -143,9 +143,7 @@ export function useRoomMutations(editing: any) {
|
||||
);
|
||||
const importMutation = useApiMutation(
|
||||
async (formData: FormData) =>
|
||||
api.post<{ message?: string }>('/rooms/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
api.post<{ message?: string }>('/rooms/import', formData),
|
||||
{ invalidate: [['rooms']] },
|
||||
);
|
||||
|
||||
|
||||
@@ -228,16 +228,12 @@ const StudentsPage: React.FC = () => {
|
||||
);
|
||||
const importMutation = useApiMutation(
|
||||
async (formData: FormData) =>
|
||||
api.post('/students/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
api.post('/students/import', formData),
|
||||
{ invalidate: invalidateStudents },
|
||||
);
|
||||
const importMatchMutation = useApiMutation(
|
||||
async (formData: FormData) =>
|
||||
api.post('/students/import-match', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
api.post('/students/import-match', formData),
|
||||
{ invalidate: invalidateStudents },
|
||||
);
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export class AiExcelReaderService {
|
||||
|
||||
private async loadWithExcelJs(buffer: Buffer): Promise<ExcelSheetRows[]> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer.buffer as ArrayBuffer);
|
||||
await workbook.xlsx.load(buffer as unknown as ArrayBuffer);
|
||||
const sheets: ExcelSheetRows[] = [];
|
||||
workbook.eachSheet((sheet) => {
|
||||
const rows: string[][] = [];
|
||||
|
||||
14
apps/server/src/archive/archive-report.exam.spec.ts
Normal file
14
apps/server/src/archive/archive-report.exam.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { renderScoreTrendChart } from './archive-report.exam';
|
||||
|
||||
describe('renderScoreTrendChart', () => {
|
||||
it('places the first y-axis label 4px below the top gridline using numeric addition', () => {
|
||||
const html = renderScoreTrendChart([
|
||||
{ examType: '文化', score: 60, examDate: '2026-01-01' },
|
||||
{ examType: '文化', score: 90, examDate: '2026-02-01' },
|
||||
{ examType: '文化', score: 70, examDate: '2026-03-01' },
|
||||
] as never);
|
||||
|
||||
expect(html).toContain('y="154.0"');
|
||||
expect(html).not.toContain('y="150.04"');
|
||||
});
|
||||
});
|
||||
@@ -146,7 +146,7 @@ export function renderScoreTrendChart(exams: ExamScore[]): string {
|
||||
for (let i = 0; i <= ySteps; i++) {
|
||||
const val = minScore + (scoreRange * i) / ySteps;
|
||||
const y = scaleY(val);
|
||||
yLabels += `<text x="${pad.left - 6}" y="${y.toFixed(1) + 4}" text-anchor="end" fill="#667085" font-size="10">${val.toFixed(0)}</text>`;
|
||||
yLabels += `<text x="${pad.left - 6}" y="${(y + 4).toFixed(1)}" text-anchor="end" fill="#667085" font-size="10">${val.toFixed(0)}</text>`;
|
||||
if (i > 0) {
|
||||
yLabels += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="#eaf3fd" stroke-width="1"/>`;
|
||||
}
|
||||
|
||||
@@ -131,8 +131,8 @@ export class AttendanceQueryService {
|
||||
},
|
||||
accessibleClassIds?: number[],
|
||||
) {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 20;
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
|
||||
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
|
||||
@@ -233,8 +233,8 @@ export class AttendanceQueryService {
|
||||
|
||||
// ── DingAttendance raw records ──
|
||||
async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 20;
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
|
||||
const qb = this.dingRawRepo.createQueryBuilder('ar');
|
||||
|
||||
qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent');
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
@@ -85,7 +86,7 @@ export class ClassesController {
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('class:view')
|
||||
async findOne(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
async findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.findOne(+id);
|
||||
}
|
||||
@@ -93,7 +94,7 @@ export class ClassesController {
|
||||
@Get(':id/schedule')
|
||||
@RequirePermission('class:view')
|
||||
async getSchedule(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Query() query: QueryClassScheduleDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
@@ -104,7 +105,7 @@ export class ClassesController {
|
||||
@Get(':id/attendance-summary')
|
||||
@RequirePermission('class:view')
|
||||
async getAttendanceSummary(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Query() query: QueryClassAttendanceSummaryDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
@@ -125,27 +126,27 @@ export class ClassesController {
|
||||
/** 批量导入学生到班级(通过钉钉用户ID) */
|
||||
@Post(':id/students/import')
|
||||
@RequirePermission('class:edit')
|
||||
async batchImportStudents(@Param('id') id: string, @Body() dto: BatchImportStudentsDto) {
|
||||
async batchImportStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: BatchImportStudentsDto) {
|
||||
return this.service.batchImportStudents(+id, dto.users);
|
||||
}
|
||||
|
||||
/** 归档班级 */
|
||||
@Put(':id/archive')
|
||||
@RequirePermission('class:edit')
|
||||
async archive(@Param('id') id: string) {
|
||||
async archive(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.service.archive(+id);
|
||||
}
|
||||
|
||||
/** 取消归档 */
|
||||
@Put(':id/restore')
|
||||
@RequirePermission('class:edit')
|
||||
async restore(@Param('id') id: string) {
|
||||
async restore(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.service.restore(+id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('class:edit')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) {
|
||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: any) {
|
||||
const result = await this.service.update(+id, dto);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto),
|
||||
@@ -155,7 +156,7 @@ export class ClassesController {
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('class:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const result = await this.service.remove(+id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class',
|
||||
@@ -165,7 +166,7 @@ export class ClassesController {
|
||||
|
||||
@Delete(':id/permanent')
|
||||
@RequirePermission('class:purge')
|
||||
async purge(@Param('id') id: string, @Request() req: any) {
|
||||
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const result = await this.service.purge(+id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复',
|
||||
@@ -176,7 +177,7 @@ export class ClassesController {
|
||||
@Get(':id/roster/export')
|
||||
@RequirePermission('class:view')
|
||||
async exportRoster(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Res() res: Response,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
@@ -218,14 +219,14 @@ export class ClassesController {
|
||||
|
||||
@Get(':id/students')
|
||||
@RequirePermission('class:view')
|
||||
async getStudents(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
async getStudents(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getStudents(+id);
|
||||
}
|
||||
|
||||
@Post(':id/students')
|
||||
@RequirePermission('class:edit')
|
||||
async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) {
|
||||
async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: any) {
|
||||
const result = await this.service.addStudents(+id, dto.studentIds);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`,
|
||||
@@ -249,8 +250,8 @@ export class ClassesController {
|
||||
@Delete(':id/students/:studentId')
|
||||
@RequirePermission('class:edit')
|
||||
async removeStudent(
|
||||
@Param('id') id: string,
|
||||
@Param('studentId') studentId: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Param('studentId', ParseIntPipe) studentId: number,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const result = await this.service.removeStudent(+id, +studentId);
|
||||
@@ -262,14 +263,14 @@ export class ClassesController {
|
||||
|
||||
@Get(':id/teachers')
|
||||
@RequirePermission('class:view')
|
||||
async getTeachers(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
async getTeachers(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getTeachers(+id);
|
||||
}
|
||||
|
||||
@Post(':id/teachers')
|
||||
@RequirePermission('class:edit')
|
||||
async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) {
|
||||
async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: any) {
|
||||
const result = await this.service.addTeacher(+id, dto);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`,
|
||||
@@ -290,8 +291,8 @@ export class ClassesController {
|
||||
@Delete(':id/teacher-assignments/:assignmentId')
|
||||
@RequirePermission('class:edit')
|
||||
async removeTeacherAssignment(
|
||||
@Param('id') id: string,
|
||||
@Param('assignmentId') assignmentId: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Param('assignmentId', ParseIntPipe) assignmentId: number,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
|
||||
@@ -304,8 +305,8 @@ export class ClassesController {
|
||||
@Delete(':id/teachers/:userId')
|
||||
@RequirePermission('class:edit')
|
||||
async removeTeacher(
|
||||
@Param('id') id: string,
|
||||
@Param('userId') userId: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Param('userId', ParseIntPipe) userId: number,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const result = await this.service.removeTeacher(+id, +userId);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
@@ -95,7 +96,7 @@ export class ClassroomRentalsController {
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('rental:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.service.findOne(+id);
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ export class ClassroomRentalsController {
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('rental:edit')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateRentalDto, @Request() req: any) {
|
||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRentalDto, @Request() req: any) {
|
||||
const result = await this.service.update(+id, dto);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental', detail: JSON.stringify(dto),
|
||||
@@ -121,7 +122,7 @@ export class ClassroomRentalsController {
|
||||
|
||||
@Put(':id/cancel')
|
||||
@RequirePermission('rental:edit')
|
||||
async cancel(@Param('id') id: string, @Request() req: any) {
|
||||
async cancel(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const result = await this.service.cancel(+id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '教室租赁', action: '取消租赁', targetId: +id, targetType: 'classroom-rental',
|
||||
@@ -131,7 +132,7 @@ export class ClassroomRentalsController {
|
||||
|
||||
@Put(':id/end')
|
||||
@RequirePermission('rental:edit')
|
||||
async end(@Param('id') id: string, @Request() req: any) {
|
||||
async end(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const result = await this.service.end(+id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '教室租赁', action: '结束租赁', targetId: +id, targetType: 'classroom-rental',
|
||||
@@ -141,7 +142,7 @@ export class ClassroomRentalsController {
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('rental:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const result = await this.service.remove(+id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '教室租赁', action: '归档租赁', targetId: +id, targetType: 'classroom-rental',
|
||||
@@ -151,7 +152,7 @@ export class ClassroomRentalsController {
|
||||
|
||||
@Delete(':id/permanent')
|
||||
@RequirePermission('rental:purge')
|
||||
async purge(@Param('id') id: string, @Request() req: any) {
|
||||
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const result = await this.service.purge(+id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '教室租赁', action: '永久删除租赁订单', targetId: +id, targetType: 'classroom-rental', detail: '物理删除,不可恢复',
|
||||
@@ -174,7 +175,7 @@ export class ClassroomRentalsController {
|
||||
}),
|
||||
)
|
||||
async uploadContract(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Request() req: any,
|
||||
) {
|
||||
@@ -188,7 +189,7 @@ export class ClassroomRentalsController {
|
||||
|
||||
@Get(':id/contract')
|
||||
@RequirePermission('rental:view')
|
||||
async downloadContract(@Param('id') id: string, @Res() res: Response) {
|
||||
async downloadContract(@Param('id', ParseIntPipe) id: number, @Res() res: Response) {
|
||||
const { fullPath, originalName } = await this.service.getContractPath(+id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader(
|
||||
@@ -201,7 +202,7 @@ export class ClassroomRentalsController {
|
||||
|
||||
@Delete(':id/contract')
|
||||
@RequirePermission('rental:edit')
|
||||
async deleteContract(@Param('id') id: string, @Request() req: any) {
|
||||
async deleteContract(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const result = await this.service.removeContract(+id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '教室租赁', action: '移除合同', targetId: +id, targetType: 'classroom-rental',
|
||||
|
||||
@@ -158,7 +158,7 @@ export class ClassroomsController {
|
||||
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
|
||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows: any[] = [];
|
||||
ws.eachRow((row, idx) => {
|
||||
|
||||
@@ -17,4 +17,11 @@ describe('BatchIdsDto', () => {
|
||||
const dto = Object.assign(new BatchIdsDto(), { ids: [1, 1, 2] });
|
||||
await expect(validate(dto)).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects batches with more than 500 ids', async () => {
|
||||
const dto = Object.assign(new BatchIdsDto(), {
|
||||
ids: Array.from({ length: 501 }, (_, i) => i + 1),
|
||||
});
|
||||
await expect(validate(dto)).resolves.not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ArrayNotEmpty, IsArray, IsInt, Min } from 'class-validator';
|
||||
import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsInt, Min } from 'class-validator';
|
||||
|
||||
export class BatchIdsDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ArrayMaxSize(500)
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
ids: number[];
|
||||
|
||||
@@ -117,6 +117,7 @@ async getGanttData(
|
||||
.leftJoinAndSelect('o.student', 'student')
|
||||
.leftJoinAndSelect('o.room', 'room')
|
||||
.where('room.status != :archived', { archived: 'archived' })
|
||||
.andWhere('o.status = :status', { status: 'active' })
|
||||
.orderBy('room.roomNumber', 'ASC')
|
||||
.addOrderBy('o.checkInDate', 'ASC');
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const queriesService = (attendanceRepo?: unknown) =>
|
||||
|
||||
const createQb = () => ({
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
@@ -13,9 +14,11 @@ const createQb = () => ({
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
addGroupBy: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
getRawOne: jest.fn().mockResolvedValue({ cnt: '0' }),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
describe('DashboardService — teacher class scope', () => {
|
||||
@@ -48,6 +51,18 @@ describe('DashboardService — teacher class scope', () => {
|
||||
});
|
||||
|
||||
describe('DashboardService — boundary conditions', () => {
|
||||
it('excludes archived occupancy records from the gantt timeline', async () => {
|
||||
const qb = createQb();
|
||||
const occRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
const queries = queriesService();
|
||||
|
||||
await queries.getGanttData(occRepo as never, () => undefined);
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('o.status = :status', {
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a deny-all predicate instead of an empty SQL IN list', async () => {
|
||||
const qb = createQb();
|
||||
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, ParseIntPipe, UseGuards, Request } from '@nestjs/common';
|
||||
import { ExpenseTypesService } from './expense-types.service';
|
||||
import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
@@ -47,7 +47,7 @@ export class ExpenseTypesController {
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('expense:edit')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateExpenseTypeDto, @Request() req: any) {
|
||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseTypeDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.update(+id, dto);
|
||||
await this.logService.log({
|
||||
@@ -66,7 +66,7 @@ export class ExpenseTypesController {
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('expense:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
|
||||
@@ -28,4 +28,17 @@ describe('BatchRoomExpenseDto boundaries', () => {
|
||||
});
|
||||
await expect(validate(dto)).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a batch with more than 500 items', async () => {
|
||||
const dto = plainToInstance(BatchRoomExpenseDto, {
|
||||
periodStart: '2026-07-01',
|
||||
periodEnd: '2026-07-31',
|
||||
expenses: Array.from({ length: 501 }, () => ({
|
||||
roomId: 1,
|
||||
expenseType: 'water',
|
||||
amount: 10,
|
||||
})),
|
||||
});
|
||||
await expect(validate(dto)).resolves.not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsISO8601, IsString, IsNumber, IsOptional, Matches, Min, ValidateNested } from 'class-validator';
|
||||
import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsISO8601, IsString, IsNumber, IsOptional, Matches, Min, ValidateNested } from 'class-validator';
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@@ -117,6 +117,7 @@ export class BatchRoomExpenseDto {
|
||||
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ArrayMaxSize(500)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BatchRoomExpenseItemDto)
|
||||
expenses: BatchRoomExpenseItemDto[];
|
||||
|
||||
@@ -316,7 +316,7 @@ export class ExpensesController {
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
|
||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows: any[] = [];
|
||||
ws.eachRow((row, idx) => {
|
||||
@@ -381,7 +381,7 @@ export class ExpensesController {
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
|
||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows: any[] = [];
|
||||
ws.eachRow((row, idx) => {
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function parseSheets(
|
||||
if (kind === 'csv') {
|
||||
await workbook.csv.read(Readable.from(Buffer.from(buffer)));
|
||||
} else {
|
||||
await workbook.xlsx.load(buffer.buffer as ArrayBuffer);
|
||||
await workbook.xlsx.load(buffer as unknown as ArrayBuffer);
|
||||
}
|
||||
const sheets = extractSheets(workbook, headerRow);
|
||||
if (sheets.length === 0) {
|
||||
|
||||
@@ -32,4 +32,13 @@ describe('notification DTO boundaries', () => {
|
||||
expect(await validate(dto)).not.toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects more than 500 recipients', async () => {
|
||||
const dto = plainToInstance(CreateNotificationDto, {
|
||||
recipientIds: Array.from({ length: 501 }, (_, i) => i + 1),
|
||||
type: 'test',
|
||||
title: '标题',
|
||||
});
|
||||
await expect(validate(dto)).resolves.not.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
ArrayMaxSize,
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
@@ -13,6 +14,7 @@ import { Type } from 'class-transformer';
|
||||
export class CreateNotificationDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ArrayMaxSize(500)
|
||||
@IsInt({ each: true })
|
||||
recipientIds: number[];
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Get,
|
||||
Put,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Req,
|
||||
Sse,
|
||||
@@ -74,7 +75,7 @@ export class NotificationsController {
|
||||
|
||||
@Put(':id/read')
|
||||
async markRead(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.service.markRead(+id, req.user.id);
|
||||
|
||||
@@ -264,7 +264,7 @@ export class OccupanciesController {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
if (!file?.buffer) throw new BadRequestException('请上传入住名单 Excel 文件');
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
|
||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows = parseOccupancyImportWorksheet(ws);
|
||||
const result = await this.service.batchImportCheckIn(rows, {
|
||||
|
||||
@@ -53,8 +53,8 @@ export class OperationLogsService {
|
||||
if (query?.endDate)
|
||||
qb.andWhere('log.createdAt <= :endDate', { endDate: query.endDate + ' 23:59:59' });
|
||||
|
||||
const page = query?.page || 1;
|
||||
const pageSize = query?.pageSize || 50;
|
||||
const page = Math.max(1, Math.floor(Number(query?.page) || 1));
|
||||
const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query?.pageSize) || 50)));
|
||||
const [data, total] = await qb
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
@@ -42,7 +43,7 @@ export class OrganizationsController {
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('organization:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.service.findOne(+id);
|
||||
}
|
||||
|
||||
@@ -67,7 +68,7 @@ export class OrganizationsController {
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('organization:edit')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateOrganizationDto, @Request() req: any) {
|
||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateOrganizationDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.update(+id, dto);
|
||||
await this.logService.log({
|
||||
@@ -86,7 +87,7 @@ export class OrganizationsController {
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('organization:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
@@ -104,7 +105,7 @@ export class OrganizationsController {
|
||||
|
||||
@Delete(':id/permanent')
|
||||
@RequirePermission('organization:purge')
|
||||
async purge(@Param('id') id: string, @Request() req: any) {
|
||||
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.purge(+id);
|
||||
await this.logService.log({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
BadRequestException,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { RbacService } from './rbac.service';
|
||||
import {
|
||||
@@ -41,7 +42,7 @@ export class RbacController {
|
||||
|
||||
@Get('roles/:id')
|
||||
@RequirePermission('role:view')
|
||||
findRoleById(@Param('id') id: string) {
|
||||
findRoleById(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.rbacService.findRoleById(+id);
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ export class RbacController {
|
||||
|
||||
@Put('roles/:id')
|
||||
@RequirePermission('role:edit')
|
||||
async updateRole(@Param('id') id: string, @Body() dto: UpdateRoleDto, @Request() req: any) {
|
||||
async updateRole(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRoleDto, @Request() req: any) {
|
||||
try {
|
||||
const result = await this.rbacService.updateRole(+id, dto);
|
||||
await logAudit(this.logService, req, {
|
||||
@@ -71,7 +72,7 @@ export class RbacController {
|
||||
|
||||
@Delete('roles/:id')
|
||||
@RequirePermission('role:delete')
|
||||
async deleteRole(@Param('id') id: string, @Request() req: any) {
|
||||
async deleteRole(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
try {
|
||||
const result = await this.rbacService.deleteRole(+id);
|
||||
await logAudit(this.logService, req, {
|
||||
@@ -118,7 +119,7 @@ export class RbacController {
|
||||
|
||||
@Put('users/:id')
|
||||
@RequirePermission('user:edit')
|
||||
async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto, @Request() req: any) {
|
||||
async updateUser(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto, @Request() req: any) {
|
||||
try {
|
||||
const result = await this.rbacService.updateUser(+id, dto);
|
||||
await logAudit(this.logService, req, {
|
||||
@@ -132,7 +133,7 @@ export class RbacController {
|
||||
|
||||
@Put('users/:id/password')
|
||||
@RequirePermission('user:reset-password')
|
||||
async resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto, @Request() req: any) {
|
||||
async resetPassword(@Param('id', ParseIntPipe) id: number, @Body() dto: ResetPasswordDto, @Request() req: any) {
|
||||
try {
|
||||
const result = await this.rbacService.resetPassword(+id, dto.password);
|
||||
await logAudit(this.logService, req, {
|
||||
@@ -146,7 +147,7 @@ export class RbacController {
|
||||
|
||||
@Put('users/:id/archive')
|
||||
@RequirePermission('user:edit')
|
||||
async archiveUser(@Param('id') id: string) {
|
||||
async archiveUser(@Param('id', ParseIntPipe) id: number) {
|
||||
try {
|
||||
return await this.rbacService.archiveUser(+id);
|
||||
} catch (e: unknown) {
|
||||
@@ -157,7 +158,7 @@ export class RbacController {
|
||||
|
||||
@Put('users/:id/restore')
|
||||
@RequirePermission('user:edit')
|
||||
async restoreUser(@Param('id') id: string) {
|
||||
async restoreUser(@Param('id', ParseIntPipe) id: number) {
|
||||
try {
|
||||
return await this.rbacService.restoreUser(+id);
|
||||
} catch (e: unknown) {
|
||||
@@ -168,7 +169,7 @@ export class RbacController {
|
||||
|
||||
@Delete('users/:id/permanent')
|
||||
@RequirePermission('user:purge')
|
||||
async purgeUser(@Param('id') id: string, @Request() req: any) {
|
||||
async purgeUser(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
try {
|
||||
const result = await this.rbacService.purgeUser(+id, req.user?.id);
|
||||
await logAudit(this.logService, req, {
|
||||
@@ -183,7 +184,7 @@ export class RbacController {
|
||||
|
||||
@Put('users/:id/mark-staff')
|
||||
@RequirePermission('user:edit')
|
||||
async markAsStaff(@Param('id') id: string) {
|
||||
async markAsStaff(@Param('id', ParseIntPipe) id: number) {
|
||||
try {
|
||||
return await this.rbacService.markAsStaff(+id);
|
||||
} catch (e: unknown) {
|
||||
@@ -194,7 +195,7 @@ export class RbacController {
|
||||
|
||||
@Put('users/:id/mark-student')
|
||||
@RequirePermission('user:edit')
|
||||
async markAsStudent(@Param('id') id: string) {
|
||||
async markAsStudent(@Param('id', ParseIntPipe) id: number) {
|
||||
try {
|
||||
return await this.rbacService.markAsStudent(+id);
|
||||
} catch (e: unknown) {
|
||||
@@ -205,14 +206,14 @@ export class RbacController {
|
||||
|
||||
@Get('users/:id/profile')
|
||||
@RequirePermission('user:view')
|
||||
getUserProfile(@Param('id') id: string) {
|
||||
getUserProfile(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.rbacService.getUserProfile(+id);
|
||||
}
|
||||
|
||||
@Put('users/:id/profile')
|
||||
@RequirePermission('user:edit')
|
||||
async updateUserProfile(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateProfileDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
@@ -250,7 +251,7 @@ export class RbacController {
|
||||
@Put('teachers/:id/profile')
|
||||
@RequirePermission('teacher:edit')
|
||||
async updateTeacherProfile(
|
||||
@Param('id') id: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() profile: UpdateProfileDto,
|
||||
@Request() req: { user?: { id: number; username: string } },
|
||||
) {
|
||||
|
||||
@@ -343,7 +343,7 @@ export class RoomsController {
|
||||
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
|
||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows: {
|
||||
roomNumber: string;
|
||||
|
||||
@@ -255,7 +255,7 @@ export class StudentsController {
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
|
||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||
const importData = parseStudentImportWorkbook(workbook);
|
||||
// Resolve organization names to IDs
|
||||
for (const row of importData.students) {
|
||||
@@ -280,7 +280,7 @@ export class StudentsController {
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
|
||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||
const importData = parseStudentImportWorkbook(workbook);
|
||||
// Resolve organization names to IDs
|
||||
for (const row of importData.students) {
|
||||
|
||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -40,6 +40,7 @@
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router": "^8.3.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"use-immer": "^0.11.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"zod": "^4.4.3",
|
||||
@@ -16256,7 +16257,7 @@
|
||||
},
|
||||
"node_modules/react-syntax-highlighter": {
|
||||
"version": "16.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
|
||||
"integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user