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
73 changes: 73 additions & 0 deletions backend/src/assests/assets.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Roles } from '../auth/decorators/roles.decorator'; // Role Decorator
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { AssetsService } from './assets.service';
import { CreateAssetDto } from './dto/create-asset.dto';

@ApiTags('Assets')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('assets')
export class AssetsController {
constructor(private readonly assetsService: AssetsService) {}

@Post()
@ApiOperation({ summary: 'Create asset with auto-generated QR and barcode' })
@ApiResponse({ status: 201, description: 'Asset created with S3 code keys' })
async create(@Body() createAssetDto: CreateAssetDto) {
return this.assetsService.createAsset(createAssetDto);
}

@Get('scan')
@ApiOperation({ summary: 'Look up asset by scanned UUID or barcode value' })
@ApiQuery({ name: 'code', required: true, example: 'AST-10042' })
@ApiResponse({ status: 200, description: 'Asset retrieved successfully' })
@ApiResponse({ status: 404, description: 'Asset not found' })
async scan(@Query('code') code: string) {
return this.assetsService.scanLookup(code);
}

@Get(':id/qrcode')
@ApiOperation({ summary: 'Get 1-hour pre-signed S3 URL for asset QR code' })
@ApiResponse({ status: 200, description: 'Pre-signed S3 URL generated' })
@ApiResponse({ status: 404, description: 'QR Code not found' })
async getQrCode(@Param('id') id: string) {
return this.assetsService.getQrCodeUrl(id);
}

@Get(':id/barcode')
@ApiOperation({ summary: 'Get 1-hour pre-signed S3 URL for asset barcode' })
@ApiResponse({ status: 200, description: 'Pre-signed S3 URL generated' })
@ApiResponse({ status: 404, description: 'Barcode not found' })
async getBarcode(@Param('id') id: string) {
return this.assetsService.getBarcodeUrl(id);
}

@Post(':id/regenerate-codes')
@Roles('ADMIN')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Regenerate QR and barcode images in S3 (Admin only)' })
@ApiResponse({ status: 200, description: 'Codes regenerated successfully' })
@ApiResponse({ status: 403, description: 'Forbidden resource' })
async regenerateCodes(@Param('id') id: string) {
return this.assetsService.regenerateCodes(id);
}
}
115 changes: 115 additions & 0 deletions backend/src/assests/assets.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FileService } from '../files/file.service'; // BE-06 S3 File Service
import { CreateAssetDto } from './dto/create-asset.dto';
import { Asset } from './entities/asset.entity';
import { AssetCodeGeneratorService } from './services/asset-code-generator.service';

@Injectable()
export class AssetsService {
constructor(
@InjectRepository(Asset)
private readonly assetRepository: Repository<Asset>,
private readonly codeGeneratorService: AssetCodeGeneratorService,
private readonly fileService: FileService,
) {}

/**
* Generates and uploads both QR and barcode PNGs to S3.
*/
async generateAndUploadCodes(asset: Asset): Promise<{ qrKey: string; barcodeKey: string }> {
const qrBuffer = await this.codeGeneratorService.generateQrCodeBuffer(asset.id);
const barcodeBuffer = await this.codeGeneratorService.generateBarcodeBuffer(
asset.assetTag || asset.id,
);

const qrKey = `qrcodes/${asset.id}/qrcode.png`;
const barcodeKey = `qrcodes/${asset.id}/barcode.png`;

await this.fileService.uploadBuffer(qrKey, qrBuffer, 'image/png');
await this.fileService.uploadBuffer(barcodeKey, barcodeBuffer, 'image/png');

return { qrKey, barcodeKey };
}

/**
* POST /assets - Create Asset and auto-generate physical tags.
*/
async createAsset(createAssetDto: CreateAssetDto): Promise<Asset> {
const asset = this.assetRepository.create(createAssetDto);
const savedAsset = await this.assetRepository.save(asset);

const { qrKey, barcodeKey } = await this.generateAndUploadCodes(savedAsset);

savedAsset.qrCode = qrKey;
savedAsset.barcode = barcodeKey;

return this.assetRepository.save(savedAsset);
}

/**
* GET /assets/:id/qrcode - Get pre-signed URL for QR Code.
*/
async getQrCodeUrl(id: string): Promise<{ url: string }> {
const asset = await this.assetRepository.findOne({ where: { id } });
if (!asset || !asset.qrCode) {
throw new NotFoundException(`QR code not found for asset ID ${id}`);
}

const url = await this.fileService.getPresignedUrl(asset.qrCode, 3600); // 1 hour expiration
return { url };
}

/**
* GET /assets/:id/barcode - Get pre-signed URL for Barcode.
*/
async getBarcodeUrl(id: string): Promise<{ url: string }> {
const asset = await this.assetRepository.findOne({ where: { id } });
if (!asset || !asset.barcode) {
throw new NotFoundException(`Barcode not found for asset ID ${id}`);
}

const url = await this.fileService.getPresignedUrl(asset.barcode, 3600); // 1 hour expiration
return { url };
}

/**
* GET /assets/scan?code= - Look up asset by assetId string or barcode value.
*/
async scanLookup(code: string): Promise<Asset> {
if (!code) {
throw new NotFoundException('Scan code parameter is required');
}

const asset = await this.assetRepository.findOne({
where: [{ id: code }, { assetTag: code }],
});

if (!asset) {
throw new NotFoundException(`No asset found matching code '${code}'`);
}

return asset;
}

/**
* POST /assets/:id/regenerate-codes - Re-generate and re-upload QR and barcode images.
*/
async regenerateCodes(id: string): Promise<Asset> {
const asset = await this.assetRepository.findOne({ where: { id } });
if (!asset) {
throw new NotFoundException(`Asset with ID ${id} not found`);
}

const { qrKey, barcodeKey } = await this.generateAndUploadCodes(asset);

asset.qrCode = qrKey;
asset.barcode = barcodeKey;

return this.assetRepository.save(asset);
}
}
33 changes: 33 additions & 0 deletions backend/src/assests/entities/asset.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';

@Entity('assets')
export class Asset {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
name: string;

@Column({ unique: true })
@Index()
assetTag: string; // Used for physical barcode lookups

@Column({ nullable: true })
qrCode: string; // S3 Key for QR code PNG

@Column({ nullable: true })
barcode: string; // S3 Key for Barcode PNG

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
56 changes: 56 additions & 0 deletions backend/src/assests/services/asset-code-generator.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as bwipjs from 'bwip-js';
import * as QRCode from 'qrcode';

@Injectable()
export class AssetCodeGeneratorService {
constructor(private readonly configService: ConfigService) {}

/**
* Generates a QR Code PNG buffer encoding the frontend URL.
*/
async generateQrCodeBuffer(assetId: string): Promise<Buffer> {
try {
const frontendUrl = this.configService.get<string>(
'FRONTEND_URL',
'http://localhost:3000',
);
const urlPayload = `${frontendUrl}/assets/${assetId}`;

return await QRCode.toBuffer(urlPayload, {
type: 'png',
width: 300,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
} catch (error) {
throw new InternalServerErrorException(
`Failed to generate QR code: ${(error as Error).message}`,
);
}
}

/**
* Generates a Code128 Barcode PNG buffer for physical asset tags.
*/
async generateBarcodeBuffer(barcodeText: string): Promise<Buffer> {
try {
return await bwipjs.toBuffer({
bcid: 'code128', // Barcode type
text: barcodeText, // Text/tag to encode
scale: 3,
height: 10,
includetext: true, // Show human-readable text below barcode
textxalign: 'center',
});
} catch (error) {
throw new InternalServerErrorException(
`Failed to generate barcode: ${(error as Error).message}`,
);
}
}
}
63 changes: 63 additions & 0 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,67 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { User } from '../users/entities/user.entity';
import { AuthService } from './auth.service';
import { GetUser } from './decorators/get-user.decorator';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';

@ApiTags('Auth')
@Controller('api/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post('register')
@ApiOperation({ summary: 'Create new user account' })
@ApiResponse({ status: 201, description: 'User successfully registered' })
@ApiResponse({ status: 400, description: 'Weak payload or validation error' })
@ApiResponse({ status: 409, description: 'Email already exists' })
async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}

@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Authenticate user and return JWT' })
@ApiResponse({ status: 200, description: 'Successfully authenticated' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}

@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Invalidate current session' })
@ApiResponse({ status: 200, description: 'Logged out successfully' })
async logout() {
// Stateless JWT logout response
return { message: 'Logged out successfully' };
}

@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "Get current authenticated user's profile" })
@ApiResponse({ status: 200, description: 'Profile retrieved' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async me(@GetUser() user: User) {
return this.authService.getCurrentUser(user);
}
}
Controller,
Post,
Body,
Expand Down
14 changes: 14 additions & 0 deletions backend/src/auth/dto/login.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';

export class LoginDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
@IsNotEmpty()
email: string;

@ApiProperty({ example: 'Password123!' })
@IsString()
@IsNotEmpty()
password: string;
}
24 changes: 24 additions & 0 deletions backend/src/auth/dto/register.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';

export class RegisterDto {
@ApiProperty({ example: 'John' })
@IsString()
@IsNotEmpty()
firstName: string;

@ApiProperty({ example: 'Doe' })
@IsString()
@IsNotEmpty()
lastName: string;

@ApiProperty({ example: 'user@example.com' })
@IsEmail()
@IsNotEmpty()
email: string;

@ApiProperty({ example: 'Password123!', minimum: 8 })
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters long' })
password: string;
}
Loading
Loading