From b294df9cf52155803cd4314ca77456f267da96c5 Mon Sep 17 00:00:00 2001 From: Peace Date: Tue, 12 Aug 2025 17:33:02 +0900 Subject: [PATCH] be s --- .../src/sensors/dto/create-sensor-data.dto.ts | 17 ++ .../sensors/dto/create-sensor-group.dto.ts | 14 ++ backend/src/sensors/dto/create-sensor.dto.ts | 19 +++ .../sensors/dto/sensor-data-response.dto.ts | 12 ++ .../sensors/dto/sensor-group-response.dto.ts | 12 ++ .../src/sensors/dto/sensor-response.dto.ts | 15 ++ .../sensors/entities/sensor-group.entity.ts | 3 + backend/src/sensors/entities/sensor.entity.ts | 3 + .../src/sensors/sensors.controller.spec.ts | 18 +++ backend/src/sensors/sensors.controller.ts | 24 +++ backend/src/sensors/sensors.module.ts | 7 +- backend/src/sensors/sensors.service.spec.ts | 18 +++ backend/src/sensors/sensors.service.ts | 148 ++++++++++++++++++ backend/src/users/users.controller.ts | 3 +- 14 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 backend/src/sensors/dto/create-sensor-data.dto.ts create mode 100644 backend/src/sensors/dto/create-sensor-group.dto.ts create mode 100644 backend/src/sensors/dto/create-sensor.dto.ts create mode 100644 backend/src/sensors/dto/sensor-data-response.dto.ts create mode 100644 backend/src/sensors/dto/sensor-group-response.dto.ts create mode 100644 backend/src/sensors/dto/sensor-response.dto.ts create mode 100644 backend/src/sensors/sensors.controller.spec.ts create mode 100644 backend/src/sensors/sensors.controller.ts create mode 100644 backend/src/sensors/sensors.service.spec.ts create mode 100644 backend/src/sensors/sensors.service.ts diff --git a/backend/src/sensors/dto/create-sensor-data.dto.ts b/backend/src/sensors/dto/create-sensor-data.dto.ts new file mode 100644 index 0000000..eb556ca --- /dev/null +++ b/backend/src/sensors/dto/create-sensor-data.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString } from 'class-validator'; + +export class CreateSensorDataDto { + @ApiProperty({ example: 23.56, description: '센서 측정값(숫자)' }) + @IsNumber() + value!: number; + + @ApiProperty({ + example: '2025-08-12T10:30:00Z', + required: false, + description: '기록 시각(ISO). 없으면 서버가 now() 사용', + }) + @IsOptional() + @IsString() + recordedAt?: string; +} diff --git a/backend/src/sensors/dto/create-sensor-group.dto.ts b/backend/src/sensors/dto/create-sensor-group.dto.ts new file mode 100644 index 0000000..0b2b02e --- /dev/null +++ b/backend/src/sensors/dto/create-sensor-group.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateSensorGroupDto { + @ApiProperty({ description: '센서 그룹 이름', example: 'Sensor group 1' }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ description: '설명', example: 'desc...', required: false }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend/src/sensors/dto/create-sensor.dto.ts b/backend/src/sensors/dto/create-sensor.dto.ts new file mode 100644 index 0000000..76efaa9 --- /dev/null +++ b/backend/src/sensors/dto/create-sensor.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; + +export class CreateSensorDto { + @ApiProperty({ description: '소속 센서 그룹 ID', example: 1 }) + @IsInt() + @Min(1) + groupId!: number; + + @ApiProperty({ description: '센서 이름', example: '센서 1' }) + @IsString() + @IsNotEmpty() + name!: string; + + @ApiProperty({ description: '센서 단위', example: '℃', required: false }) + @IsString() + @IsOptional() + unit?: string; +} diff --git a/backend/src/sensors/dto/sensor-data-response.dto.ts b/backend/src/sensors/dto/sensor-data-response.dto.ts new file mode 100644 index 0000000..b8cd17e --- /dev/null +++ b/backend/src/sensors/dto/sensor-data-response.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SensorDataResponseDto { + @ApiProperty({ example: 999 }) + id!: number; + + @ApiProperty({ example: 23.56 }) + value!: number; + + @ApiProperty({ example: '2025-08-12T10:30:00.000Z' }) + recordedAt!: Date; +} diff --git a/backend/src/sensors/dto/sensor-group-response.dto.ts b/backend/src/sensors/dto/sensor-group-response.dto.ts new file mode 100644 index 0000000..75c328a --- /dev/null +++ b/backend/src/sensors/dto/sensor-group-response.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SensorGroupResponseDto { + @ApiProperty({ example: 10 }) + id!: number; + + @ApiProperty({ example: 'Factory A Line 1' }) + name!: string; + + @ApiProperty({ example: 'Main production line group', required: false }) + description?: string; +} diff --git a/backend/src/sensors/dto/sensor-response.dto.ts b/backend/src/sensors/dto/sensor-response.dto.ts new file mode 100644 index 0000000..3f8d2dc --- /dev/null +++ b/backend/src/sensors/dto/sensor-response.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SensorResponseDto { + @ApiProperty({ example: 101 }) + id!: number; + + @ApiProperty({ example: 'Temp Sensor 01' }) + name!: string; + + @ApiProperty({ example: '°C', required: false }) + unit?: string; + + @ApiProperty({ example: 10, description: '소속 그룹 ID' }) + groupId!: number; +} diff --git a/backend/src/sensors/entities/sensor-group.entity.ts b/backend/src/sensors/entities/sensor-group.entity.ts index cf4dd43..dcc029a 100644 --- a/backend/src/sensors/entities/sensor-group.entity.ts +++ b/backend/src/sensors/entities/sensor-group.entity.ts @@ -11,6 +11,9 @@ export class SensorGroup extends TimestampedEntity { @Column({ unique: true }) name: string; + @Column() + description: string; + @ManyToMany(() => User, (user) => user.sensorGroups) users: User[]; diff --git a/backend/src/sensors/entities/sensor.entity.ts b/backend/src/sensors/entities/sensor.entity.ts index e903354..937ebc7 100644 --- a/backend/src/sensors/entities/sensor.entity.ts +++ b/backend/src/sensors/entities/sensor.entity.ts @@ -11,6 +11,9 @@ export class Sensor extends TimestampedEntity { @Column({ unique: true }) name: string; + @Column() + unit: string; + @ManyToOne(() => SensorGroup, (group) => group.sensors, { onDelete: 'CASCADE' }) // FK Onwer group: SensorGroup; diff --git a/backend/src/sensors/sensors.controller.spec.ts b/backend/src/sensors/sensors.controller.spec.ts new file mode 100644 index 0000000..1ca49bb --- /dev/null +++ b/backend/src/sensors/sensors.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SensorsController } from './sensors.controller'; + +describe('SensorsController', () => { + let controller: SensorsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SensorsController], + }).compile(); + + controller = module.get(SensorsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/src/sensors/sensors.controller.ts b/backend/src/sensors/sensors.controller.ts new file mode 100644 index 0000000..f13d724 --- /dev/null +++ b/backend/src/sensors/sensors.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common'; +import { SensorsService } from './sensors.service'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Sensor } from './entities/sensor.entity'; + +@ApiTags('센서') +@Controller('sensors') +export class SensorsController { + constructor(private readonly sensorService: SensorsService) {} + + @Get() + @ApiOperation({ summary: '센서 목록 조회' }) + @ApiOkResponse({ description: '센서 목록', type: [Sensor] }) + async findAll(): Promise { + return this.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: '특정 센서 조회' }) + @ApiOkResponse({ description: '센서', type: [Sensor] }) + async findOne(@Param('id', ParseIntPipe) id: number): Promise { + return this.findOne(id); + } +} diff --git a/backend/src/sensors/sensors.module.ts b/backend/src/sensors/sensors.module.ts index d9f39ad..946fcaf 100644 --- a/backend/src/sensors/sensors.module.ts +++ b/backend/src/sensors/sensors.module.ts @@ -3,8 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { SensorGroup } from './entities/sensor-group.entity'; import { Sensor } from './entities/sensor.entity'; import { SensorData } from './entities/sensor-data.entity'; +import { SensorsService } from './sensors.service'; +import { SensorsController } from './sensors.controller'; +import { User } from 'src/users/entities/user.entity'; @Module({ - imports: [TypeOrmModule.forFeature([SensorGroup, Sensor, SensorData])], + imports: [TypeOrmModule.forFeature([SensorGroup, Sensor, SensorData, User])], + providers: [SensorsService], + controllers: [SensorsController], }) export class SensorsModule {} diff --git a/backend/src/sensors/sensors.service.spec.ts b/backend/src/sensors/sensors.service.spec.ts new file mode 100644 index 0000000..5b75886 --- /dev/null +++ b/backend/src/sensors/sensors.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SensorsService } from './sensors.service'; + +describe('SensorsService', () => { + let service: SensorsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SensorsService], + }).compile(); + + service = module.get(SensorsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/sensors/sensors.service.ts b/backend/src/sensors/sensors.service.ts new file mode 100644 index 0000000..e469c3f --- /dev/null +++ b/backend/src/sensors/sensors.service.ts @@ -0,0 +1,148 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Sensor } from './entities/sensor.entity'; +import { Repository } from 'typeorm'; +import { SensorGroup } from './entities/sensor-group.entity'; +import { SensorData } from './entities/sensor-data.entity'; +import { User } from 'src/users/entities/user.entity'; +import { CreateSensorGroupDto } from './dto/create-sensor-group.dto'; +import { SensorGroupResponseDto } from './dto/sensor-group-response.dto'; +import { CreateSensorDto } from './dto/create-sensor.dto'; +import { SensorResponseDto } from './dto/sensor-response.dto'; +import { SensorDataResponseDto } from './dto/sensor-data-response.dto'; +import { CreateSensorDataDto } from './dto/create-sensor-data.dto'; + +@Injectable() +export class SensorsService { + constructor( + @InjectRepository(SensorGroup) private readonly groupRepo: Repository, + @InjectRepository(Sensor) private readonly sensorRepo: Repository, + @InjectRepository(SensorData) private readonly dataRepo: Repository, + @InjectRepository(User) private readonly userRepo: Repository, + ) {} + + //#region SensorGroup + async createGroup( + currentUserId: number, + dto: CreateSensorGroupDto, + ): Promise { + const me = await this.userRepo.findOne({ where: { id: currentUserId } }); + if (!me) throw new NotFoundException('User not found'); + + const group = this.groupRepo.create({ + name: dto.name, + description: dto.description, + users: [me], + }); + + const saved = await this.groupRepo.save(group); + return { + id: saved.id, + name: saved.name, + description: saved.description ?? undefined, + }; + } + + async findMyGroups(currentUserId: number): Promise { + const groups = await this.groupRepo + .createQueryBuilder('g') + .innerJoin('g.users', 'u', 'u.id = :uid', { uid: currentUserId }) + .select(['g.id AS id', 'g.name AS name', 'g.description AS description']) + .distinct(true) + .orderBy('g.id', 'DESC') + .getRawMany<{ id: number; name: string; description: string | null }>(); + + return groups.map((g) => ({ id: g.id, name: g.name, description: g.description ?? undefined })); + } + + private async assertMemberOfGroup(currentUserId: number, groupId: number): Promise { + const group = await this.groupRepo.findOne({ where: { id: groupId }, relations: ['users'] }); + if (!group) throw new NotFoundException('SensorGroup not found'); + + const isMember = group.users?.some((u) => u.id === currentUserId); + if (!isMember) throw new ForbiddenException('No permission on this group'); + + return group; + } + //#endregion + + //#region Sensor + async createSensor(currentUserId: number, dto: CreateSensorDto): Promise { + const group = await this.assertMemberOfGroup(currentUserId, dto.groupId); + const sensor = this.sensorRepo.create({ + name: dto.name, + unit: dto.unit, + group, + }); + const saved = await this.sensorRepo.save(sensor); + + return { + id: saved.id, + name: saved.name, + unit: saved.unit ?? undefined, + groupId: group.id, + }; + } + + async findSensorsInGroup(currentUserId: number, groupId: number): Promise { + await this.assertMemberOfGroup(currentUserId, groupId); + const sensors = await this.sensorRepo.find({ + where: { group: { id: groupId } }, + order: { id: 'DESC' }, + }); + + return sensors.map((s) => ({ + id: s.id, + name: s.name, + unit: s.unit ?? undefined, + groupId, + })); + } + + private async assertMemeberOfSensor(currentUserId: number, sensorId: number): Promise { + const sensor = await this.sensorRepo.findOne({ + where: { id: sensorId }, + relations: ['group', 'group.users'], + }); + if (!sensor) throw new NotFoundException('Sensor not found'); + + const isMember = sensor.group?.users?.some((u) => u.id === currentUserId); + if (!isMember) throw new ForbiddenException('No permission on this sensor'); + + return sensor; + } + //#endregion + + //#region SensorData + async createSensorData( + currentUserId: number, + sensorId: number, + dto: CreateSensorDataDto, + ): Promise { + const sensor = await this.assertMemeberOfSensor(currentUserId, sensorId); + const recordedAt = dto.recordedAt ? new Date(dto.recordedAt) : new Date(); + if (Number.isNaN(recordedAt.getTime())) throw new BadRequestException('Invalid recoredAt'); + + const data = this.dataRepo.create({ sensor, value: dto.value, recordedAt }); + const saved = await this.dataRepo.save(data); + + return { + id: saved.id, + value: saved.value, + recordedAt: saved.recordedAt, + }; + } + //#endregion + async findAll(): Promise { + return this.sensorRepo.find({ relations: ['group'] }); + } + + async findOne(id: number): Promise { + return this.sensorRepo.findOne({ where: { id }, relations: ['group'] }); + } +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 03f1851..0fa9783 100755 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -3,7 +3,7 @@ import { UsersService } from './users.service'; import { ChangePasswordDto } from './dto/change-password.dto'; import { JwtAuthGuard } from 'src/auth/jwt-auth.guard'; import { AuthRequest } from 'src/common/interfaces/auth-request.interface'; -import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { UserInfoResponseDto } from './dto/user-info-response.dto'; @ApiTags('사용자') @@ -14,6 +14,7 @@ export class UsersController { @Patch('password') @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @ApiOperation({ summary: '비밀번호 변경' }) @ApiOkResponse({ description: '성공', type: UserInfoResponseDto }) async changePassword( @Request() req: AuthRequest,