diff --git a/backend/src/assests/assets.controller.ts b/backend/src/assests/assets.controller.ts new file mode 100644 index 000000000..1fce3f803 --- /dev/null +++ b/backend/src/assests/assets.controller.ts @@ -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); + } +} \ No newline at end of file diff --git a/backend/src/assests/assets.service.ts b/backend/src/assests/assets.service.ts new file mode 100644 index 000000000..7fafec678 --- /dev/null +++ b/backend/src/assests/assets.service.ts @@ -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, + 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 { + 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 { + 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 { + 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); + } +} \ No newline at end of file diff --git a/backend/src/assests/entities/asset.entity.ts b/backend/src/assests/entities/asset.entity.ts new file mode 100644 index 000000000..0b53c5556 --- /dev/null +++ b/backend/src/assests/entities/asset.entity.ts @@ -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; +} \ No newline at end of file diff --git a/backend/src/assests/services/asset-code-generator.service.ts b/backend/src/assests/services/asset-code-generator.service.ts new file mode 100644 index 000000000..fefa4f000 --- /dev/null +++ b/backend/src/assests/services/asset-code-generator.service.ts @@ -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 { + try { + const frontendUrl = this.configService.get( + '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 { + 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}`, + ); + } + } +} \ No newline at end of file diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index e0e0a4bb8..4a808fbf4 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -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, diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts new file mode 100644 index 000000000..99e13f356 --- /dev/null +++ b/backend/src/auth/dto/login.dto.ts @@ -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; +} \ No newline at end of file diff --git a/backend/src/auth/dto/register.dto.ts b/backend/src/auth/dto/register.dto.ts new file mode 100644 index 000000000..ae59b82f4 --- /dev/null +++ b/backend/src/auth/dto/register.dto.ts @@ -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; +} \ No newline at end of file diff --git a/backend/src/auth/interfaces/auth-response.interface.ts b/backend/src/auth/interfaces/auth-response.interface.ts new file mode 100644 index 000000000..240f4ae4c --- /dev/null +++ b/backend/src/auth/interfaces/auth-response.interface.ts @@ -0,0 +1,17 @@ +export interface AuthUser { + id: string; + email: string; + firstName: string; + lastName: string; + role: string; +} + +export interface AuthResponse { + accessToken: string; + user: AuthUser; +} + +export interface JwtPayload { + sub: string; + email: string; +} \ No newline at end of file diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 000000000..33a73b73f --- /dev/null +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,28 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { UsersService } from '../../users/users.service'; +import { JwtPayload } from '../interfaces/auth-response.interface'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + private readonly configService: ConfigService, + private readonly usersService: UsersService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET', 'secretKey'), + }); + } + + async validate(payload: JwtPayload) { + const user = await this.usersService.findById(payload.sub); + if (!user) { + throw new UnauthorizedException('Invalid or expired token'); + } + return user; + } +} \ No newline at end of file diff --git a/backend/src/common/dto/export-query.dto.ts b/backend/src/common/dto/export-query.dto.ts new file mode 100644 index 000000000..c1c285d09 --- /dev/null +++ b/backend/src/common/dto/export-query.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +export enum ExportFormat { + CSV = 'csv', + XLSX = 'xlsx', +} + +export class ExportQueryDto { + @ApiPropertyOptional({ enum: ExportFormat, default: ExportFormat.CSV }) + @IsEnum(ExportFormat) + @IsOptional() + format?: ExportFormat = ExportFormat.CSV; +} \ No newline at end of file diff --git a/backend/src/common/services/export.service.ts b/backend/src/common/services/export.service.ts new file mode 100644 index 000000000..601c08c86 --- /dev/null +++ b/backend/src/common/services/export.service.ts @@ -0,0 +1,98 @@ +import { Injectable, Response } from '@nestjs/common'; +import { Response as ExpressResponse } from 'express'; +import * as ExcelJS from 'exceljs'; +import { Readable } from 'stream'; + +export interface ColumnDefinition { + header: string; + key: string; + width?: number; +} + +@Injectable() +export class ExportService { + /** + * Formats and streams CSV response with properly escaped fields. + */ + async streamCsv( + res: ExpressResponse, + columns: ColumnDefinition[], + dataStream: Readable | AsyncIterable>, + filenamePrefix: string, + ): Promise { + const filename = `${filenamePrefix}-${new Date().toISOString().split('T')[0]}.csv`; + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + + // Write header row + const headerRow = columns.map((col) => this.escapeCsvField(col.header)).join(',') + '\n'; + res.write(headerRow); + + // Stream rows dynamically + for await (const item of dataStream) { + const row = columns + .map((col) => this.escapeCsvField(item[col.key])) + .join(','); + res.write(row + '\n'); + } + + res.end(); + } + + /** + * Generates and streams XLSX file using ExcelJS streaming writer. + */ + async streamXlsx( + res: ExpressResponse, + columns: ColumnDefinition[], + dataStream: Readable | AsyncIterable>, + filenamePrefix: string, + sheetName = 'Export Data', + ): Promise { + const filename = `${filenamePrefix}-${new Date().toISOString().split('T')[0]}.xlsx`; + + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + + // Use WorkbookWriter to stream directly to HTTP response + const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ + stream: res, + useStyles: true, + useSharedStrings: true, + }); + + const worksheet = workbook.addWorksheet(sheetName); + + worksheet.columns = columns.map((col) => ({ + header: col.header, + key: col.key, + width: col.width || 20, + })); + + for await (const item of dataStream) { + worksheet.addRow(item).commit(); + } + + worksheet.commit(); + await workbook.commit(); + } + + /** + * Escapes values containing commas, quotes, or newlines according to CSV standards. + */ + private escapeCsvField(val: any): string { + if (val === null || val === undefined) return '""'; + + let str = typeof val === 'object' ? JSON.stringify(val) : String(val); + if (str.includes('"') || str.includes(',') || str.includes('\n') || str.includes('\r')) { + str = `"${str.replace(/"/g, '""')}"`; + } else { + str = `"${str}"`; + } + return str; + } +} \ No newline at end of file diff --git a/frontend/components/departments/department-drawer.tsx b/frontend/components/departments/department-drawer.tsx new file mode 100644 index 000000000..601dd58dc --- /dev/null +++ b/frontend/components/departments/department-drawer.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { Department, useDepartmentAssets, useDepartmentUsers } from '@/lib/query/hooks/useDepartments'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; + +interface DepartmentDrawerProps { + isOpen: boolean; + onClose: () => void; + department: Department | null; +} + +export function DepartmentDrawer({ isOpen, onClose, department }: DepartmentDrawerProps) { + const { data: users, isLoading: usersLoading } = useDepartmentUsers(department?.id); + const { data: assets, isLoading: assetsLoading } = useDepartmentAssets(department?.id); + + if (!department) return null; + + return ( + + + + {department.name} +

{department.description || 'No description provided'}

+
+ + + + Members ({department.memberCount}) + Assets ({department.assetCount}) + + + + {usersLoading ? ( +

Loading members...

+ ) : ( + users?.map((user: any) => ( +
+
+

{user.firstName} {user.lastName}

+

{user.email}

+
+ {user.role} +
+ )) + )} +
+ + + {assetsLoading ? ( +

Loading assets...

+ ) : ( + assets?.map((asset: any) => ( +
+
+

{asset.name}

+

Tag: {asset.assetTag}

+
+ {asset.status} +
+ )) + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/departments/department-modal.tsx b/frontend/components/departments/department-modal.tsx new file mode 100644 index 000000000..d6856360c --- /dev/null +++ b/frontend/components/departments/department-modal.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { Department, useCreateDepartment, useUpdateDepartment } from '@/lib/query/hooks/useDepartments'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; + +interface DepartmentModalProps { + isOpen: boolean; + onClose: () => void; + department?: Department | null; +} + +interface FormData { + name: string; + description?: string; + managerId?: string; +} + +export function DepartmentModal({ isOpen, onClose, department }: DepartmentModalProps) { + const { register, handleSubmit, reset } = useForm(); + const createDepartment = useCreateDepartment(); + const updateDepartment = useUpdateDepartment(); + + useEffect(() => { + if (department) { + reset({ + name: department.name, + description: department.description || '', + managerId: department.managerId || '', + }); + } else { + reset({ name: '', description: '', managerId: '' }); + } + }, [department, reset]); + + const onSubmit = async (data: FormData) => { + if (department) { + await updateDepartment.mutateAsync({ id: department.id, payload: data }); + } else { + await createDepartment.mutateAsync(data); + } + onClose(); + }; + + return ( + + + + {department ? 'Edit Department' : 'Add Department'} + +
+
+ + +
+
+ +