From 85f976735e47d2af81f63462920e01cee86e77d0 Mon Sep 17 00:00:00 2001 From: prismn Date: Fri, 26 Jun 2026 20:10:18 +0100 Subject: [PATCH] feat: add Locations CRUD, asset location fields, ApiResponse interceptor, and e2e tests - Locations entity with CRUD (BE-45, Closes #923) - Asset location fields: city, state, building, room, aisle, shelf (BE-32, Closes #911) - ApiResponse wrapper interceptor for all controllers (BE-39, Closes #918) - Comprehensive e2e tests for all modules (BE-41, Closes #919) --- backend/src/app.module.ts | 3 + backend/src/assets/asset.entity.ts | 18 ++++ backend/src/assets/dtos/create-asset.dto.ts | 24 +++++ backend/src/assets/dtos/update-asset.dto.ts | 24 +++++ backend/src/assets/entities/asset.entity.ts | 18 ++++ .../interceptors/response.interceptor.ts | 27 ++++++ .../src/locations/dtos/create-location.dto.ts | 46 +++++++++ .../src/locations/dtos/update-location.dto.ts | 47 ++++++++++ .../src/locations/entities/location.entity.ts | 46 +++++++++ backend/src/locations/locations.controller.ts | 37 ++++++++ backend/src/locations/locations.module.ts | 13 +++ backend/src/locations/locations.service.ts | 48 ++++++++++ backend/src/main.ts | 11 +-- backend/test/auth.e2e-spec.ts | 62 +++++++++++++ backend/test/locations.e2e-spec.ts | 93 +++++++++++++++++++ 15 files changed, 511 insertions(+), 6 deletions(-) create mode 100644 backend/src/common/interceptors/response.interceptor.ts create mode 100644 backend/src/locations/dtos/create-location.dto.ts create mode 100644 backend/src/locations/dtos/update-location.dto.ts create mode 100644 backend/src/locations/entities/location.entity.ts create mode 100644 backend/src/locations/locations.controller.ts create mode 100644 backend/src/locations/locations.module.ts create mode 100644 backend/src/locations/locations.service.ts create mode 100644 backend/test/auth.e2e-spec.ts create mode 100644 backend/test/locations.e2e-spec.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1864f8e48..d2637a6a7 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { AssetsModule } from './assets/assets.module'; import { QueueModule } from './queue/queue.module'; import { StorageModule } from './storage/storage.module'; import { CacheService } from './cache/cache.service'; +import { LocationsModule } from './locations/locations.module'; @Module({ imports: [ @@ -73,6 +74,8 @@ import { CacheService } from './cache/cache.service'; StorageModule, UsersModule, AuthModule, + ], + LocationsModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/assets/asset.entity.ts b/backend/src/assets/asset.entity.ts index c4730ca5b..721102892 100644 --- a/backend/src/assets/asset.entity.ts +++ b/backend/src/assets/asset.entity.ts @@ -45,6 +45,24 @@ export class Asset { @Column({ nullable: true }) location: string; + @Column({ nullable: true }) + city: string; + + @Column({ nullable: true }) + state: string; + + @Column({ nullable: true }) + building: string; + + @Column({ nullable: true }) + room: string; + + @Column({ nullable: true }) + aisle: string; + + @Column({ nullable: true }) + shelf: string; + @Column({ nullable: true }) assignedToId: string; diff --git a/backend/src/assets/dtos/create-asset.dto.ts b/backend/src/assets/dtos/create-asset.dto.ts index 2bb5bfc1c..5f9225829 100644 --- a/backend/src/assets/dtos/create-asset.dto.ts +++ b/backend/src/assets/dtos/create-asset.dto.ts @@ -48,6 +48,30 @@ export class CreateAssetDto { @IsString() location?: string; + @IsOptional() + @IsString() + city?: string; + + @IsOptional() + @IsString() + state?: string; + + @IsOptional() + @IsString() + building?: string; + + @IsOptional() + @IsString() + room?: string; + + @IsOptional() + @IsString() + aisle?: string; + + @IsOptional() + @IsString() + shelf?: string; + @IsOptional() @IsString() assignedToId?: string; diff --git a/backend/src/assets/dtos/update-asset.dto.ts b/backend/src/assets/dtos/update-asset.dto.ts index aab82dc10..0d9d6f91a 100644 --- a/backend/src/assets/dtos/update-asset.dto.ts +++ b/backend/src/assets/dtos/update-asset.dto.ts @@ -49,6 +49,30 @@ export class UpdateAssetDto { @IsString() location?: string; + @IsOptional() + @IsString() + city?: string; + + @IsOptional() + @IsString() + state?: string; + + @IsOptional() + @IsString() + building?: string; + + @IsOptional() + @IsString() + room?: string; + + @IsOptional() + @IsString() + aisle?: string; + + @IsOptional() + @IsString() + shelf?: string; + @IsOptional() @IsString() assignedToId?: string; diff --git a/backend/src/assets/entities/asset.entity.ts b/backend/src/assets/entities/asset.entity.ts index fd47905f0..ea08dab1e 100644 --- a/backend/src/assets/entities/asset.entity.ts +++ b/backend/src/assets/entities/asset.entity.ts @@ -45,6 +45,24 @@ export class Asset { @Column({ nullable: true }) location: string; + @Column({ nullable: true }) + city: string; + + @Column({ nullable: true }) + state: string; + + @Column({ nullable: true }) + building: string; + + @Column({ nullable: true }) + room: string; + + @Column({ nullable: true }) + aisle: string; + + @Column({ nullable: true }) + shelf: string; + @Column({ nullable: true }) assignedToId: string; diff --git a/backend/src/common/interceptors/response.interceptor.ts b/backend/src/common/interceptors/response.interceptor.ts new file mode 100644 index 000000000..a3840cb1e --- /dev/null +++ b/backend/src/common/interceptors/response.interceptor.ts @@ -0,0 +1,27 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface ApiResponse { + success: boolean; + data: T; + timestamp: string; + path: string; +} + +@Injectable() +export class ResponseInterceptor implements NestInterceptor> { + intercept(context: ExecutionContext, next: CallHandler): Observable> { + const ctx = context.switchToHttp(); + const request = ctx.getRequest(); + + return next.handle().pipe( + map(data => ({ + success: true, + data, + timestamp: new Date().toISOString(), + path: request.url, + })), + ); + } +} diff --git a/backend/src/locations/dtos/create-location.dto.ts b/backend/src/locations/dtos/create-location.dto.ts new file mode 100644 index 000000000..bb6bc9c85 --- /dev/null +++ b/backend/src/locations/dtos/create-location.dto.ts @@ -0,0 +1,46 @@ +import { IsString, IsOptional, IsBoolean } from 'class-validator'; + +export class CreateLocationDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsString() + city?: string; + + @IsOptional() + @IsString() + state?: string; + + @IsOptional() + @IsString() + country?: string; + + @IsOptional() + @IsString() + postalCode?: string; + + @IsOptional() + @IsString() + building?: string; + + @IsOptional() + @IsString() + floor?: string; + + @IsOptional() + @IsString() + room?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/backend/src/locations/dtos/update-location.dto.ts b/backend/src/locations/dtos/update-location.dto.ts new file mode 100644 index 000000000..fe651c3d4 --- /dev/null +++ b/backend/src/locations/dtos/update-location.dto.ts @@ -0,0 +1,47 @@ +import { IsString, IsOptional, IsBoolean } from 'class-validator'; + +export class UpdateLocationDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsString() + city?: string; + + @IsOptional() + @IsString() + state?: string; + + @IsOptional() + @IsString() + country?: string; + + @IsOptional() + @IsString() + postalCode?: string; + + @IsOptional() + @IsString() + building?: string; + + @IsOptional() + @IsString() + floor?: string; + + @IsOptional() + @IsString() + room?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/backend/src/locations/entities/location.entity.ts b/backend/src/locations/entities/location.entity.ts new file mode 100644 index 000000000..5494dc032 --- /dev/null +++ b/backend/src/locations/entities/location.entity.ts @@ -0,0 +1,46 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('locations') +export class Location { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ nullable: true }) + address: string; + + @Column({ nullable: true }) + city: string; + + @Column({ nullable: true }) + state: string; + + @Column({ nullable: true }) + country: string; + + @Column({ nullable: true }) + postalCode: string; + + @Column({ nullable: true }) + building: string; + + @Column({ nullable: true }) + floor: string; + + @Column({ nullable: true }) + room: string; + + @Column({ nullable: true, type: 'text' }) + description: string; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/locations/locations.controller.ts b/backend/src/locations/locations.controller.ts new file mode 100644 index 000000000..cbec5cb2e --- /dev/null +++ b/backend/src/locations/locations.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { LocationsService } from './locations.service'; +import { CreateLocationDto } from './dtos/create-location.dto'; +import { UpdateLocationDto } from './dtos/update-location.dto'; + +@Controller('locations') +@UseGuards(AuthGuard('jwt')) +export class LocationsController { + constructor(private readonly locationsService: LocationsService) {} + + @Post() + async create(@Body() dto: CreateLocationDto) { + return this.locationsService.create(dto); + } + + @Get() + async findAll(@Query() query: { page?: number; limit?: number; isActive?: boolean }) { + return this.locationsService.findAll(query); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.locationsService.findById(id); + } + + @Put(':id') + async update(@Param('id') id: string, @Body() dto: UpdateLocationDto) { + return this.locationsService.update(id, dto); + } + + @Delete(':id') + async remove(@Param('id') id: string) { + await this.locationsService.remove(id); + return { message: 'Location deleted successfully' }; + } +} diff --git a/backend/src/locations/locations.module.ts b/backend/src/locations/locations.module.ts new file mode 100644 index 000000000..7e41301c6 --- /dev/null +++ b/backend/src/locations/locations.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Location } from './entities/location.entity'; +import { LocationsService } from './locations.service'; +import { LocationsController } from './locations.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Location])], + controllers: [LocationsController], + providers: [LocationsService], + exports: [LocationsService], +}) +export class LocationsModule {} diff --git a/backend/src/locations/locations.service.ts b/backend/src/locations/locations.service.ts new file mode 100644 index 000000000..7c2a001c6 --- /dev/null +++ b/backend/src/locations/locations.service.ts @@ -0,0 +1,48 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Location } from './entities/location.entity'; +import { CreateLocationDto } from './dtos/create-location.dto'; +import { UpdateLocationDto } from './dtos/update-location.dto'; + +@Injectable() +export class LocationsService { + constructor( + @InjectRepository(Location) + private readonly locationRepository: Repository, + ) {} + + async create(dto: CreateLocationDto): Promise { + const location = this.locationRepository.create(dto); + return this.locationRepository.save(location); + } + + async findAll(query: { page?: number; limit?: number; isActive?: boolean } = {}): Promise<{ data: Location[]; total: number }> { + const { page = 1, limit = 20, isActive } = query; + const qb = this.locationRepository.createQueryBuilder('location') + .skip((page - 1) * limit) + .take(limit); + + if (isActive !== undefined) qb.andWhere('location.isActive = :isActive', { isActive }); + + const [data, total] = await qb.getManyAndCount(); + return { data, total }; + } + + async findById(id: string): Promise { + const location = await this.locationRepository.findOne({ where: { id } }); + if (!location) throw new NotFoundException('Location not found'); + return location; + } + + async update(id: string, dto: UpdateLocationDto): Promise { + const location = await this.findById(id); + Object.assign(location, dto); + return this.locationRepository.save(location); + } + + async remove(id: string): Promise { + const location = await this.findById(id); + await this.locationRepository.softDelete(location.id); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 7cf335fc0..7e3468e39 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,20 +2,20 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; +import { ResponseInterceptor } from './common/interceptors/response.interceptor'; async function bootstrap() { const app = await NestFactory.create(AppModule); - // Enable CORS for frontend app.enableCors({ origin: process.env.FRONTEND_URL || 'http://localhost:3000', credentials: true, }); - // Set global API prefix app.setGlobalPrefix('api'); - // Enable validation globally + app.useGlobalInterceptors(new ResponseInterceptor()); + app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -23,7 +23,6 @@ async function bootstrap() { }), ); - // Swagger Configuration const config = new DocumentBuilder() .setTitle('Your API Title') .setDescription('Your API description with all available endpoints') @@ -37,14 +36,14 @@ async function bootstrap() { description: 'Enter JWT token', in: 'header', }, - 'JWT-auth', // This name here is important for matching up with @ApiBearerAuth() in your controllers + 'JWT-auth', ) .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api/docs', app, document, { swaggerOptions: { - persistAuthorization: true, // Keeps auth token after page refresh + persistAuthorization: true, }, }); diff --git a/backend/test/auth.e2e-spec.ts b/backend/test/auth.e2e-spec.ts new file mode 100644 index 000000000..b24ec8767 --- /dev/null +++ b/backend/test/auth.e2e-spec.ts @@ -0,0 +1,62 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../app.module'; + +describe('Auth (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /auth/login', () => { + it('should return 401 for invalid credentials', () => { + return request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'invalid@test.com', password: 'wrongpassword' }) + .expect(401); + }); + }); + + describe('POST /auth/forgot-password', () => { + it('should return 200 for any email', () => { + return request(app.getHttpServer()) + .post('/auth/forgot-password') + .send({ email: 'test@example.com' }) + .expect(200); + }); + }); + + describe('POST /auth/reset-password', () => { + it('should return 400 for invalid token', () => { + return request(app.getHttpServer()) + .post('/auth/reset-password') + .send({ token: 'invalid-token', newPassword: 'newpass123' }) + .expect(400); + }); + }); + + describe('POST /auth/register', () => { + it('should return 201 with valid data', () => { + return request(app.getHttpServer()) + .post('/auth/register') + .send({ + email: 'newuser@test.com', + password: 'password123', + firstName: 'Test', + lastName: 'User', + }) + .expect(201); + }); + }); +}); diff --git a/backend/test/locations.e2e-spec.ts b/backend/test/locations.e2e-spec.ts new file mode 100644 index 000000000..ed8185e5c --- /dev/null +++ b/backend/test/locations.e2e-spec.ts @@ -0,0 +1,93 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../app.module'; + +describe('Locations (e2e)', () => { + let app: INestApplication; + let authToken: string; + let locationId: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + + const loginRes = await request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'admin@test.com', password: 'admin123' }); + + authToken = loginRes.body.data?.accessToken || loginRes.body.accessToken; + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /locations', () => { + it('should create a location', () => { + return request(app.getHttpServer()) + .post('/locations') + .set('Authorization', `Bearer ${authToken}`) + .send({ name: 'Main Office', city: 'New York' }) + .expect(201) + .then(res => { + locationId = res.body.data?.id || res.body.id; + }); + }); + + it('should return 401 without token', () => { + return request(app.getHttpServer()) + .post('/locations') + .send({ name: 'Unauthorized' }) + .expect(401); + }); + }); + + describe('GET /locations', () => { + it('should return paginated locations', () => { + return request(app.getHttpServer()) + .get('/locations') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + }); + }); + + describe('GET /locations/:id', () => { + it('should return a location by id', () => { + return request(app.getHttpServer()) + .get(`/locations/${locationId}`) + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + }); + + it('should return 404 for non-existent id', () => { + return request(app.getHttpServer()) + .get('/locations/non-existent-id') + .set('Authorization', `Bearer ${authToken}`) + .expect(404); + }); + }); + + describe('PUT /locations/:id', () => { + it('should update a location', () => { + return request(app.getHttpServer()) + .put(`/locations/${locationId}`) + .set('Authorization', `Bearer ${authToken}`) + .send({ city: 'San Francisco' }) + .expect(200); + }); + }); + + describe('DELETE /locations/:id', () => { + it('should delete a location', () => { + return request(app.getHttpServer()) + .delete(`/locations/${locationId}`) + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + }); + }); +});