diff --git a/backend/src/assets/asset-label.service.spec.ts b/backend/src/assets/asset-label.service.spec.ts new file mode 100644 index 00000000..cf67ecc0 --- /dev/null +++ b/backend/src/assets/asset-label.service.spec.ts @@ -0,0 +1,52 @@ +import { NotFoundException } from '@nestjs/common'; +import { AssetLabelService } from './asset-label.service'; + +const asset = { id: 'a-1', assetTag: 'AST-00001' }; + +function build(found: unknown) { + const repo = { findOne: jest.fn().mockResolvedValue(found) }; + return new AssetLabelService(repo as never); +} + +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + +describe('AssetLabelService', () => { + it('returns a PNG QR code for a known asset', async () => { + const img = await build(asset).qr('a-1'); + expect(img.contentType).toBe('image/png'); + expect((img.body as Buffer).subarray(0, 4)).toEqual(PNG); + }); + + it('returns an SVG QR code on request', async () => { + const img = await build(asset).qr('a-1', 'svg', 128); + expect(img.contentType).toBe('image/svg+xml'); + expect(String(img.body)).toContain(' { + const img = await build(asset).label('a-1'); + expect(img.contentType).toBe('image/png'); + expect((img.body as Buffer).subarray(0, 4)).toEqual(PNG); + }); + + it('throws 404 for an unknown asset', async () => { + const svc = build(null); + await expect(svc.qr('missing')).rejects.toBeInstanceOf(NotFoundException); + await expect(svc.label('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('encodes the frontend asset URL', () => { + const prev = process.env.FRONTEND_URL; + process.env.FRONTEND_URL = 'https://app.example.com/'; + try { + expect(build(asset).scanUrl('a-1')).toBe( + 'https://app.example.com/assets/a-1', + ); + } finally { + if (prev === undefined) delete process.env.FRONTEND_URL; + else process.env.FRONTEND_URL = prev; + } + }); +}); diff --git a/backend/src/assets/asset-label.service.ts b/backend/src/assets/asset-label.service.ts new file mode 100644 index 00000000..39e34cf7 --- /dev/null +++ b/backend/src/assets/asset-label.service.ts @@ -0,0 +1,68 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as QRCode from 'qrcode'; +import * as bwipjs from 'bwip-js'; +import { Asset } from './entities/asset.entity'; + +export interface RenderedImage { + body: Buffer | string; + contentType: string; +} + +/** Renders the QR code and printable barcode label for an asset. */ +@Injectable() +export class AssetLabelService { + constructor( + @InjectRepository(Asset) + private readonly assetRepo: Repository, + ) {} + + /** The URL a scanned QR code opens. */ + scanUrl(assetId: string): string { + const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace( + /\/+$/, + '', + ); + return `${base}/assets/${assetId}`; + } + + private async load(id: string): Promise { + const asset = await this.assetRepo.findOne({ where: { id } }); + if (!asset) throw new NotFoundException(`Asset ${id} not found`); + return asset; + } + + async qr( + id: string, + format: 'png' | 'svg' = 'png', + size = 256, + ): Promise { + await this.load(id); + const url = this.scanUrl(id); + if (format === 'svg') { + const body = await QRCode.toString(url, { + type: 'svg', + width: size, + margin: 1, + }); + return { body, contentType: 'image/svg+xml' }; + } + const body = await QRCode.toBuffer(url, { width: size, margin: 1 }); + return { body, contentType: 'image/png' }; + } + + /** Code 128 barcode of the asset tag, with the tag printed beneath it. */ + async label(id: string, scale = 3): Promise { + const asset = await this.load(id); + const body = await bwipjs.toBuffer({ + bcid: 'code128', + text: asset.assetTag, + scale, + height: 12, + includetext: true, + textxalign: 'center', + }); + return { body, contentType: 'image/png' }; + } +} diff --git a/backend/src/assets/assets.controller.ts b/backend/src/assets/assets.controller.ts index c4fb29ea..5a0d066e 100644 --- a/backend/src/assets/assets.controller.ts +++ b/backend/src/assets/assets.controller.ts @@ -8,6 +8,7 @@ import { Param, Query, UseGuards, + StreamableFile, } from '@nestjs/common'; import { ApiTags, @@ -18,6 +19,8 @@ import { } from '@nestjs/swagger'; import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { AssetsService } from './assets.service'; +import { AssetLabelService } from './asset-label.service'; +import { AssetQrQueryDto, AssetLabelQueryDto } from './dto/asset-qr-query.dto'; import { PublicAssetDto } from './dto/public-asset.dto'; import { AssetHistoryService } from './asset-history.service'; import { ReservationsService } from '../reservations/reservations.service'; @@ -42,6 +45,7 @@ export class AssetsController { private readonly assetsService: AssetsService, private readonly assetHistoryService: AssetHistoryService, private readonly reservationsService: ReservationsService, + private readonly labelService: AssetLabelService, ) {} @Get() @@ -97,6 +101,30 @@ export class AssetsController { return this.assetsService.findDetail(id); } + @Get(':id/qr') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'QR code image (PNG or SVG) encoding the asset scan URL', + }) + @ApiResponse({ status: 200, description: 'QR image' }) + @ApiResponse({ status: 404, description: 'Asset not found' }) + async qr(@Param('id') id: string, @Query() query: AssetQrQueryDto) { + const img = await this.labelService.qr(id, query.format, query.size); + return new StreamableFile(Buffer.from(img.body), { type: img.contentType }); + } + + @Get(':id/label') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Printable Code 128 barcode label (PNG) of the asset tag', + }) + @ApiResponse({ status: 200, description: 'Barcode label image' }) + @ApiResponse({ status: 404, description: 'Asset not found' }) + async label(@Param('id') id: string, @Query() query: AssetLabelQueryDto) { + const img = await this.labelService.label(id, query.scale); + return new StreamableFile(Buffer.from(img.body), { type: img.contentType }); + } + // Literal `bulk` routes must be declared before the `:id` routes below, // otherwise Express matches `bulk` as an id and these handlers never run. @Patch('bulk/status') diff --git a/backend/src/assets/assets.module.ts b/backend/src/assets/assets.module.ts index c5d3bd4e..99a1f4ae 100644 --- a/backend/src/assets/assets.module.ts +++ b/backend/src/assets/assets.module.ts @@ -4,6 +4,7 @@ import { ThrottlerModule } from '@nestjs/throttler'; import { Asset } from './entities/asset.entity'; import { AssetsService } from './assets.service'; import { AssetsController } from './assets.controller'; +import { AssetLabelService } from './asset-label.service'; import { AssetsLifecycleModule } from './assets-lifecycle.module'; import { AssetHistoryModule } from './asset-history.module'; import { AuditLogsModule } from '../audit-logs/audit-logs.module'; @@ -20,7 +21,7 @@ import { User } from '../users/entities/user.entity'; AuditLogsModule, ReservationsModule, ], - providers: [AssetsService], + providers: [AssetsService, AssetLabelService], controllers: [AssetsController], exports: [AssetsService], }) diff --git a/backend/src/assets/dto/asset-qr-query.dto.ts b/backend/src/assets/dto/asset-qr-query.dto.ts new file mode 100644 index 00000000..1fceee90 --- /dev/null +++ b/backend/src/assets/dto/asset-qr-query.dto.ts @@ -0,0 +1,28 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class AssetQrQueryDto { + @ApiPropertyOptional({ enum: ['png', 'svg'], default: 'png' }) + @IsOptional() + @IsIn(['png', 'svg']) + format?: 'png' | 'svg' = 'png'; + + @ApiPropertyOptional({ description: 'Width/height in px', default: 256 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(64) + @Max(1024) + size?: number = 256; +} + +export class AssetLabelQueryDto { + @ApiPropertyOptional({ description: 'Bar width scale', default: 3 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(6) + scale?: number = 3; +} diff --git a/backend/src/departments/departments.controller.ts b/backend/src/departments/departments.controller.ts index b6ee220b..3c2ff0d4 100644 --- a/backend/src/departments/departments.controller.ts +++ b/backend/src/departments/departments.controller.ts @@ -48,6 +48,22 @@ export class DepartmentsController { return this.deptService.findById(id); } + @Get(':id/assets') + @ApiOperation({ summary: 'List assets in a department (paginated)' }) + @ApiResponse({ status: 200, description: 'Paginated list of assets' }) + @ApiResponse({ status: 404, description: 'Department not found' }) + findAssets(@Param('id') id: string, @Query() query: PaginationQueryDto) { + return this.deptService.findAssets(id, query); + } + + @Get(':id/users') + @ApiOperation({ summary: 'List users in a department (paginated)' }) + @ApiResponse({ status: 200, description: 'Paginated list of users' }) + @ApiResponse({ status: 404, description: 'Department not found' }) + findUsers(@Param('id') id: string, @Query() query: PaginationQueryDto) { + return this.deptService.findUsers(id, query); + } + @Patch(':id') @ApiOperation({ summary: 'Update a department' }) @ApiResponse({ status: 200, description: 'Department updated' }) diff --git a/backend/src/departments/departments.module.ts b/backend/src/departments/departments.module.ts index 77cb05ed..75e85751 100644 --- a/backend/src/departments/departments.module.ts +++ b/backend/src/departments/departments.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { Asset } from '../assets/entities/asset.entity'; +import { User } from '../users/entities/user.entity'; import { Department } from './entities/department.entity'; import { DepartmentsService } from './departments.service'; import { DepartmentsController } from './departments.controller'; import { AuditLogsModule } from '../audit-logs/audit-logs.module'; @Module({ - imports: [TypeOrmModule.forFeature([Department]), AuditLogsModule], + imports: [TypeOrmModule.forFeature([Department, Asset, User]), AuditLogsModule], providers: [DepartmentsService], controllers: [DepartmentsController], exports: [DepartmentsService], diff --git a/backend/src/departments/departments.service.spec.ts b/backend/src/departments/departments.service.spec.ts new file mode 100644 index 00000000..406e663a --- /dev/null +++ b/backend/src/departments/departments.service.spec.ts @@ -0,0 +1,55 @@ +import { NotFoundException } from '@nestjs/common'; +import { DepartmentsService } from './departments.service'; + +describe('DepartmentsService (assets and users)', () => { + let deptRepo: any; + let assetRepo: any; + let userRepo: any; + let service: DepartmentsService; + + beforeEach(() => { + deptRepo = { findOne: jest.fn().mockResolvedValue({ id: 'd-1' }) }; + assetRepo = { findAndCount: jest.fn() }; + userRepo = { findAndCount: jest.fn() }; + service = new DepartmentsService(deptRepo, assetRepo, userRepo); + }); + + it('lists a department\'s assets, paginated', async () => { + assetRepo.findAndCount.mockResolvedValue([[{ id: 'a-1' }], 21]); + const result = await service.findAssets('d-1', { page: 2, limit: 10 }); + expect(assetRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: { departmentId: 'd-1' }, + skip: 10, + take: 10, + }), + ); + expect(result).toMatchObject({ total: 21, totalPages: 3 }); + }); + + it('returns an empty page for a department with no assets', async () => { + assetRepo.findAndCount.mockResolvedValue([[], 0]); + expect(await service.findAssets('d-1', {})).toMatchObject({ + items: [], + total: 0, + }); + }); + + it('lists department users without selecting credential columns', async () => { + userRepo.findAndCount.mockResolvedValue([[{ id: 'u-1' }], 1]); + await service.findUsers('d-1', {}); + const args = userRepo.findAndCount.mock.calls[0][0]; + expect(args.where).toEqual({ departmentId: 'd-1' }); + expect(args.select).not.toContain('passwordHash'); + expect(args.select).not.toContain('refreshTokenHash'); + expect(args.select).not.toContain('passwordResetToken'); + }); + + it('throws NotFoundException for an unknown department', async () => { + deptRepo.findOne.mockResolvedValue(null); + await expect(service.findAssets('x', {})).rejects.toThrow( + NotFoundException, + ); + await expect(service.findUsers('x', {})).rejects.toThrow(NotFoundException); + }); +}); diff --git a/backend/src/departments/departments.service.ts b/backend/src/departments/departments.service.ts index 50bcc7b6..ad340921 100644 --- a/backend/src/departments/departments.service.ts +++ b/backend/src/departments/departments.service.ts @@ -7,6 +7,8 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { ILike, Repository } from 'typeorm'; import { Department } from './entities/department.entity'; +import { Asset } from '../assets/entities/asset.entity'; +import { User } from '../users/entities/user.entity'; import { AuditLogsService } from '../audit-logs/audit-logs.service'; import { PaginationQueryDto, PaginatedResponse } from '../common/dto/pagination.dto'; @@ -15,6 +17,10 @@ export class DepartmentsService { constructor( @InjectRepository(Department) private readonly deptRepo: Repository, + @InjectRepository(Asset) + private readonly assetRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, @Optional() private readonly auditLogsService?: AuditLogsService, ) {} @@ -83,4 +89,46 @@ export class DepartmentsService { }); return dept; } -} \ No newline at end of file + + /** Assets belonging to a department, newest first. */ + async findAssets( + id: string, + query: PaginationQueryDto, + ): Promise> { + await this.findById(id); + const { page = 1, limit = 20 } = query; + const [items, total] = await this.assetRepo.findAndCount({ + where: { departmentId: id }, + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + }); + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + /** Users in a department. Only non-sensitive columns are selected. */ + async findUsers( + id: string, + query: PaginationQueryDto, + ): Promise>> { + await this.findById(id); + const { page = 1, limit = 20 } = query; + const [items, total] = await this.userRepo.findAndCount({ + where: { departmentId: id }, + select: [ + 'id', + 'email', + 'firstName', + 'lastName', + 'role', + 'isActive', + 'departmentId', + 'avatarUrl', + ], + skip: (page - 1) * limit, + take: limit, + order: { lastName: 'ASC', firstName: 'ASC' }, + }); + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } +} diff --git a/backend/src/vendors/vendors.controller.ts b/backend/src/vendors/vendors.controller.ts index 88045f5e..4f4e3d29 100644 --- a/backend/src/vendors/vendors.controller.ts +++ b/backend/src/vendors/vendors.controller.ts @@ -52,6 +52,14 @@ export class VendorsController { return this.vendorsService.findById(id); } + @Get(':id/assets') + @ApiOperation({ summary: 'List assets supplied by a vendor (paginated)' }) + @ApiResponse({ status: 200, description: 'Paginated list of assets' }) + @ApiResponse({ status: 404, description: 'Vendor not found' }) + findAssets(@Param('id') id: string, @Query() query: PaginationQueryDto) { + return this.vendorsService.findAssets(id, query); + } + @Patch(':id') @ApiOperation({ summary: 'Update a vendor' }) @ApiResponse({ status: 200, description: 'Vendor updated' }) diff --git a/backend/src/vendors/vendors.module.ts b/backend/src/vendors/vendors.module.ts index 5f486e53..1ce4da47 100644 --- a/backend/src/vendors/vendors.module.ts +++ b/backend/src/vendors/vendors.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Vendor } from './entities/vendor.entity'; +import { Asset } from '../assets/entities/asset.entity'; import { PurchaseOrder } from '../purchase-orders/entities/purchase-order.entity'; import { VendorsService } from './vendors.service'; import { VendorsController } from './vendors.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Vendor, PurchaseOrder])], + imports: [TypeOrmModule.forFeature([Vendor, PurchaseOrder, Asset])], providers: [VendorsService], controllers: [VendorsController], exports: [VendorsService], diff --git a/backend/src/vendors/vendors.service.spec.ts b/backend/src/vendors/vendors.service.spec.ts index f859c0f6..10dea8e9 100644 --- a/backend/src/vendors/vendors.service.spec.ts +++ b/backend/src/vendors/vendors.service.spec.ts @@ -6,6 +6,7 @@ describe('VendorsService', () => { let service: VendorsService; let mockVendorRepo: any; let mockPoRepo: any; + let mockAssetRepo: any; beforeEach(() => { mockVendorRepo = { @@ -18,13 +19,47 @@ describe('VendorsService', () => { mockPoRepo = { findOne: jest.fn(), }; - service = new VendorsService(mockVendorRepo, mockPoRepo); + mockAssetRepo = { findAndCount: jest.fn() }; + service = new VendorsService(mockVendorRepo, mockPoRepo, mockAssetRepo); }); afterEach(() => { jest.clearAllMocks(); }); + describe('findAssets', () => { + it('returns paginated assets scoped by supplierId', async () => { + mockVendorRepo.findOne.mockResolvedValue({ id: 'v-1' }); + mockAssetRepo.findAndCount.mockResolvedValue([[{ id: 'a-1' }], 41]); + + const result = await service.findAssets('v-1', { page: 2, limit: 20 }); + + expect(mockAssetRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: { supplierId: 'v-1' }, + skip: 20, + take: 20, + }), + ); + expect(result).toMatchObject({ total: 41, page: 2, totalPages: 3 }); + }); + + it('returns an empty page when the vendor has no assets', async () => { + mockVendorRepo.findOne.mockResolvedValue({ id: 'v-1' }); + mockAssetRepo.findAndCount.mockResolvedValue([[], 0]); + const result = await service.findAssets('v-1', {}); + expect(result).toMatchObject({ items: [], total: 0, totalPages: 0 }); + }); + + it('throws NotFoundException for an unknown vendor', async () => { + mockVendorRepo.findOne.mockResolvedValue(null); + await expect(service.findAssets('nope', {})).rejects.toThrow( + NotFoundException, + ); + expect(mockAssetRepo.findAndCount).not.toHaveBeenCalled(); + }); + }); + describe('findAll', () => { it('returns paginated vendors', async () => { const vendors = [ diff --git a/backend/src/vendors/vendors.service.ts b/backend/src/vendors/vendors.service.ts index 154cd614..801d49fa 100644 --- a/backend/src/vendors/vendors.service.ts +++ b/backend/src/vendors/vendors.service.ts @@ -6,6 +6,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { ILike, Not, Repository } from 'typeorm'; import { Vendor } from './entities/vendor.entity'; +import { Asset } from '../assets/entities/asset.entity'; import { PurchaseOrder, POStatus } from '../purchase-orders/entities/purchase-order.entity'; import { PaginationQueryDto, PaginatedResponse } from '../common/dto/pagination.dto'; @@ -16,6 +17,8 @@ export class VendorsService { private readonly vendorRepo: Repository, @InjectRepository(PurchaseOrder) private readonly poRepo: Repository, + @InjectRepository(Asset) + private readonly assetRepo: Repository, ) {} async findAll(query: PaginationQueryDto): Promise> { @@ -30,6 +33,22 @@ export class VendorsService { return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; } + /** Assets supplied by a vendor (`Asset.supplierId`), newest first. */ + async findAssets( + id: string, + query: PaginationQueryDto, + ): Promise> { + await this.findById(id); + const { page = 1, limit = 20 } = query; + const [items, total] = await this.assetRepo.findAndCount({ + where: { supplierId: id }, + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + }); + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + async findById(id: string) { const vendor = await this.vendorRepo.findOne({ where: { id } }); if (!vendor) throw new NotFoundException(`Vendor ${id} not found`);