Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions backend/src/assets/asset-label.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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('<svg');
});

it('returns a PNG barcode label of the asset tag', async () => {
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;
}
});
});
68 changes: 68 additions & 0 deletions backend/src/assets/asset-label.service.ts
Original file line number Diff line number Diff line change
@@ -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<Asset>,
) {}

/** 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<Asset> {
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<RenderedImage> {
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<RenderedImage> {
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' };
}
}
28 changes: 28 additions & 0 deletions backend/src/assets/assets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Param,
Query,
UseGuards,
StreamableFile,
} from '@nestjs/common';
import {
ApiTags,
Expand All @@ -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';
Expand All @@ -42,6 +45,7 @@ export class AssetsController {
private readonly assetsService: AssetsService,
private readonly assetHistoryService: AssetHistoryService,
private readonly reservationsService: ReservationsService,
private readonly labelService: AssetLabelService,
) {}

@Get()
Expand Down Expand Up @@ -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')
Expand Down
3 changes: 2 additions & 1 deletion backend/src/assets/assets.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,7 +21,7 @@ import { User } from '../users/entities/user.entity';
AuditLogsModule,
ReservationsModule,
],
providers: [AssetsService],
providers: [AssetsService, AssetLabelService],
controllers: [AssetsController],
exports: [AssetsService],
})
Expand Down
28 changes: 28 additions & 0 deletions backend/src/assets/dto/asset-qr-query.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 16 additions & 0 deletions backend/src/departments/departments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
4 changes: 3 additions & 1 deletion backend/src/departments/departments.module.ts
Original file line number Diff line number Diff line change
@@ -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],
Expand Down
55 changes: 55 additions & 0 deletions backend/src/departments/departments.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
50 changes: 49 additions & 1 deletion backend/src/departments/departments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -15,6 +17,10 @@ export class DepartmentsService {
constructor(
@InjectRepository(Department)
private readonly deptRepo: Repository<Department>,
@InjectRepository(Asset)
private readonly assetRepo: Repository<Asset>,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@Optional() private readonly auditLogsService?: AuditLogsService,
) {}

Expand Down Expand Up @@ -83,4 +89,46 @@ export class DepartmentsService {
});
return dept;
}
}

/** Assets belonging to a department, newest first. */
async findAssets(
id: string,
query: PaginationQueryDto,
): Promise<PaginatedResponse<Asset>> {
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<PaginatedResponse<Partial<User>>> {
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) };
}
}
Loading
Loading