forked from wangziqi/gongxue-base
Server: - Add DingTalk attendance import service with SSE progress streaming - Add IntegrationConfig entity & module for multi-tenant DingTalk setup - Add ExpenseType entity & ExpenseTypesModule - Add SeedModule for DB initialization - Add UserDingMapping entity for DingTalk user linkage - Attendance service: import flow with dedup & student auto-mapping - Rooms service: time-range overlap queries - Sync controller/service: DingTalk integration wiring - Permission guard: refactor to pure re-export - Campus scope middleware: tenant-aware filtering Admin UI: - Attendance page: import UI with progress & result summary - All pages: tableStyle/tablePagination standardization - Login page: responsive styling - Sensitive data: useViewSensitive hook for masked viewing - Vite config: path aliases, build optimization - Test infra: vitest config, test utilities Docs: PRD DingTalk batch 1 & 2 design docs
78 lines
2.4 KiB
TypeScript
78 lines
2.4 KiB
TypeScript
import { Injectable, Scope, Inject } from '@nestjs/common';
|
|
import { REQUEST } from '@nestjs/core';
|
|
import { In } from 'typeorm';
|
|
import { Request } from 'express';
|
|
import { DepartmentsService } from '../departments/departments.service';
|
|
|
|
export interface CampusRequest extends Request {
|
|
user?: {
|
|
id: number;
|
|
isSuperAdmin?: boolean;
|
|
};
|
|
campusScope?: CampusScope;
|
|
}
|
|
|
|
@Injectable({ scope: Scope.REQUEST })
|
|
export class CampusScope {
|
|
constructor(
|
|
@Inject(REQUEST) private req: CampusRequest,
|
|
private departmentsService: DepartmentsService,
|
|
) {}
|
|
|
|
get userId(): number | undefined {
|
|
return this.req.user?.id;
|
|
}
|
|
|
|
get isSuperAdmin(): boolean {
|
|
return this.req.user?.isSuperAdmin ?? false;
|
|
}
|
|
|
|
get currentDepartmentId(): number | null {
|
|
const raw = this.req.headers?.['x-campus-id'];
|
|
if (raw === undefined) return null;
|
|
const str = Array.isArray(raw) ? raw[0] : raw;
|
|
if (!str) return null;
|
|
const id = parseInt(str, 10);
|
|
return Number.isNaN(id) ? null : id;
|
|
}
|
|
|
|
/** Appends departmentId filter. No campus selected = no filtering for super admin; empty result for others. */
|
|
async filter<T extends Record<string, unknown>>(where: T): Promise<T> {
|
|
if (this.isSuperAdmin && !this.currentDepartmentId) {
|
|
return where;
|
|
}
|
|
|
|
const ids = await this.getEffectiveScopeIds();
|
|
if (ids.length === 0) {
|
|
// Non-super-admin with no scoping → match nothing, never leak unfiltered data
|
|
if (!this.isSuperAdmin) {
|
|
return { ...where, departmentId: In([]) };
|
|
}
|
|
return where;
|
|
}
|
|
|
|
return { ...where, departmentId: In(ids) };
|
|
}
|
|
|
|
/** Returns department IDs for QueryBuilder .andWhere(). null = no filtering. */
|
|
async getScopeDepartmentIds(): Promise<number[] | null> {
|
|
if (this.isSuperAdmin && !this.currentDepartmentId) return null;
|
|
const ids = await this.getEffectiveScopeIds();
|
|
return ids.length > 0 ? ids : null;
|
|
}
|
|
|
|
private async getEffectiveScopeIds(): Promise<number[]> {
|
|
if (this.currentDepartmentId) {
|
|
return this.departmentsService.getDescendantIds(this.currentDepartmentId);
|
|
}
|
|
|
|
if (!this.userId) return [];
|
|
|
|
const userDeptIds = await this.departmentsService.getUserDepartments(this.userId);
|
|
const allIds = await Promise.all(
|
|
userDeptIds.map((id) => this.departmentsService.getDescendantIds(id)),
|
|
);
|
|
return [...new Set(allIds.flat())];
|
|
}
|
|
}
|