From c98d37307e9772d47e3f9a8c63f6519cd0afa872 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 23 Jul 2026 11:32:13 +0800 Subject: [PATCH] fix: align permission-gated UI actions --- apps/admin/src/api/index.ts | 3 +- .../permission-state.integration.test.tsx | 67 +++ apps/admin/src/auth/permission-store.ts | 43 +- apps/admin/src/components/DefaultRoute.tsx | 7 +- .../src/components/JinshujuMatchModal.tsx | 385 +++++++++++++----- apps/admin/src/components/PermissionRoute.tsx | 7 +- .../StudentProfileContent/index.tsx | 190 +++++---- apps/admin/src/hooks/usePermission.ts | 33 +- apps/admin/src/layouts/MainLayout.tsx | 10 +- apps/admin/src/pages/AiConfig/index.tsx | 24 +- .../Attendance/LessonAttendanceDetail.tsx | 4 + apps/admin/src/pages/Attendance/index.tsx | 32 +- .../src/pages/ClassroomRentals/index.tsx | 16 +- apps/admin/src/pages/Classrooms/index.tsx | 46 ++- apps/admin/src/pages/Exams/detail.tsx | 108 ++++- apps/admin/src/pages/Expenses/index.tsx | 100 ++--- .../src/pages/IntegrationConfig/index.tsx | 105 ++--- apps/admin/src/pages/Login/index.tsx | 9 +- apps/admin/src/pages/Occupancies/index.tsx | 219 ++++++---- apps/admin/src/pages/RoomVisual/index.tsx | 33 +- apps/admin/src/pages/Rooms/index.tsx | 239 +++++------ apps/admin/src/pages/Students/index.tsx | 262 +++++++----- apps/admin/src/pages/Teachers/index.tsx | 17 +- .../occupancies.controller.spec.ts | 26 ++ .../src/occupancies/occupancies.controller.ts | 4 +- apps/server/src/students/dto/student.dto.ts | 3 +- .../src/students/students.lifecycle.spec.ts | 27 +- apps/server/src/students/students.service.ts | 39 +- 28 files changed, 1340 insertions(+), 718 deletions(-) create mode 100644 apps/admin/src/auth/permission-state.integration.test.tsx create mode 100644 apps/server/src/occupancies/occupancies.controller.spec.ts diff --git a/apps/admin/src/api/index.ts b/apps/admin/src/api/index.ts index 20af776..cc2e4c1 100644 --- a/apps/admin/src/api/index.ts +++ b/apps/admin/src/api/index.ts @@ -1,4 +1,5 @@ import axios, { type AxiosRequestConfig } from 'axios'; +import { clearPermissions } from '../auth/permission-store'; const instance = axios.create({ baseURL: '/api', @@ -21,7 +22,7 @@ instance.interceptors.response.use( if (err.response?.status === 401 && !isLoginRequest) { localStorage.removeItem('token'); localStorage.removeItem('user'); - localStorage.removeItem('permissions'); + clearPermissions(); window.location.href = '/login'; } if (err.response?.status === 403) { diff --git a/apps/admin/src/auth/permission-state.integration.test.tsx b/apps/admin/src/auth/permission-state.integration.test.tsx new file mode 100644 index 0000000..bd93b98 --- /dev/null +++ b/apps/admin/src/auth/permission-state.integration.test.tsx @@ -0,0 +1,67 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import PermissionButton from '../components/PermissionButton'; +import { + beginPermissionVerification, + clearPermissions, + readPermissionState, + writePermissions, +} from './permission-store'; + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +beforeAll(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +async function renderPermissionButton() { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render(编辑学生); + }); +} + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; + clearPermissions(); +}); + +describe('permission state', () => { + it('ignores cached localStorage permissions until profile verification succeeds', async () => { + localStorage.setItem('permissions', JSON.stringify(['student:edit'])); + beginPermissionVerification(); + + expect(readPermissionState()).toEqual({ permissions: [], status: 'loading' }); + await renderPermissionButton(); + expect(container?.textContent).not.toContain('编辑学生'); + }); + + it('renders permission actions only after verified permissions are written', async () => { + beginPermissionVerification(); + await renderPermissionButton(); + expect(container?.textContent).not.toContain('编辑学生'); + + await act(async () => writePermissions(['student:edit'])); + expect(container?.textContent).toContain('编辑学生'); + }); + + it('fails closed after profile refresh failure', async () => { + writePermissions(['student:edit']); + beginPermissionVerification(); + clearPermissions('ready'); + + expect(readPermissionState()).toEqual({ permissions: [], status: 'ready' }); + await renderPermissionButton(); + expect(container?.textContent).not.toContain('编辑学生'); + expect(localStorage.getItem('permissions')).toBeNull(); + }); +}); diff --git a/apps/admin/src/auth/permission-store.ts b/apps/admin/src/auth/permission-store.ts index ecc0d8a..e20e888 100644 --- a/apps/admin/src/auth/permission-store.ts +++ b/apps/admin/src/auth/permission-store.ts @@ -1,17 +1,40 @@ export const PERMISSIONS_UPDATED_EVENT = 'permissions-updated'; +export type PermissionStatus = 'unknown' | 'loading' | 'ready'; + +export interface PermissionState { + permissions: string[]; + status: PermissionStatus; +} + +let permissionState: PermissionState = { permissions: [], status: 'unknown' }; + +function notifyPermissionStateChanged(): void { + window.dispatchEvent(new Event(PERMISSIONS_UPDATED_EVENT)); +} + +export function readPermissionState(): PermissionState { + return permissionState; +} + export function readPermissions(): string[] { - try { - const value = JSON.parse(localStorage.getItem('permissions') || '[]'); - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === 'string') - : []; - } catch { - return []; - } + return permissionState.status === 'ready' ? permissionState.permissions : []; +} + +export function beginPermissionVerification(): void { + permissionState = { permissions: [], status: 'loading' }; + notifyPermissionStateChanged(); } export function writePermissions(permissions: string[]): void { - localStorage.setItem('permissions', JSON.stringify([...new Set(permissions)])); - window.dispatchEvent(new Event(PERMISSIONS_UPDATED_EVENT)); + const uniquePermissions = [...new Set(permissions)]; + localStorage.setItem('permissions', JSON.stringify(uniquePermissions)); + permissionState = { permissions: uniquePermissions, status: 'ready' }; + notifyPermissionStateChanged(); +} + +export function clearPermissions(status: PermissionStatus = 'unknown'): void { + localStorage.removeItem('permissions'); + permissionState = { permissions: [], status }; + notifyPermissionStateChanged(); } diff --git a/apps/admin/src/components/DefaultRoute.tsx b/apps/admin/src/components/DefaultRoute.tsx index 9366729..3c8da54 100644 --- a/apps/admin/src/components/DefaultRoute.tsx +++ b/apps/admin/src/components/DefaultRoute.tsx @@ -1,11 +1,14 @@ import React from 'react'; import { Navigate } from 'react-router-dom'; -import { Result } from 'antd'; +import { Result, Spin } from 'antd'; import { usePermission } from '../hooks/usePermission'; import { findRoleAwareLandingPath } from '../auth/menu-policy'; const DefaultRoute: React.FC = () => { - const { permissions } = usePermission(); + const { permissions, permissionsReady } = usePermission(); + if (!permissionsReady) { + return ; + } const roles = (() => { try { return JSON.parse(localStorage.getItem('user') || '{}').roles || []; diff --git a/apps/admin/src/components/JinshujuMatchModal.tsx b/apps/admin/src/components/JinshujuMatchModal.tsx index 1dddaa8..a65312d 100644 --- a/apps/admin/src/components/JinshujuMatchModal.tsx +++ b/apps/admin/src/components/JinshujuMatchModal.tsx @@ -11,6 +11,8 @@ import { } from '@ant-design/icons'; import api from '../api'; import { message } from '../ui/app-message'; +import { usePermission } from '../hooks/usePermission'; +import PermissionButton from './PermissionButton'; const { Text } = Typography; @@ -86,7 +88,12 @@ interface MatchSelectorProps { onChange: (d: MatchDecision) => void; } -const MatchSelector: React.FC = ({ entry, decision, studentOptions, onChange }) => { +const MatchSelector: React.FC = ({ + entry, + decision, + studentOptions, + onChange, +}) => { const action = decision?.action ?? 'skip'; if (action === 'match') { @@ -94,7 +101,9 @@ const MatchSelector: React.FC = ({ entry, decision, studentO const matchedStudent = studentOptions.find((s) => s.id === matchD.matchStudentId); return (
- }>已匹配 + }> + 已匹配 + {matchedStudent?.name ?? '未知'} {matchedStudent?.studentNo && ( @@ -103,7 +112,9 @@ const MatchSelector: React.FC = ({ entry, decision, studentO )} - +
); } @@ -112,30 +123,76 @@ const MatchSelector: React.FC = ({ entry, decision, studentO const createD = decision as { action: 'create'; createName: string; createPhone: string }; return (
- }>将新建 - onChange({ action: 'create', createName: e.target.value, createPhone: createD.createPhone })} /> - onChange({ action: 'create', createName: createD.createName, createPhone: e.target.value })} /> - + }> + 将新建 + + + onChange({ + action: 'create', + createName: e.target.value, + createPhone: createD.createPhone, + }) + } + /> + + onChange({ + action: 'create', + createName: createD.createName, + createPhone: e.target.value, + }) + } + /> +
); } return (
- + ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()) + } options={studentOptions.map((s) => ({ value: s.id, label: `${s.name}${s.phone ? ` (${s.phone})` : ''}${s.studentNo ? ` [${s.studentNo}]` : ''}`, }))} - onChange={(studentId: number) => onChange({ action: 'match', matchStudentId: studentId })} /> - - +
); }; @@ -151,13 +208,25 @@ interface RuleEditorProps { onCancel: () => void; } -const RuleEditor: React.FC = ({ rule, formToken, fields, onSave, onDelete, onCancel }) => { +const RuleEditor: React.FC = ({ + rule, + formToken, + fields, + onSave, + onDelete, + onCancel, +}) => { const [name, setName] = useState(rule?.name ?? ''); - const [mappings, setMappings] = useState>(rule?.mappings ?? { name: 'field_1', phone: 'field_2' }); + const [mappings, setMappings] = useState>( + rule?.mappings ?? { name: 'field_1', phone: 'field_2' }, + ); const [saving, setSaving] = useState(false); const handleSave = async () => { - if (!name.trim()) { message.warning('请输入规则名称'); return; } + if (!name.trim()) { + message.warning('请输入规则名称'); + return; + } setSaving(true); try { if (rule) { @@ -170,18 +239,31 @@ const RuleEditor: React.FC = ({ rule, formToken, fields, onSave } catch (e: unknown) { const err = e as { message?: string }; if (err?.message) message.error(err.message); - } finally { setSaving(false); } + } finally { + setSaving(false); + } }; return (
- setName(e.target.value)} - style={{ marginBottom: 12 }} /> - 选择金数据字段映射到学生资料 + setName(e.target.value)} + style={{ marginBottom: 12 }} + /> + + 选择金数据字段映射到学生资料 + {STUDENT_FIELDS.map((sf) => ( -
+
{sf.label} - + + ← + - - - - - - + + + + + + } - placeholder="用户名" - autoComplete="username" - /> + } placeholder="用户名" autoComplete="username" /> { + const { hasPermission } = usePermission(); + const canCheckIn = hasPermission('occupancy:checkin'); const [data, setData] = useState([]); const [students, setStudents] = useState([]); const [rooms, setRooms] = useState([]); @@ -93,7 +96,12 @@ const OccupanciesPage: React.FC = () => { [data, selectedRowKeys], ); const latestSelectedCheckInDate = useMemo( - () => selectedBatchRecords.map((item) => item.checkInDate).filter(Boolean).sort().at(-1), + () => + selectedBatchRecords + .map((item) => item.checkInDate) + .filter(Boolean) + .sort() + .at(-1), [selectedBatchRecords], ); const latestSelectedBillingStartDate = useMemo( @@ -106,7 +114,8 @@ const OccupanciesPage: React.FC = () => { [selectedBatchRecords], ); - const dateNotBefore = (start: string | Dayjs | null | undefined, messageText: string) => + const dateNotBefore = + (start: string | Dayjs | null | undefined, messageText: string) => (_: unknown, value?: Dayjs | null) => { if (!value || !start) return Promise.resolve(); const startDate = dayjs.isDayjs(start) ? start : dayjs(start); @@ -457,46 +466,77 @@ const OccupanciesPage: React.FC = () => { > 入住登记 - { - const formData = new FormData(); - formData.append('file', file); - const params = new URLSearchParams(); - if (autoDeposit) { - params.set('autoDeposit', 'true'); - params.set('depositAmount', String(depositAmount)); - } - try { - const res: any = await api.post( - `/occupancies/import?${params.toString()}`, - formData, - { headers: { 'Content-Type': 'multipart/form-data' } }, - ); - if (res.errors?.length > 0) { - Modal.warning({ - title: res.message, - content: res.errors.join('\n'), - width: 500, - }); - } else { - message.success(res.message); - } - onSuccess?.(res); - fetchData(); - } catch (e: any) { - message.error(e?.message || '导入失败'); - onError?.(e); - } - }} - > - - - - + {canCheckIn ? ( + <> + { + const formData = new FormData(); + formData.append('file', file); + const params = new URLSearchParams(); + if (autoDeposit) { + params.set('autoDeposit', 'true'); + params.set('depositAmount', String(depositAmount)); + } + try { + const res: any = await api.post( + `/occupancies/import?${params.toString()}`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } }, + ); + if (res.errors?.length > 0) { + Modal.warning({ + title: res.message, + content: res.errors.join('\n'), + width: 500, + }); + } else { + message.success(res.message); + } + onSuccess?.(res); + fetchData(); + } catch (e: any) { + message.error(e?.message || '导入失败'); + onError?.(e); + } + }} + > + + + + + + + 导入时自动收押金 + {autoDeposit && ( + + setDepositAmount(v || 500)} + style={{ width: 60 }} + /> + + 元 + + + )} + + + ) : null} } @@ -521,33 +561,6 @@ const OccupanciesPage: React.FC = () => { > 导出记录 - - - 导入时自动收押金 - {autoDeposit && ( - - setDepositAmount(v || 500)} - style={{ width: 60 }} - /> - - 元 - - - )} -
{selectedRowKeys.length > 0 && ( @@ -642,7 +655,11 @@ const OccupanciesPage: React.FC = () => { .filter((s: any) => s.status === 'active') .map((s: any) => { const activeOccupancy = activeOccupancyByStudentId.get(s.id); - const identifier = s.idNumber ? maskIdNumber(s.idNumber) : s.phone ? maskPhone(s.phone) : ''; + const identifier = s.idNumber + ? maskIdNumber(s.idNumber) + : s.phone + ? maskPhone(s.phone) + : ''; return { value: s.id, label: `${s.name} (${identifier})${activeOccupancy ? ` · 已入住${activeOccupancy.room?.roomNumber ? ` ${activeOccupancy.room.roomNumber}` : ''}` : ''}`, @@ -668,18 +685,25 @@ const OccupanciesPage: React.FC = () => { }))} /> - + ({ - validator: dateNotBefore(getFieldValue('checkInDate'), '计费起始日不能早于入住日期'), + validator: dateNotBefore( + getFieldValue('checkInDate'), + '计费起始日不能早于入住日期', + ), }), ]} > @@ -689,7 +713,11 @@ const OccupanciesPage: React.FC = () => { format="YYYY-MM-DD" /> - + ({ value: b.id, label: b.bedNumber, @@ -732,7 +762,9 @@ const OccupanciesPage: React.FC = () => { allowClear placeholder="可选分配柜子" loading={availableResourcesLoading} - disabled={!selectedCheckInRoomId || availableResourcesLoading || availableLockers.length === 0} + disabled={ + !selectedCheckInRoomId || availableResourcesLoading || availableLockers.length === 0 + } options={availableLockers.map((l) => ({ value: l.id, label: l.lockerNumber, @@ -792,12 +824,14 @@ const OccupanciesPage: React.FC = () => { ({ validator: dateNotBefore( - checkOutModal?.billingStartDate || checkOutModal?.checkInDate || getFieldValue('checkOutDate'), + checkOutModal?.billingStartDate || + checkOutModal?.checkInDate || + getFieldValue('checkOutDate'), '计费截止日不能早于计费起始日', ), }), @@ -948,7 +982,11 @@ const OccupanciesPage: React.FC = () => { - + {canViewOrganizations ? ( + + ) : null} - - ({ + value: organization.id, + label: organization.isHost + ? `${organization.name}(本机构)` + : organization.name, + }), + )} + /> + + ) : null} @@ -968,11 +1007,16 @@ const StudentsPage: React.FC = () => { - setJinshujuOpen(false)} - onApplied={() => { setJinshujuOpen(false); fetchData(); }} - /> + {hasPermission('sync:read') ? ( + setJinshujuOpen(false)} + onApplied={() => { + setJinshujuOpen(false); + fetchData(); + }} + /> + ) : null} = { const DEFAULT_PAGE_SIZE = 20; const TeachersPage: React.FC = () => { + const { hasPermission } = usePermission(); + const canEditTeachers = hasPermission('teacher:edit'); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); @@ -190,7 +194,8 @@ const TeachersPage: React.FC = () => { key: 'actions', width: 100, render: (_: unknown, r: TeacherRow) => ( - + ), }, ], - [saveProfileCell], + [saveProfileCell, form], ); return ( @@ -263,8 +268,8 @@ const TeachersPage: React.FC = () => { /> setProfileModal(null)} okText="保存" confirmLoading={saving} diff --git a/apps/server/src/occupancies/occupancies.controller.spec.ts b/apps/server/src/occupancies/occupancies.controller.spec.ts new file mode 100644 index 0000000..605d00b --- /dev/null +++ b/apps/server/src/occupancies/occupancies.controller.spec.ts @@ -0,0 +1,26 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { OccupanciesController } from './occupancies.controller'; + +describe('OccupanciesController permissions', () => { + it('requires occupancy:delete for single and batch archive actions', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.remove)).toEqual([ + 'occupancy:delete', + ]); + expect( + Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.batchRemove), + ).toEqual(['occupancy:delete']); + }); + + it('keeps read-only endpoints on occupancy:view', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.findAll)).toEqual([ + 'occupancy:view', + ]); + expect( + Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.exportExcel), + ).toEqual(['occupancy:view']); + expect( + Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.downloadTemplate), + ).toEqual(['occupancy:view']); + }); +}); diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index b2746ee..dc1e0af 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -159,7 +159,7 @@ export class OccupanciesController { } @Delete(':id') - @RequirePermission('occupancy:view') + @RequirePermission('occupancy:delete') async remove(@Param('id') id: string, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); @@ -177,7 +177,7 @@ export class OccupanciesController { } @Post('batch-delete') - @RequirePermission('occupancy:view') + @RequirePermission('occupancy:delete') async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRemove(body.ids || []); diff --git a/apps/server/src/students/dto/student.dto.ts b/apps/server/src/students/dto/student.dto.ts index 276e1c8..18d53c2 100644 --- a/apps/server/src/students/dto/student.dto.ts +++ b/apps/server/src/students/dto/student.dto.ts @@ -33,8 +33,9 @@ export class CreateStudentDto { @IsString() emergencyPhone?: string; + @IsOptional() @IsInt() - organizationId: number; + organizationId?: number; @IsOptional() @IsString() diff --git a/apps/server/src/students/students.lifecycle.spec.ts b/apps/server/src/students/students.lifecycle.spec.ts index fbe7b11..3a85d87 100644 --- a/apps/server/src/students/students.lifecycle.spec.ts +++ b/apps/server/src/students/students.lifecycle.spec.ts @@ -43,6 +43,21 @@ describe('StudentsService — archive lifecycle boundaries', () => { expect(repo.save).not.toHaveBeenCalled(); }); + it('defaults a new student to the active host organization when none is supplied', async () => { + const repo = { + create: jest.fn((value) => value), + save: jest.fn(async (value) => ({ ...value, id: 1 })), + }; + const organizationRepo = { + findOne: jest.fn().mockResolvedValue({ id: 7, isHost: true, status: 'active' }), + }; + + await expect(createService(repo, organizationRepo).create({ name: '张三' })).resolves.toEqual( + expect.objectContaining({ organizationId: 7 }), + ); + expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 7 })); + }); + it('returns not found for a missing student', async () => { const repo = { findOne: jest.fn().mockResolvedValue(null) }; await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException); @@ -50,11 +65,13 @@ describe('StudentsService — archive lifecycle boundaries', () => { it('builds export archive maps from profile and result rows', async () => { const profileRepo = { - find: jest.fn().mockResolvedValue([{ - studentId: 1, - targetCollege: '北京大学', - collegeSchool: '北京职业技术学院', - }]), + find: jest.fn().mockResolvedValue([ + { + studentId: 1, + targetCollege: '北京大学', + collegeSchool: '北京职业技术学院', + }, + ]), }; const resultRepo = { find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]), diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 26a2279..4deafab 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -164,8 +164,9 @@ export class StudentsService { } async create(dto: CreateStudentDto) { - await this.assertActiveOrganization(dto.organizationId); - return this.repo.save(this.repo.create(dto)); + const organizationId = dto.organizationId || (await this.getHostOrganizationId()); + await this.assertActiveOrganization(organizationId); + return this.repo.save(this.repo.create({ ...dto, organizationId })); } async update(id: number, dto: UpdateStudentDto) { @@ -314,7 +315,9 @@ export class StudentsService { }; } - private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport { + private normalizeImportData( + importData: StudentWorkbookImport | StudentImportRow[], + ): StudentWorkbookImport { if (Array.isArray(importData)) { return { students: importData, enrollments: [], examScores: [], learningRecords: [] }; } @@ -370,18 +373,24 @@ export class StudentsService { if (!phone) return imported; const enrollmentByClassName = new Map(); - for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) { + for (const enrollmentRow of data.enrollments.filter( + (item) => this.normalizePhone(item.phone) === phone, + )) { const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); if (!enrollment) continue; if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); imported++; } - for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) { + for (const examRow of data.examScores.filter( + (item) => this.normalizePhone(item.phone) === phone, + )) { if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { imported++; } } - for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) { + for (const learningRow of data.learningRecords.filter( + (item) => this.normalizePhone(item.phone) === phone, + )) { if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { imported++; } @@ -390,7 +399,9 @@ export class StudentsService { } private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { - const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId }); + const entity = + (await this.profileRepo.findOne({ where: { studentId } })) || + this.profileRepo.create({ studentId }); if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); @@ -403,9 +414,12 @@ export class StudentsService { } private async upsertResultFromImport(studentId: number, row: StudentImportRow) { - const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId }); + const entity = + (await this.resultRepo.findOne({ where: { studentId } })) || + this.resultRepo.create({ studentId }); if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; - if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore; + if (row.professionalFinalScore !== undefined) + entity.professionalFinalScore = row.professionalFinalScore; if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); @@ -623,10 +637,9 @@ export class StudentsService { // ---- Filters ---- if (query?.keyword) { - qb.andWhere( - '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', - { keyword: `%${query.keyword}%` }, - ); + qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', { + keyword: `%${query.keyword}%`, + }); } if (query?.organizationId) { qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId });