diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..87d8a085 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Dependencies +node_modules/ +package-lock.json +yarn.lock + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Uploads +uploads/ + +# Logs +logs/ +*.log +npm-debug.log* + +# Coverage +coverage/ + +# Misc +.cache/ +.turbo/ diff --git a/backend/contrib/src/assets/dto/bulk-delete-assets.dto.ts b/backend/contrib/src/assets/dto/bulk-delete-assets.dto.ts new file mode 100644 index 00000000..ecfd6171 --- /dev/null +++ b/backend/contrib/src/assets/dto/bulk-delete-assets.dto.ts @@ -0,0 +1,8 @@ +import { IsArray, IsUUID, ArrayNotEmpty } from 'class-validator'; + +export class BulkDeleteAssetsDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + ids: string[]; +} diff --git a/backend/contrib/src/assets/dto/bulk-transfer-department.dto.ts b/backend/contrib/src/assets/dto/bulk-transfer-department.dto.ts new file mode 100644 index 00000000..bcd62333 --- /dev/null +++ b/backend/contrib/src/assets/dto/bulk-transfer-department.dto.ts @@ -0,0 +1,11 @@ +import { IsArray, IsUUID, ArrayNotEmpty } from 'class-validator'; + +export class BulkTransferDepartmentDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + ids: string[]; + + @IsUUID('4') + departmentId: string; +} diff --git a/backend/contrib/src/assets/dto/bulk-update-status.dto.ts b/backend/contrib/src/assets/dto/bulk-update-status.dto.ts new file mode 100644 index 00000000..cbc6ffe2 --- /dev/null +++ b/backend/contrib/src/assets/dto/bulk-update-status.dto.ts @@ -0,0 +1,12 @@ +import { IsArray, IsEnum, IsUUID, ArrayNotEmpty } from 'class-validator'; +import { AssetStatus } from '../enums'; + +export class BulkUpdateStatusDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + ids: string[]; + + @IsEnum(AssetStatus) + status: AssetStatus; +} diff --git a/backend/contrib/src/assets/dto/search-assets.dto.ts b/backend/contrib/src/assets/dto/search-assets.dto.ts new file mode 100644 index 00000000..a98697ea --- /dev/null +++ b/backend/contrib/src/assets/dto/search-assets.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class SearchAssetsDto { + @IsOptional() + @IsString() + q?: string; +} diff --git a/backend/contrib/src/assets/dto/update-asset-tags.dto.ts b/backend/contrib/src/assets/dto/update-asset-tags.dto.ts new file mode 100644 index 00000000..7ff6d36a --- /dev/null +++ b/backend/contrib/src/assets/dto/update-asset-tags.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsString } from 'class-validator'; + +export class UpdateAssetTagsDto { + @IsArray() + @IsString({ each: true }) + tags: string[]; +} diff --git a/backend/contrib/src/auth/decorators/roles.decorator.ts b/backend/contrib/src/auth/decorators/roles.decorator.ts new file mode 100644 index 00000000..e038e168 --- /dev/null +++ b/backend/contrib/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/backend/contrib/src/auth/guards/roles.guard.ts b/backend/contrib/src/auth/guards/roles.guard.ts new file mode 100644 index 00000000..8d917876 --- /dev/null +++ b/backend/contrib/src/auth/guards/roles.guard.ts @@ -0,0 +1,28 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const userRole = String(request.user?.role || '').toLowerCase(); + + if (!requiredRoles.map((role) => role.toLowerCase()).includes(userRole)) { + throw new ForbiddenException('Insufficient role'); + } + + return true; + } +} diff --git a/backend/contrib/src/notifications/dto/update-notification-preferences.dto.ts b/backend/contrib/src/notifications/dto/update-notification-preferences.dto.ts new file mode 100644 index 00000000..1b0f2e98 --- /dev/null +++ b/backend/contrib/src/notifications/dto/update-notification-preferences.dto.ts @@ -0,0 +1,19 @@ +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateNotificationPreferencesDto { + @IsOptional() + @IsBoolean() + assetCreated?: boolean; + + @IsOptional() + @IsBoolean() + assetTransferred?: boolean; + + @IsOptional() + @IsBoolean() + maintenanceDue?: boolean; + + @IsOptional() + @IsBoolean() + warrantyExpiring?: boolean; +} diff --git a/backend/contrib/src/notifications/notification-preference.entity.ts b/backend/contrib/src/notifications/notification-preference.entity.ts new file mode 100644 index 00000000..74e251a4 --- /dev/null +++ b/backend/contrib/src/notifications/notification-preference.entity.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + JoinColumn, + OneToOne, +} from 'typeorm'; +import { User } from '../users/user.entity'; + +@Entity('notification_preferences') +export class NotificationPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + userId: string; + + @OneToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ default: true }) + assetCreated: boolean; + + @Column({ default: true }) + assetTransferred: boolean; + + @Column({ default: true }) + maintenanceDue: boolean; + + @Column({ default: true }) + warrantyExpiring: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/contrib/src/notifications/notification-preferences.controller.ts b/backend/contrib/src/notifications/notification-preferences.controller.ts new file mode 100644 index 00000000..daa8ff1b --- /dev/null +++ b/backend/contrib/src/notifications/notification-preferences.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Get, Patch, Req, UnauthorizedException, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { NotificationPreferencesService } from './notification-preferences.service'; +import { UpdateNotificationPreferencesDto } from './dto/update-notification-preferences.dto'; + +@ApiTags('Notifications') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('notifications') +export class NotificationPreferencesController { + constructor(private readonly preferencesService: NotificationPreferencesService) {} + + private getUserId(req: { user?: { id?: string } }): string { + const userId = req.user?.id; + if (!userId) { + throw new UnauthorizedException('User context is missing'); + } + return userId; + } + + @Get('preferences') + getPreferences(@Req() req: { user?: { id?: string } }) { + return this.preferencesService.getForUser(this.getUserId(req)); + } + + @Patch('preferences') + updatePreferences( + @Req() req: { user?: { id?: string } }, + @Body() dto: UpdateNotificationPreferencesDto, + ) { + return this.preferencesService.updateForUser(this.getUserId(req), dto); + } +} diff --git a/backend/contrib/src/notifications/notification-preferences.service.ts b/backend/contrib/src/notifications/notification-preferences.service.ts new file mode 100644 index 00000000..4b6a6b79 --- /dev/null +++ b/backend/contrib/src/notifications/notification-preferences.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationPreference } from './notification-preference.entity'; +import { UpdateNotificationPreferencesDto } from './dto/update-notification-preferences.dto'; + +@Injectable() +export class NotificationPreferencesService { + constructor( + @InjectRepository(NotificationPreference) + private readonly preferencesRepo: Repository, + ) {} + + async createDefaultsForUser(userId: string): Promise { + const existing = await this.preferencesRepo.findOne({ where: { userId } }); + if (existing) { + return existing; + } + + return this.preferencesRepo.save( + this.preferencesRepo.create({ + userId, + assetCreated: true, + assetTransferred: true, + maintenanceDue: true, + warrantyExpiring: true, + }), + ); + } + + async getForUser(userId: string): Promise { + return this.createDefaultsForUser(userId); + } + + async updateForUser(userId: string, dto: UpdateNotificationPreferencesDto): Promise { + const current = await this.createDefaultsForUser(userId); + Object.assign(current, dto); + return this.preferencesRepo.save(current); + } +} diff --git a/backend/contrib/src/notifications/notifications.module.ts b/backend/contrib/src/notifications/notifications.module.ts new file mode 100644 index 00000000..6004e013 --- /dev/null +++ b/backend/contrib/src/notifications/notifications.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { NotificationPreference } from './notification-preference.entity'; +import { User } from '../users/user.entity'; +import { NotificationPreferencesController } from './notification-preferences.controller'; +import { NotificationPreferencesService } from './notification-preferences.service'; +import { UserRegistrationSubscriber } from './user-registration.subscriber'; + +@Module({ + imports: [TypeOrmModule.forFeature([NotificationPreference, User])], + controllers: [NotificationPreferencesController], + providers: [NotificationPreferencesService, UserRegistrationSubscriber], + exports: [NotificationPreferencesService], +}) +export class NotificationsModule {} diff --git a/backend/contrib/src/notifications/user-registration.subscriber.ts b/backend/contrib/src/notifications/user-registration.subscriber.ts new file mode 100644 index 00000000..70dd8a0d --- /dev/null +++ b/backend/contrib/src/notifications/user-registration.subscriber.ts @@ -0,0 +1,36 @@ +import { DataSource, EventSubscriber, EntitySubscriberInterface, InsertEvent } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { User } from '../users/user.entity'; +import { NotificationPreference } from './notification-preference.entity'; + +@Injectable() +@EventSubscriber() +export class UserRegistrationSubscriber implements EntitySubscriberInterface { + constructor(dataSource: DataSource) { + dataSource.subscribers.push(this); + } + + listenTo() { + return User; + } + + async afterInsert(event: InsertEvent): Promise { + if (!event.entity?.id) { + return; + } + + const repo = event.manager.getRepository(NotificationPreference); + const existing = await repo.findOne({ where: { userId: event.entity.id } }); + if (existing) { + return; + } + + await repo.insert({ + userId: event.entity.id, + assetCreated: true, + assetTransferred: true, + maintenanceDue: true, + warrantyExpiring: true, + }); + } +}