Commit fd8fbe81 by Augusto

unikeform-api

parents
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/generated/prisma
{
"singleQuote": true,
"trailingComma": "all"
}
\ No newline at end of file
{
"eslint.enable": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "never"
}
}
\ No newline at end of file
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);
\ No newline at end of file
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "unike-form",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.6",
"@nestjs/swagger": "^11.2.0",
"@prisma/client": "^6.15.0",
"@types/bcrypt": "^6.0.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"multer": "^2.0.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"prisma": "^6.15.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
generator client {
provider = "prisma-client-js"
output = "../generated/prisma"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
firstName String
lastName String
email String @unique
role UserRole @default(USER)
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reportedOccurrences Occurrence[] @relation("ReportedBy")
assignedOccurrences Occurrence[] @relation("AssignedTo")
comments Comment[]
@@map("users")
}
model Occurrence {
id String @id @default(cuid())
title String
description String @db.Text
status OccurrenceStatus @default(OPEN)
priority Priority @default(MEDIUM)
category String
location String?
// Relations
reportedBy User @relation("ReportedBy", fields: [reporterId], references: [id])
reporterId String
assignedTo User? @relation("AssignedTo", fields: [assigneeId], references: [id])
assigneeId String?
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
closedAt DateTime?
// Relations
comments Comment[]
attachments Attachment[]
@@map("occurrences")
}
model Comment {
id String @id @default(cuid())
content String @db.Text
isInternal Boolean @default(false)
// Relations
occurrence Occurrence @relation(fields: [occurrenceId], references: [id], onDelete: Cascade)
occurrenceId String
author User @relation(fields: [authorId], references: [id])
authorId String
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("comments")
}
model Attachment {
id String @id @default(cuid())
filename String
originalName String
mimeType String
size Int
path String
// Relations
occurrence Occurrence @relation(fields: [occurrenceId], references: [id], onDelete: Cascade)
occurrenceId String
// Timestamps
createdAt DateTime @default(now())
@@map("attachments")
}
enum UserRole {
USER
MODERATOR
ADMIN
}
enum OccurrenceStatus {
OPEN
IN_PROGRESS
RESOLVED
CLOSED
PARCIAL_RESOLVED
CANCELLED
}
enum Priority {
LOW
MEDIUM
HIGH
URGENT
}
\ No newline at end of file
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return API information', () => {
const result = appController.getApiInfo();
expect(result).toEqual({
name: 'Unike Form API',
version: '1.0.0',
description: 'Occurrence management system API',
status: 'running',
});
});
});
});
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AppService } from './app.service';
import { Public } from './modules/auth/decorators/public.decorator';
@ApiTags('app')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@Public()
@ApiOperation({ summary: 'Get API information' })
@ApiResponse({
status: 200,
description: 'Returns API information',
schema: {
type: 'object',
properties: {
name: { type: 'string', example: 'Unike Form API' },
version: { type: 'string', example: '1.0.0' },
description: {
type: 'string',
example: 'Occurrence management system API',
},
status: { type: 'string', example: 'running' },
},
},
})
getApiInfo(): object {
return this.appService.getApiInfo();
}
}
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaService } from './common/prisma.service';
import { UserModule } from './modules/user/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { OccurrenceModule } from './modules/occurrence/occurrence.module';
import { CommentModule } from './modules/comment/comment.module';
import { AttachmentModule } from './modules/attachment/attachment.module';
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
@Module({
imports: [
UserModule,
AuthModule,
OccurrenceModule,
CommentModule,
AttachmentModule,
],
controllers: [AppController],
providers: [
AppService,
PrismaService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard, // Apply JWT guard globally
},
],
exports: [PrismaService], // Export for use in other modules
})
export class AppModule {}
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getApiInfo(): object {
return {
name: 'Unike Form API',
version: '1.0.0',
description: 'Occurrence management system API',
status: 'running',
};
}
}
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '../../generated/prisma';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable global validation pipes
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Remove properties not in DTO
forbidNonWhitelisted: true, // Throw error for unknown properties
transform: true, // Auto-transform payloads to DTO instances
}),
);
// Enable CORS with open settings for development
app.enableCors({
origin: true, // Allow all origins
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
credentials: true, // Allow cookies and credentials
});
// Swagger configuration
const config = new DocumentBuilder()
.setTitle('Unike Form API')
.setDescription('API documentation for Unike Form application')
.setVersion('1.0')
.addTag('auth', 'Authentication endpoints')
.addTag('users', 'User management endpoints')
.addTag('forms', 'Form management endpoints')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
name: 'JWT',
description: 'Enter JWT token',
in: 'header',
},
'JWT-auth', // This name here is important for matching up with @ApiBearerAuth() in your controller!
)
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
# Attachment Module
This module handles file uploads and management for occurrences, supporting various file types including images, documents, and archives.
## Features
### File Upload Support
- **Single file upload**: Upload one file at a time
- **Multiple file upload**: Upload up to 10 files simultaneously
- **File validation**: MIME type, size, and security validation
- **Secure storage**: Files stored with unique names and proper organization
### Supported File Types
- **Images**: JPEG, PNG, GIF, WebP, SVG
- **Documents**: PDF, Word (.doc, .docx), Excel (.xls, .xlsx), PowerPoint (.ppt, .pptx)
- **Text Files**: Plain text, CSV
- **Archives**: ZIP, RAR, 7Z
### File Security
- **Size Limit**: Maximum 10MB per file
- **Type Validation**: Only allowed MIME types accepted
- **Filename Sanitization**: Special characters removed/replaced
- **Path Traversal Protection**: Prevents directory traversal attacks
- **Virus Protection Ready**: Structure supports antivirus integration
## API Endpoints
### File Upload
- `POST /attachments/upload` - Upload single file
- `POST /attachments/upload-multiple` - Upload multiple files (max 10)
### File Retrieval
- `GET /attachments/occurrence/:occurrenceId` - Get all attachments for occurrence
- `GET /attachments/:id` - Get attachment metadata
- `GET /attachments/:id/download` - Download file (as attachment)
- `GET /attachments/:id/view` - View file inline (for images, PDFs)
### File Management
- `DELETE /attachments/:id` - Delete single attachment
- `DELETE /attachments/occurrence/:occurrenceId` - Delete all attachments (Admin/Moderator only)
### Statistics
- `GET /attachments/stats` - Get attachment statistics
- `GET /attachments/stats?occurrenceId=:id` - Get stats for specific occurrence
## Usage Examples
### Upload Single File
```typescript
// Form data with file and occurrence ID
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('occurrenceId', 'clxyz123abc456def');
fetch('/attachments/upload', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
},
body: formData,
});
```
### Upload Multiple Files
```typescript
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
formData.append('occurrenceId', 'clxyz123abc456def');
fetch('/attachments/upload-multiple', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
},
body: formData,
});
```
### Get Attachments for Occurrence
```typescript
fetch('/attachments/occurrence/clxyz123abc456def', {
headers: {
Authorization: 'Bearer ' + token,
},
})
.then((response) => response.json())
.then((attachments) => {
// Handle attachments array
});
```
### Download File
```typescript
// Download as attachment (forces download)
window.open('/attachments/:id/download');
// View inline (opens in browser)
window.open('/attachments/:id/view');
```
## Database Schema
The Attachment model includes:
```typescript
{
id: string; // CUID
filename: string; // Generated unique filename
originalName: string; // Original uploaded filename
mimeType: string; // File MIME type
size: number; // File size in bytes
path: string; // Storage path
occurrenceId: string; // Foreign key to occurrence
createdAt: Date; // Upload timestamp
}
```
## File Storage Structure
```
uploads/
├── attachments/
│ ├── 1642234567890-document.pdf
│ ├── 1642234568123-image.jpg
│ └── ...
└── temp/
└── (temporary files during processing)
```
## Security Considerations
### File Validation
- MIME type whitelist enforcement
- File size limits (10MB)
- Filename sanitization
- Path traversal prevention
### Access Control
- All endpoints require JWT authentication
- Admin/Moderator roles for bulk operations
- File access tied to occurrence permissions
### Storage Security
- Files stored outside web root when possible
- Unique filenames prevent conflicts and guessing
- No direct file access via URL patterns
## Error Handling
### Common Errors
- `400 Bad Request`: Invalid file type, size exceeded, or missing occurrence
- `401 Unauthorized`: Missing or invalid JWT token
- `403 Forbidden`: Insufficient permissions for admin operations
- `404 Not Found`: Occurrence or attachment not found
- `413 Payload Too Large`: File size exceeds limit
- `500 Internal Server Error`: File system or database errors
### Error Response Format
```json
{
"statusCode": 400,
"message": "File type 'application/exe' is not allowed",
"error": "Bad Request"
}
```
## Configuration
### Multer Configuration
- **Storage**: Memory storage for processing
- **File Size Limit**: 10MB per file
- **File Count Limit**: 10 files per request
- **MIME Type Filter**: Whitelist approach
### File Validation Rules
```typescript
const allowedMimeTypes = [
'image/jpeg',
'image/png',
'image/gif',
'application/pdf',
'application/msword',
'text/plain',
'application/zip',
// ... more types
];
```
## Integration with Occurrences
### Automatic Cleanup
- Attachments are automatically deleted when parent occurrence is deleted (CASCADE)
- Service provides methods for bulk attachment cleanup
### Occurrence Response
- Occurrence endpoints include attachment count in `_count.attachments`
- No automatic attachment details in occurrence responses (for performance)
### Relationship
- One-to-many relationship: One occurrence can have multiple attachments
- Foreign key constraint ensures data integrity
## Performance Considerations
### File Processing
- Files processed in memory for better performance
- Unique filename generation prevents collisions
- Asynchronous file operations where possible
### Database Queries
- Indexed foreign key relationships
- Optimized queries for attachment lists
- Efficient counting for statistics
### Storage Management
- Automatic directory creation
- Error handling with cleanup on failures
- File existence validation before operations
## Future Enhancements
### Planned Features
- Image thumbnail generation
- File compression for large files
- Cloud storage integration (AWS S3, Google Cloud)
- Virus scanning integration
- File versioning support
- Batch operations API
### Performance Optimizations
- Streaming for large file downloads
- Caching for frequently accessed files
- CDN integration for file delivery
- Background processing for file operations
import {
Controller,
Get,
Post,
Param,
Delete,
Body,
UseInterceptors,
UploadedFile,
UploadedFiles,
HttpCode,
HttpStatus,
Res,
UseGuards,
Query,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBody,
ApiBearerAuth,
ApiConsumes,
ApiQuery,
} from '@nestjs/swagger';
import { Response } from 'express';
import { AttachmentService } from './attachment.service';
import { AttachmentResponseDto } from './dto/attachment-response.dto';
import { UploadAttachmentDto } from './dto/upload-attachment.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { UserRole } from '../../../generated/prisma';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { UserResponseDto } from '../user/dto/user-response.dto';
@ApiTags('attachments')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('attachments')
export class AttachmentController {
constructor(private readonly attachmentService: AttachmentService) {}
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: 'Upload a single file to an occurrence' })
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'File upload with occurrence ID',
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
description: 'File to upload (max 10MB)',
},
occurrenceId: {
type: 'string',
description: 'ID of the occurrence',
example: 'clxyz123abc456def',
},
},
required: ['file', 'occurrenceId'],
},
})
@ApiResponse({
status: 201,
description: 'File uploaded successfully',
type: AttachmentResponseDto,
})
@ApiResponse({
status: 400,
description: 'Invalid file or occurrence not found',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async uploadFile(
@UploadedFile() file: Express.Multer.File,
@Body() uploadAttachmentDto: UploadAttachmentDto,
): Promise<AttachmentResponseDto> {
return this.attachmentService.uploadFile(file, uploadAttachmentDto);
}
@Post('upload-multiple')
@UseInterceptors(FilesInterceptor('files', 10)) // Max 10 files at once
@ApiOperation({ summary: 'Upload multiple files to an occurrence' })
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'Multiple file upload with occurrence ID',
schema: {
type: 'object',
properties: {
files: {
type: 'array',
items: {
type: 'string',
format: 'binary',
},
description: 'Files to upload (max 10 files, 10MB each)',
},
occurrenceId: {
type: 'string',
description: 'ID of the occurrence',
example: 'clxyz123abc456def',
},
},
required: ['files', 'occurrenceId'],
},
})
@ApiResponse({
status: 201,
description: 'Files uploaded successfully',
type: [AttachmentResponseDto],
})
@ApiResponse({
status: 400,
description: 'Invalid files or occurrence not found',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async uploadMultipleFiles(
@UploadedFiles() files: Express.Multer.File[],
@Body() uploadAttachmentDto: UploadAttachmentDto,
): Promise<AttachmentResponseDto[]> {
return this.attachmentService.uploadMultipleFiles(
files,
uploadAttachmentDto,
);
}
@Get('occurrence/:occurrenceId')
@ApiOperation({ summary: 'Get all attachments for an occurrence' })
@ApiParam({
name: 'occurrenceId',
description: 'Occurrence unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 200,
description: 'List of attachments for the occurrence',
type: [AttachmentResponseDto],
})
@ApiResponse({
status: 404,
description: 'Occurrence not found',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async findByOccurrence(
@Param('occurrenceId') occurrenceId: string,
): Promise<AttachmentResponseDto[]> {
return this.attachmentService.findByOccurrence(occurrenceId);
}
@Get('stats')
@ApiOperation({ summary: 'Get attachment statistics' })
@ApiQuery({
name: 'occurrenceId',
required: false,
type: String,
description: 'Filter stats by occurrence ID',
})
@ApiResponse({
status: 200,
description: 'Attachment statistics',
schema: {
type: 'object',
properties: {
totalFiles: { type: 'number', example: 42 },
totalSize: { type: 'number', example: 104857600 },
byMimeType: {
type: 'object',
example: {
'application/pdf': 15,
'image/jpeg': 20,
'image/png': 7,
},
},
},
},
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async getStats(@Query('occurrenceId') occurrenceId?: string): Promise<{
totalFiles: number;
totalSize: number;
byMimeType: Record<string, number>;
}> {
return this.attachmentService.getStats(occurrenceId);
}
@Get(':id')
@ApiOperation({ summary: 'Get attachment metadata by ID' })
@ApiParam({
name: 'id',
description: 'Attachment unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 200,
description: 'Attachment metadata',
type: AttachmentResponseDto,
})
@ApiResponse({
status: 404,
description: 'Attachment not found',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async findOne(@Param('id') id: string): Promise<AttachmentResponseDto> {
return this.attachmentService.findOne(id);
}
@Get(':id/download')
@ApiOperation({
summary: 'Download attachment file',
description:
'Downloads the actual file content. Returns the file as a stream with appropriate headers.',
})
@ApiParam({
name: 'id',
description: 'Attachment unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 200,
description: 'File content',
content: {
'application/octet-stream': {
schema: {
type: 'string',
format: 'binary',
},
},
},
})
@ApiResponse({
status: 404,
description: 'Attachment or file not found',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async downloadFile(
@Param('id') id: string,
@Res() res: Response,
): Promise<void> {
const { attachment, fileBuffer } =
await this.attachmentService.downloadFile(id);
// Set appropriate headers
res.set({
'Content-Type': attachment.mimeType,
'Content-Disposition': `attachment; filename="${attachment.originalName}"`,
'Content-Length': attachment.size.toString(),
});
res.send(fileBuffer);
}
@Get(':id/view')
@ApiOperation({
summary: 'View attachment file inline',
description:
'Views the file content inline in the browser (for images, PDFs, etc.)',
})
@ApiParam({
name: 'id',
description: 'Attachment unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 200,
description: 'File content for inline viewing',
content: {
'application/octet-stream': {
schema: {
type: 'string',
format: 'binary',
},
},
},
})
@ApiResponse({
status: 404,
description: 'Attachment or file not found',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async viewFile(@Param('id') id: string, @Res() res: Response): Promise<void> {
const { attachment, fileBuffer } =
await this.attachmentService.downloadFile(id);
// Set headers for inline viewing
res.set({
'Content-Type': attachment.mimeType,
'Content-Disposition': `inline; filename="${attachment.originalName}"`,
'Content-Length': attachment.size.toString(),
});
res.send(fileBuffer);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete attachment by ID' })
@ApiParam({
name: 'id',
description: 'Attachment unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 204,
description: 'Attachment deleted successfully',
})
@ApiResponse({
status: 404,
description: 'Attachment not found',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async remove(@Param('id') id: string): Promise<void> {
return this.attachmentService.remove(id);
}
@Delete('occurrence/:occurrenceId')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN, UserRole.MODERATOR)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'Delete all attachments for an occurrence (Admin/Moderator only)',
description:
'Removes all files and database records for the specified occurrence',
})
@ApiParam({
name: 'occurrenceId',
description: 'Occurrence unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 204,
description: 'All attachments deleted successfully',
})
@ApiResponse({
status: 403,
description: 'Access denied - Admin or Moderator role required',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async removeByOccurrence(
@Param('occurrenceId') occurrenceId: string,
): Promise<void> {
return this.attachmentService.removeByOccurrence(occurrenceId);
}
}
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { AttachmentService } from './attachment.service';
import { AttachmentController } from './attachment.controller';
import { PrismaService } from '../../common/prisma.service';
import { memoryStorage } from 'multer';
@Module({
imports: [
MulterModule.register({
storage: memoryStorage(), // Store files in memory for processing
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
files: 10, // Max 10 files per request
},
fileFilter: (req, file, cb) => {
// Basic file type validation (additional validation in service)
const allowedMimeTypes = [
// Images
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
// Documents
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Text files
'text/plain',
'text/csv',
// Archives
'application/zip',
'application/x-rar-compressed',
'application/x-7z-compressed',
];
if (allowedMimeTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`File type '${file.mimetype}' is not allowed`), false);
}
},
}),
],
controllers: [AttachmentController],
providers: [AttachmentService, PrismaService],
exports: [AttachmentService],
})
export class AttachmentModule {}
import {
Injectable,
NotFoundException,
BadRequestException,
InternalServerErrorException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { AttachmentResponseDto } from './dto/attachment-response.dto';
import { UploadAttachmentDto } from './dto/upload-attachment.dto';
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
const unlinkAsync = promisify(fs.unlink);
const accessAsync = promisify(fs.access);
@Injectable()
export class AttachmentService {
private readonly uploadPath = 'uploads/attachments';
private readonly maxFileSize = 10 * 1024 * 1024; // 10MB
private readonly allowedMimeTypes = [
// Images
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
// Documents
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Text files
'text/plain',
'text/csv',
// Archives
'application/zip',
'application/x-rar-compressed',
'application/x-7z-compressed',
];
constructor(private readonly prisma: PrismaService) {
this.ensureUploadDirectory();
}
private ensureUploadDirectory(): void {
const fullPath = path.resolve(this.uploadPath);
if (!fs.existsSync(fullPath)) {
fs.mkdirSync(fullPath, { recursive: true });
}
}
async uploadFile(
file: Express.Multer.File,
uploadAttachmentDto: UploadAttachmentDto,
): Promise<AttachmentResponseDto> {
// Validate file
this.validateFile(file);
// Validate that occurrence exists
const occurrence = await this.prisma.occurrence.findUnique({
where: { id: uploadAttachmentDto.occurrenceId },
});
if (!occurrence) {
throw new BadRequestException('Occurrence not found');
}
// Generate unique filename
const timestamp = Date.now();
const fileExtension = path.extname(file.originalname);
const sanitizedOriginalName = this.sanitizeFilename(
path.basename(file.originalname, fileExtension),
);
const filename = `${timestamp}-${sanitizedOriginalName}${fileExtension}`;
const filePath = path.join(this.uploadPath, filename);
try {
// Move file to final destination
fs.writeFileSync(filePath, file.buffer);
// Save attachment record to database
const attachment = await this.prisma.attachment.create({
data: {
filename,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
path: filePath,
occurrenceId: uploadAttachmentDto.occurrenceId,
},
});
return attachment;
} catch (error) {
// Clean up file if database operation fails
try {
if (fs.existsSync(filePath)) {
await unlinkAsync(filePath);
}
} catch (cleanupError) {
console.error('Failed to cleanup file:', cleanupError);
}
throw new InternalServerErrorException(
'Failed to save attachment: ' + (error as Error).message,
);
}
}
async uploadMultipleFiles(
files: Express.Multer.File[],
uploadAttachmentDto: UploadAttachmentDto,
): Promise<AttachmentResponseDto[]> {
if (!files || files.length === 0) {
throw new BadRequestException('No files provided');
}
// Validate that occurrence exists
const occurrence = await this.prisma.occurrence.findUnique({
where: { id: uploadAttachmentDto.occurrenceId },
});
if (!occurrence) {
throw new BadRequestException('Occurrence not found');
}
const uploadedAttachments: AttachmentResponseDto[] = [];
const uploadedFiles: string[] = [];
try {
for (const file of files) {
// Validate each file
this.validateFile(file);
// Generate unique filename
const timestamp = Date.now() + Math.random();
const fileExtension = path.extname(file.originalname);
const sanitizedOriginalName = this.sanitizeFilename(
path.basename(file.originalname, fileExtension),
);
const filename = `${timestamp}-${sanitizedOriginalName}${fileExtension}`;
const filePath = path.join(this.uploadPath, filename);
// Write file
fs.writeFileSync(filePath, file.buffer);
uploadedFiles.push(filePath);
// Save attachment record to database
const attachment = await this.prisma.attachment.create({
data: {
filename,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
path: filePath,
occurrenceId: uploadAttachmentDto.occurrenceId,
},
});
uploadedAttachments.push(attachment);
}
return uploadedAttachments;
} catch (error) {
// Clean up all uploaded files if any operation fails
for (const filePath of uploadedFiles) {
try {
if (fs.existsSync(filePath)) {
await unlinkAsync(filePath);
}
} catch (cleanupError) {
console.error('Failed to cleanup file:', cleanupError);
}
}
throw new InternalServerErrorException(
'Failed to save attachments: ' + (error as Error).message,
);
}
}
async findByOccurrence(
occurrenceId: string,
): Promise<AttachmentResponseDto[]> {
// Validate that occurrence exists
const occurrence = await this.prisma.occurrence.findUnique({
where: { id: occurrenceId },
});
if (!occurrence) {
throw new NotFoundException('Occurrence not found');
}
return this.prisma.attachment.findMany({
where: { occurrenceId },
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string): Promise<AttachmentResponseDto> {
const attachment = await this.prisma.attachment.findUnique({
where: { id },
});
if (!attachment) {
throw new NotFoundException(`Attachment with ID ${id} not found`);
}
return attachment;
}
async downloadFile(id: string): Promise<{
attachment: AttachmentResponseDto;
fileBuffer: Buffer;
}> {
const attachment = await this.findOne(id);
try {
// Check if file exists
await accessAsync(attachment.path);
const fileBuffer = fs.readFileSync(attachment.path);
return {
attachment,
fileBuffer,
};
} catch {
throw new NotFoundException('File not found on disk');
}
}
async remove(id: string): Promise<void> {
const attachment = await this.findOne(id);
try {
// Delete file from disk
if (fs.existsSync(attachment.path)) {
await unlinkAsync(attachment.path);
}
} catch (error) {
console.error('Failed to delete file from disk:', error);
// Continue with database deletion even if file deletion fails
}
// Delete record from database
await this.prisma.attachment.delete({
where: { id },
});
}
async removeByOccurrence(occurrenceId: string): Promise<void> {
const attachments = await this.prisma.attachment.findMany({
where: { occurrenceId },
});
// Delete files from disk
for (const attachment of attachments) {
try {
if (fs.existsSync(attachment.path)) {
await unlinkAsync(attachment.path);
}
} catch (error) {
console.error('Failed to delete file from disk:', error);
}
}
// Delete records from database
await this.prisma.attachment.deleteMany({
where: { occurrenceId },
});
}
private validateFile(file: Express.Multer.File): void {
if (!file) {
throw new BadRequestException('No file provided');
}
// Check file size
if (file.size > this.maxFileSize) {
throw new BadRequestException(
`File size exceeds maximum allowed size of ${this.maxFileSize / (1024 * 1024)}MB`,
);
}
// Check MIME type
if (!this.allowedMimeTypes.includes(file.mimetype)) {
throw new BadRequestException(
`File type '${file.mimetype}' is not allowed. Allowed types: ${this.allowedMimeTypes.join(', ')}`,
);
}
// Check for null bytes and suspicious patterns
if (file.originalname.includes('\0')) {
throw new BadRequestException('Invalid filename');
}
// Check filename length
if (file.originalname.length > 255) {
throw new BadRequestException('Filename too long');
}
}
private sanitizeFilename(filename: string): string {
// Remove any path separators and special characters
return filename
.replace(/[^a-zA-Z0-9.-]/g, '_')
.replace(/_{2,}/g, '_')
.slice(0, 100); // Limit length
}
async getStats(occurrenceId?: string): Promise<{
totalFiles: number;
totalSize: number;
byMimeType: Record<string, number>;
}> {
const where = occurrenceId ? { occurrenceId } : {};
const attachments = await this.prisma.attachment.findMany({
where,
select: {
size: true,
mimeType: true,
},
});
const totalFiles = attachments.length;
const totalSize = attachments.reduce((sum, att) => sum + att.size, 0);
const byMimeType = attachments.reduce(
(acc, att) => {
acc[att.mimeType] = (acc[att.mimeType] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
return {
totalFiles,
totalSize,
byMimeType,
};
}
}
import { ApiProperty } from '@nestjs/swagger';
export class AttachmentResponseDto {
@ApiProperty({
description: 'Attachment unique identifier',
example: 'clxyz123abc456def',
})
id: string;
@ApiProperty({
description: 'Generated filename for storage',
example: '1642234567890-document.pdf',
})
filename: string;
@ApiProperty({
description: 'Original filename from upload',
example: 'incident-report.pdf',
})
originalName: string;
@ApiProperty({
description: 'MIME type of the file',
example: 'application/pdf',
})
mimeType: string;
@ApiProperty({
description: 'File size in bytes',
example: 2048576,
})
size: number;
@ApiProperty({
description: 'File storage path',
example: 'uploads/attachments/1642234567890-document.pdf',
})
path: string;
@ApiProperty({
description: 'ID of the occurrence this attachment belongs to',
example: 'clxyz123abc456def',
})
occurrenceId: string;
@ApiProperty({
description: 'Attachment creation timestamp',
example: '2024-01-15T10:30:00.000Z',
})
createdAt: Date;
}
import { IsNotEmpty, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UploadAttachmentDto {
@ApiProperty({
description: 'ID of the occurrence this attachment belongs to',
example: 'clxyz123abc456def',
})
@IsUUID()
@IsNotEmpty()
occurrenceId: string;
}
export * from './attachment.controller';
export * from './attachment.service';
export * from './attachment.module';
export * from './dto/upload-attachment.dto';
export * from './dto/attachment-response.dto';
import {
Controller,
Post,
Body,
Get,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBody,
ApiBearerAuth,
} from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { AuthResponseDto } from './dto/auth-response.dto';
import { UserResponseDto } from '../user/dto/user-response.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@Public()
@ApiOperation({ summary: 'Register a new user' })
@ApiBody({ type: RegisterDto })
@ApiResponse({
status: 201,
description: 'User registered successfully',
type: AuthResponseDto,
})
@ApiResponse({
status: 409,
description: 'User with this email already exists',
})
@ApiResponse({
status: 400,
description: 'Invalid input data',
})
async register(@Body() registerDto: RegisterDto): Promise<AuthResponseDto> {
return this.authService.register(registerDto);
}
@Post('login')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login user' })
@ApiBody({ type: LoginDto })
@ApiResponse({
status: 200,
description: 'User logged in successfully',
type: AuthResponseDto,
})
@ApiResponse({
status: 401,
description: 'Invalid credentials',
})
@ApiResponse({
status: 400,
description: 'Invalid input data',
})
async login(@Body() loginDto: LoginDto): Promise<AuthResponseDto> {
return this.authService.login(loginDto);
}
@Get('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully',
type: UserResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - Invalid or missing token',
})
async getProfile(
@CurrentUser() user: UserResponseDto,
): Promise<UserResponseDto> {
return this.authService.getProfile(user.id);
}
@Post('refresh')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Refresh access token' })
@ApiResponse({
status: 200,
description: 'Token refreshed successfully',
type: AuthResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - Invalid or missing token',
})
async refreshToken(
@CurrentUser() user: UserResponseDto,
): Promise<AuthResponseDto> {
return this.authService.refreshToken(user);
}
}
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@Module({
imports: [
UserModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: process.env.JWT_SECRET || 'your-secret-key', // Should be in .env
signOptions: {
expiresIn: '1h', // Default expiration
},
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard],
exports: [AuthService, JwtAuthGuard], // Export for use in other modules
})
export class AuthModule {}
import {
Injectable,
UnauthorizedException,
ConflictException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { UserService } from '../user/user.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { AuthResponseDto } from './dto/auth-response.dto';
import { UserResponseDto } from '../user/dto/user-response.dto';
import { JwtPayload } from './strategies/jwt.strategy';
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
) {}
async register(registerDto: RegisterDto): Promise<AuthResponseDto> {
// Check if user already exists
const existingUser = await this.userService.findByEmail(registerDto.email);
if (existingUser) {
throw new ConflictException('User with this email already exists');
}
// Create the user
const user = await this.userService.create(registerDto);
// Generate JWT token
const tokens = await this.generateTokens(user);
return {
...tokens,
user,
};
}
async login(loginDto: LoginDto): Promise<AuthResponseDto> {
// Find user by email (need to get password for validation)
const user = await this.validateUser(loginDto.email, loginDto.password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Generate JWT token
const tokens = await this.generateTokens(user);
return {
...tokens,
user,
};
}
async validateUser(
email: string,
password: string,
): Promise<UserResponseDto | null> {
try {
// Get user with password for validation
const userWithPassword = await this.userService['prisma'].user.findUnique(
{
where: { email },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
role: true,
password: true,
createdAt: true,
updatedAt: true,
},
},
);
if (!userWithPassword) {
return null;
}
// Validate password
const isPasswordValid = await bcrypt.compare(
password,
userWithPassword.password,
);
if (!isPasswordValid) {
return null;
}
// Return user without password
const { password: _, ...user } = userWithPassword;
return user;
} catch (error) {
return null;
}
}
private async generateTokens(user: UserResponseDto): Promise<{
accessToken: string;
tokenType: string;
expiresIn: number;
}> {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
role: user.role,
};
const expiresIn = 3600; // 1 hour in seconds
const accessToken = await this.jwtService.signAsync(payload, {
expiresIn: `${expiresIn}s`,
});
return {
accessToken,
tokenType: 'Bearer',
expiresIn,
};
}
async refreshToken(user: UserResponseDto): Promise<AuthResponseDto> {
const tokens = await this.generateTokens(user);
return {
...tokens,
user,
};
}
async getProfile(userId: string): Promise<UserResponseDto> {
return this.userService.findOne(userId);
}
}
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { UserResponseDto } from '../../user/dto/user-response.dto';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): UserResponseDto => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../../../generated/prisma';
export const Roles = (...roles: UserRole[]) => SetMetadata('roles', roles);
import { ApiProperty } from '@nestjs/swagger';
import { UserResponseDto } from '../../user/dto/user-response.dto';
export class AuthResponseDto {
@ApiProperty({
description: 'JWT access token',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
accessToken: string;
@ApiProperty({
description: 'Token type',
example: 'Bearer',
default: 'Bearer',
})
tokenType: string = 'Bearer';
@ApiProperty({
description: 'Token expiration time in seconds',
example: 3600,
})
expiresIn: number;
@ApiProperty({
description: 'Authenticated user information',
type: UserResponseDto,
})
user: UserResponseDto;
}
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@ApiProperty({
description: 'User email address',
example: 'john.doe@example.com',
})
@IsEmail()
@IsNotEmpty()
email: string;
@ApiProperty({
description: 'User password',
example: 'securePassword123',
})
@IsNotEmpty()
@IsString()
password: string;
}
import { ApiProperty } from '@nestjs/swagger';
import {
IsEmail,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
import { UserRole } from '../../../../generated/prisma';
export class RegisterDto {
@ApiProperty({
description: 'User first name',
example: 'John',
})
@IsNotEmpty()
@IsString()
firstName: string;
@ApiProperty({
description: 'User last name',
example: 'Doe',
})
@IsNotEmpty()
@IsString()
lastName: string;
@ApiProperty({
description: 'User email address',
example: 'john.doe@example.com',
})
@IsEmail()
@IsNotEmpty()
email: string;
@ApiProperty({
description: 'User password',
example: 'securePassword123',
minLength: 6,
})
@IsNotEmpty()
@IsString()
@MinLength(6)
password: string;
@ApiProperty({
description: 'User role',
enum: UserRole,
example: UserRole.USER,
default: UserRole.USER,
required: false,
})
@IsOptional()
@IsEnum(UserRole)
role?: UserRole = UserRole.USER;
}
import { Injectable, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
// Check if the route is marked as public
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true; // Skip authentication for public routes
}
return super.canActivate(context);
}
}
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '../../../../generated/prisma';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
'roles',
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) {
return false;
}
return requiredRoles.some((role) => user.role === role);
}
}
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UserService } from '../../user/user.service';
export interface JwtPayload {
sub: string; // User ID
email: string;
role: string;
iat?: number;
exp?: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly userService: UserService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'your-secret-key', // Should be in .env
});
}
async validate(payload: JwtPayload) {
try {
const user = await this.userService.findOne(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found');
}
return user; // This will be available as req.user
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
}
# Comment Module
This module handles comments for occurrences with support for internal comments that are only visible to admins and moderators.
## Features
### Comment Types
- **Public Comments**: Visible to all users
- **Internal Comments**: Only visible to users with ADMIN or MODERATOR roles
### Role-Based Access Control
- **USER**: Can create, read (public only), update own, and delete own comments
- **MODERATOR**: Can create, read all (including internal), update any, delete any, and create internal comments
- **ADMIN**: Can create, read all (including internal), update any, delete any, and create internal comments
### API Endpoints
#### Standard Endpoints
- `POST /comments` - Create a new comment
- `GET /comments/occurrence/:occurrenceId` - Get all comments for an occurrence (filtered by user role)
- `GET /comments/occurrence/:occurrenceId/count` - Get comment count for an occurrence
- `GET /comments/:id` - Get a specific comment
- `PATCH /comments/:id` - Update a comment (own comments for users, any for admin/moderator)
- `DELETE /comments/:id` - Delete a comment (own comments for users, any for admin/moderator)
#### Admin/Moderator Only Endpoints
- `POST /comments/internal` - Create an internal comment (automatically sets isInternal: true)
- `GET /comments/occurrence/:occurrenceId/internal` - Get only internal comments for an occurrence
### DTOs
#### CreateCommentDto
- `content`: string (required) - The comment content
- `occurrenceId`: string (required) - UUID of the occurrence
- `isInternal`: boolean (optional, default: false) - Whether the comment is internal
#### UpdateCommentDto
- `content`: string (optional) - Updated comment content
- `isInternal`: boolean (optional) - Change internal status (admin/moderator only)
#### CommentResponseDto
- Full comment details including author information and timestamps
### Database Model
The Comment model in Prisma schema includes:
- `id`: String (CUID)
- `content`: Text
- `isInternal`: Boolean (default: false)
- `occurrenceId`: String (foreign key)
- `authorId`: String (foreign key)
- `createdAt`: DateTime
- `updatedAt`: DateTime
### Authorization Rules
1. **Reading Comments**:
- Public comments: All authenticated users
- Internal comments: Only ADMIN and MODERATOR roles
2. **Creating Comments**:
- Public comments: All authenticated users
- Internal comments: Only ADMIN and MODERATOR roles
3. **Updating Comments**:
- Users can update their own comments (content only)
- ADMIN/MODERATOR can update any comment and change internal status
4. **Deleting Comments**:
- Users can delete their own comments
- ADMIN/MODERATOR can delete any comment
### Usage Examples
```typescript
// Create a public comment
POST /comments
{
"content": "This issue has been resolved",
"occurrenceId": "clxyz123abc456def"
}
// Create an internal comment (admin/moderator only)
POST /comments/internal
{
"content": "Internal note: User seems confused about the process",
"occurrenceId": "clxyz123abc456def"
}
// Get comments for an occurrence (automatically filtered by role)
GET /comments/occurrence/clxyz123abc456def?page=1&limit=20
```
## Security Considerations
- Internal comments are completely hidden from regular users
- Users cannot escalate their own comments to internal status
- Role-based filtering is applied at the service level for robust security
- All endpoints require authentication via JWT
- Admin/Moderator endpoints have additional role guards
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
HttpCode,
HttpStatus,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBody,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { CommentService } from './comment.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentResponseDto } from './dto/comment-response.dto';
import { UserRole } from '../../../generated/prisma';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { UserResponseDto } from '../user/dto/user-response.dto';
@ApiTags('comments')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('comments')
export class CommentController {
constructor(private readonly commentService: CommentService) {}
@Post()
@ApiOperation({ summary: 'Create a new comment' })
@ApiBody({ type: CreateCommentDto })
@ApiResponse({
status: 201,
description: 'Comment created successfully',
type: CommentResponseDto,
})
@ApiResponse({
status: 400,
description: 'Invalid input data or occurrence not found',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async create(
@Body() createCommentDto: CreateCommentDto,
@CurrentUser() user: UserResponseDto,
): Promise<CommentResponseDto> {
return this.commentService.create(createCommentDto, user.id);
}
@Get('occurrence/:occurrenceId')
@ApiOperation({ summary: 'Get all comments for an occurrence' })
@ApiParam({
name: 'occurrenceId',
description: 'Occurrence unique identifier',
example: 'clxyz123abc456def',
})
@ApiQuery({
name: 'page',
required: false,
type: Number,
description: 'Page number (default: 1)',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of items per page (default: 20)',
})
@ApiResponse({
status: 200,
description: 'List of comments for the occurrence',
type: [CommentResponseDto],
})
@ApiResponse({
status: 404,
description: 'Occurrence not found',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async findByOccurrence(
@Param('occurrenceId') occurrenceId: string,
@CurrentUser() user: UserResponseDto,
@Query('page') page?: number,
@Query('limit') limit?: number,
): Promise<CommentResponseDto[]> {
return this.commentService.findByOccurrence(
occurrenceId,
user.role,
page ? Number(page) : undefined,
limit ? Number(limit) : undefined,
);
}
@Get('occurrence/:occurrenceId/count')
@ApiOperation({ summary: 'Get total number of comments for an occurrence' })
@ApiParam({
name: 'occurrenceId',
description: 'Occurrence unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 200,
description: 'Total number of comments',
schema: {
type: 'object',
properties: {
count: {
type: 'number',
example: 42,
},
},
},
})
@ApiResponse({
status: 404,
description: 'Occurrence not found',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async getCount(
@Param('occurrenceId') occurrenceId: string,
@CurrentUser() user: UserResponseDto,
): Promise<{ count: number }> {
const count = await this.commentService.count(occurrenceId, user.role);
return { count };
}
@Get(':id')
@ApiOperation({ summary: 'Get comment by ID' })
@ApiParam({
name: 'id',
description: 'Comment unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 200,
description: 'Comment found',
type: CommentResponseDto,
})
@ApiResponse({
status: 404,
description: 'Comment not found',
})
@ApiResponse({
status: 403,
description: 'Access denied to internal comment',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async findOne(
@Param('id') id: string,
@CurrentUser() user: UserResponseDto,
): Promise<CommentResponseDto> {
return this.commentService.findOne(id, user.role);
}
@Patch(':id')
@ApiOperation({ summary: 'Update comment by ID' })
@ApiParam({
name: 'id',
description: 'Comment unique identifier',
example: 'clxyz123abc456def',
})
@ApiBody({ type: UpdateCommentDto })
@ApiResponse({
status: 200,
description: 'Comment updated successfully',
type: CommentResponseDto,
})
@ApiResponse({
status: 404,
description: 'Comment not found',
})
@ApiResponse({
status: 403,
description:
'Access denied - you can only edit your own comments or internal status change requires admin/moderator role',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async update(
@Param('id') id: string,
@Body() updateCommentDto: UpdateCommentDto,
@CurrentUser() user: UserResponseDto,
): Promise<CommentResponseDto> {
return this.commentService.update(id, updateCommentDto, user.id, user.role);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete comment by ID' })
@ApiParam({
name: 'id',
description: 'Comment unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 204,
description: 'Comment deleted successfully',
})
@ApiResponse({
status: 404,
description: 'Comment not found',
})
@ApiResponse({
status: 403,
description: 'Access denied - you can only delete your own comments',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async remove(
@Param('id') id: string,
@CurrentUser() user: UserResponseDto,
): Promise<void> {
return this.commentService.remove(id, user.id, user.role);
}
// Admin/Moderator only endpoints for managing internal comments
@Post('internal')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN, UserRole.MODERATOR)
@ApiOperation({
summary: 'Create an internal comment (Admin/Moderator only)',
description:
'Creates a comment that is only visible to admins and moderators',
})
@ApiBody({ type: CreateCommentDto })
@ApiResponse({
status: 201,
description: 'Internal comment created successfully',
type: CommentResponseDto,
})
@ApiResponse({
status: 400,
description: 'Invalid input data or occurrence not found',
})
@ApiResponse({
status: 403,
description: 'Access denied - Admin or Moderator role required',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async createInternal(
@Body() createCommentDto: CreateCommentDto,
@CurrentUser() user: UserResponseDto,
): Promise<CommentResponseDto> {
// Force isInternal to true for this endpoint
const internalCommentDto = { ...createCommentDto, isInternal: true };
return this.commentService.create(internalCommentDto, user.id);
}
@Get('occurrence/:occurrenceId/internal')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN, UserRole.MODERATOR)
@ApiOperation({
summary:
'Get only internal comments for an occurrence (Admin/Moderator only)',
description:
'Returns only comments marked as internal for the specified occurrence',
})
@ApiParam({
name: 'occurrenceId',
description: 'Occurrence unique identifier',
example: 'clxyz123abc456def',
})
@ApiQuery({
name: 'page',
required: false,
type: Number,
description: 'Page number (default: 1)',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of items per page (default: 20)',
})
@ApiResponse({
status: 200,
description: 'List of internal comments for the occurrence',
type: [CommentResponseDto],
})
@ApiResponse({
status: 404,
description: 'Occurrence not found',
})
@ApiResponse({
status: 403,
description: 'Access denied - Admin or Moderator role required',
})
@ApiResponse({
status: 401,
description: 'Unauthorized',
})
async findInternalByOccurrence(
@Param('occurrenceId') occurrenceId: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
): Promise<CommentResponseDto[]> {
// Since this endpoint is restricted to admins/moderators, we pass ADMIN role
// which will show all comments including internal ones, then we'll filter
const allComments = await this.commentService.findByOccurrence(
occurrenceId,
UserRole.ADMIN,
page ? Number(page) : undefined,
limit ? Number(limit) : undefined,
);
// Filter to only internal comments
return allComments.filter((comment) => comment.isInternal);
}
}
import { Module } from '@nestjs/common';
import { CommentService } from './comment.service';
import { CommentController } from './comment.controller';
import { PrismaService } from '../../common/prisma.service';
@Module({
controllers: [CommentController],
providers: [CommentService, PrismaService],
exports: [CommentService],
})
export class CommentModule {}
import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentResponseDto } from './dto/comment-response.dto';
import { UserRole } from '../../../generated/prisma';
@Injectable()
export class CommentService {
constructor(private readonly prisma: PrismaService) {}
async create(
createCommentDto: CreateCommentDto,
authorId: string,
): Promise<CommentResponseDto> {
// Validate that occurrence exists
const occurrence = await this.prisma.occurrence.findUnique({
where: { id: createCommentDto.occurrenceId },
});
if (!occurrence) {
throw new BadRequestException('Occurrence not found');
}
// Validate that author exists
const author = await this.prisma.user.findUnique({
where: { id: authorId },
});
if (!author) {
throw new BadRequestException('Author user not found');
}
const comment = await this.prisma.comment.create({
data: {
content: createCommentDto.content,
isInternal: createCommentDto.isInternal || false,
occurrenceId: createCommentDto.occurrenceId,
authorId: authorId,
},
include: {
author: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
},
});
return comment;
}
async findByOccurrence(
occurrenceId: string,
userRole: UserRole,
page: number = 1,
limit: number = 20,
): Promise<CommentResponseDto[]> {
// Validate that occurrence exists
const occurrence = await this.prisma.occurrence.findUnique({
where: { id: occurrenceId },
});
if (!occurrence) {
throw new NotFoundException('Occurrence not found');
}
const skip = (page - 1) * limit;
// Build where clause based on user role
const where: {
occurrenceId: string;
isInternal?: boolean;
} = { occurrenceId };
// If user is not admin or moderator, exclude internal comments
if (userRole !== UserRole.ADMIN && userRole !== UserRole.MODERATOR) {
where.isInternal = false;
}
const comments = await this.prisma.comment.findMany({
where,
include: {
author: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
},
orderBy: {
createdAt: 'asc',
},
skip,
take: limit,
});
return comments;
}
async findOne(id: string, userRole: UserRole): Promise<CommentResponseDto> {
const comment = await this.prisma.comment.findUnique({
where: { id },
include: {
author: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
},
});
if (!comment) {
throw new NotFoundException(`Comment with ID ${id} not found`);
}
// Check if user can view internal comments
if (
comment.isInternal &&
userRole !== UserRole.ADMIN &&
userRole !== UserRole.MODERATOR
) {
throw new ForbiddenException('Access denied to internal comment');
}
return comment;
}
async update(
id: string,
updateCommentDto: UpdateCommentDto,
userId: string,
userRole: UserRole,
): Promise<CommentResponseDto> {
const comment = await this.prisma.comment.findUnique({
where: { id },
include: {
author: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
},
});
if (!comment) {
throw new NotFoundException(`Comment with ID ${id} not found`);
}
// Check if user can view the comment first
if (
comment.isInternal &&
userRole !== UserRole.ADMIN &&
userRole !== UserRole.MODERATOR
) {
throw new ForbiddenException('Access denied to internal comment');
}
// Check if user can edit the comment
// Users can only edit their own comments, admins and moderators can edit any comment
if (
comment.authorId !== userId &&
userRole !== UserRole.ADMIN &&
userRole !== UserRole.MODERATOR
) {
throw new ForbiddenException('You can only edit your own comments');
}
// Only admins and moderators can change the isInternal flag
const updateData: {
content?: string;
isInternal?: boolean;
} = { content: updateCommentDto.content };
if (updateCommentDto.isInternal !== undefined) {
if (userRole !== UserRole.ADMIN && userRole !== UserRole.MODERATOR) {
throw new ForbiddenException(
'Only admins and moderators can change internal status',
);
}
updateData.isInternal = updateCommentDto.isInternal;
}
const updatedComment = await this.prisma.comment.update({
where: { id },
data: updateData,
include: {
author: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
},
});
return updatedComment;
}
async remove(id: string, userId: string, userRole: UserRole): Promise<void> {
const comment = await this.prisma.comment.findUnique({
where: { id },
});
if (!comment) {
throw new NotFoundException(`Comment with ID ${id} not found`);
}
// Check if user can view the comment first
if (
comment.isInternal &&
userRole !== UserRole.ADMIN &&
userRole !== UserRole.MODERATOR
) {
throw new ForbiddenException('Access denied to internal comment');
}
// Check if user can delete the comment
// Users can only delete their own comments, admins and moderators can delete any comment
if (
comment.authorId !== userId &&
userRole !== UserRole.ADMIN &&
userRole !== UserRole.MODERATOR
) {
throw new ForbiddenException('You can only delete your own comments');
}
await this.prisma.comment.delete({
where: { id },
});
}
async count(occurrenceId: string, userRole: UserRole): Promise<number> {
// Validate that occurrence exists
const occurrence = await this.prisma.occurrence.findUnique({
where: { id: occurrenceId },
});
if (!occurrence) {
throw new NotFoundException('Occurrence not found');
}
// Build where clause based on user role
const where: {
occurrenceId: string;
isInternal?: boolean;
} = { occurrenceId };
// If user is not admin or moderator, exclude internal comments
if (userRole !== UserRole.ADMIN && userRole !== UserRole.MODERATOR) {
where.isInternal = false;
}
return this.prisma.comment.count({ where });
}
}
import { ApiProperty } from '@nestjs/swagger';
export class CommentAuthorDto {
@ApiProperty({
description: 'Author unique identifier',
example: 'clxyz123abc456def',
})
id: string;
@ApiProperty({
description: 'Author first name',
example: 'John',
})
firstName: string;
@ApiProperty({
description: 'Author last name',
example: 'Doe',
})
lastName: string;
@ApiProperty({
description: 'Author email address',
example: 'john.doe@example.com',
})
email: string;
}
export class CommentResponseDto {
@ApiProperty({
description: 'Comment unique identifier',
example: 'clxyz123abc456def',
})
id: string;
@ApiProperty({
description: 'Comment content',
example:
'This occurrence has been reviewed and needs further investigation.',
})
content: string;
@ApiProperty({
description:
'Whether this comment is internal (only visible to admins and moderators)',
example: false,
})
isInternal: boolean;
@ApiProperty({
description: 'ID of the occurrence this comment belongs to',
example: 'clxyz123abc456def',
})
occurrenceId: string;
@ApiProperty({
description: 'Comment author information',
type: CommentAuthorDto,
})
author: CommentAuthorDto;
@ApiProperty({
description: 'ID of the comment author',
example: 'clxyz123abc456def',
})
authorId: string;
@ApiProperty({
description: 'Comment creation timestamp',
example: '2024-01-15T10:30:00.000Z',
})
createdAt: Date;
@ApiProperty({
description: 'Comment last update timestamp',
example: '2024-01-15T10:30:00.000Z',
})
updatedAt: Date;
}
import {
IsString,
IsNotEmpty,
IsOptional,
IsBoolean,
IsUUID,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateCommentDto {
@ApiProperty({
description: 'Content of the comment',
example:
'This occurrence has been reviewed and needs further investigation.',
})
@IsString()
@IsNotEmpty()
content: string;
@ApiProperty({
description: 'ID of the occurrence this comment belongs to',
example: 'clxyz123abc456def',
})
@IsUUID()
@IsNotEmpty()
occurrenceId: string;
@ApiPropertyOptional({
description:
'Whether this comment is internal (only visible to admins and moderators)',
example: false,
default: false,
})
@IsOptional()
@IsBoolean()
isInternal?: boolean = false;
}
import { PartialType } from '@nestjs/swagger';
import { CreateCommentDto } from './create-comment.dto';
import { OmitType } from '@nestjs/swagger';
export class UpdateCommentDto extends PartialType(
OmitType(CreateCommentDto, ['occurrenceId'] as const),
) {}
export * from './comment.controller';
export * from './comment.service';
export * from './comment.module';
export * from './dto/create-comment.dto';
export * from './dto/update-comment.dto';
export * from './dto/comment-response.dto';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class AssignOccurrenceDto {
@ApiProperty({
description: 'ID of the user to assign the occurrence to',
example: 'clxyz123abc456def',
})
@IsNotEmpty()
@IsString()
assigneeId: string;
@ApiPropertyOptional({
description: 'Optional note about the assignment',
example: 'Assigned to technical team lead for urgent review',
})
@IsOptional()
@IsString()
assignmentNote?: string;
}
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { OccurrenceStatus, Priority } from '../../../../generated/prisma';
export class CreateOccurrenceDto {
@ApiProperty({
description: 'Occurrence title',
example: 'System malfunction in building A',
maxLength: 255,
})
@IsNotEmpty()
@IsString()
@MaxLength(255)
title: string;
@ApiProperty({
description: 'Detailed description of the occurrence',
example:
'The elevator in building A is not working properly. Users are getting stuck between floors.',
})
@IsNotEmpty()
@IsString()
description: string;
@ApiProperty({
description: 'Occurrence category',
example: 'Infrastructure',
})
@IsNotEmpty()
@IsString()
category: string;
@ApiPropertyOptional({
description: 'Location where the occurrence happened',
example: 'Building A, 3rd floor',
})
@IsOptional()
@IsString()
location?: string;
@ApiPropertyOptional({
description: 'Occurrence priority level',
enum: Priority,
example: Priority.HIGH,
default: Priority.MEDIUM,
})
@IsOptional()
@IsEnum(Priority)
priority?: Priority = Priority.MEDIUM;
@ApiPropertyOptional({
description: 'Occurrence status',
enum: OccurrenceStatus,
example: OccurrenceStatus.OPEN,
default: OccurrenceStatus.OPEN,
})
@IsOptional()
@IsEnum(OccurrenceStatus)
status?: OccurrenceStatus = OccurrenceStatus.OPEN;
@ApiProperty({
description: 'ID of the user reporting the occurrence',
example: 'clxyz123abc456def',
})
@IsNotEmpty()
@IsString()
reporterId: string;
@ApiPropertyOptional({
description: 'ID of the user assigned to handle the occurrence',
example: 'clxyz123abc456def',
})
@IsOptional()
@IsString()
assigneeId?: string;
}
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { OccurrenceStatus, Priority } from '../../../../generated/prisma';
export class OccurrenceResponseDto {
@ApiProperty({
description: 'Occurrence unique identifier',
example: 'clxyz123abc456def',
})
id: string;
@ApiProperty({
description: 'Occurrence title',
example: 'System malfunction in building A',
})
title: string;
@ApiProperty({
description: 'Detailed description of the occurrence',
example: 'The elevator in building A is not working properly.',
})
description: string;
@ApiProperty({
description: 'Occurrence status',
enum: OccurrenceStatus,
example: OccurrenceStatus.OPEN,
})
status: OccurrenceStatus;
@ApiProperty({
description: 'Occurrence priority level',
enum: Priority,
example: Priority.MEDIUM,
})
priority: Priority;
@ApiProperty({
description: 'Occurrence category',
example: 'Infrastructure',
})
category: string;
@ApiPropertyOptional({
description: 'Location where the occurrence happened',
example: 'Building A, 3rd floor',
})
location?: string | null;
@ApiProperty({
description: 'ID of the user who reported the occurrence',
example: 'clxyz123abc456def',
})
reporterId: string;
@ApiPropertyOptional({
description: 'ID of the user assigned to handle the occurrence',
example: 'clxyz123abc456def',
})
assigneeId?: string | null;
@ApiProperty({
description: 'Occurrence creation timestamp',
example: '2024-01-01T12:00:00.000Z',
})
createdAt: Date;
@ApiProperty({
description: 'Occurrence last update timestamp',
example: '2024-01-01T12:00:00.000Z',
})
updatedAt: Date;
@ApiPropertyOptional({
description: 'Date when the occurrence was closed',
example: '2024-01-01T12:00:00.000Z',
})
closedAt?: Date | null;
@ApiPropertyOptional({
description: 'User who reported the occurrence',
type: 'object',
properties: {
id: { type: 'string', example: 'clxyz123abc456def' },
firstName: { type: 'string', example: 'John' },
lastName: { type: 'string', example: 'Doe' },
email: { type: 'string', example: 'john.doe@example.com' },
},
})
reportedBy?: {
id: string;
firstName: string;
lastName: string;
email: string;
};
@ApiPropertyOptional({
description: 'User assigned to handle the occurrence',
type: 'object',
properties: {
id: { type: 'string', example: 'clxyz123abc456def' },
firstName: { type: 'string', example: 'Jane' },
lastName: { type: 'string', example: 'Smith' },
email: { type: 'string', example: 'jane.smith@example.com' },
},
})
assignedTo?: {
id: string;
firstName: string;
lastName: string;
email: string;
} | null;
@ApiPropertyOptional({
description: 'Number of comments on this occurrence',
example: 5,
})
_count?: {
comments: number;
attachments: number;
};
}
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsDateString } from 'class-validator';
import { CreateOccurrenceDto } from './create-occurrence.dto';
import { OccurrenceStatus, Priority } from '../../../../generated/prisma';
export class UpdateOccurrenceDto extends PartialType(CreateOccurrenceDto) {
@ApiPropertyOptional({
description: 'Occurrence status',
enum: OccurrenceStatus,
example: OccurrenceStatus.IN_PROGRESS,
})
@IsOptional()
@IsEnum(OccurrenceStatus)
status?: OccurrenceStatus;
@ApiPropertyOptional({
description: 'Occurrence priority level',
enum: Priority,
example: Priority.HIGH,
})
@IsOptional()
@IsEnum(Priority)
priority?: Priority;
@ApiPropertyOptional({
description: 'ID of the user assigned to handle the occurrence',
example: 'clxyz123abc456def',
})
@IsOptional()
assigneeId?: string;
@ApiPropertyOptional({
description: 'Date when the occurrence was closed (ISO string)',
example: '2024-01-01T12:00:00.000Z',
})
@IsOptional()
@IsDateString()
closedAt?: string;
}
import { Module } from '@nestjs/common';
import { OccurrenceService } from './occurrence.service';
import { OccurrenceController } from './occurrence.controller';
import { PrismaService } from '../../common/prisma.service';
@Module({
controllers: [OccurrenceController],
providers: [OccurrenceService, PrismaService],
exports: [OccurrenceService], // Export service for use in other modules
})
export class OccurrenceModule {}
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class AdminChangePasswordDto {
@ApiProperty({
description: 'New password to set for the user',
example: 'newSecurePassword123',
minLength: 6,
})
@IsNotEmpty()
@IsString()
@MinLength(6)
newPassword: string;
}
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@ApiProperty({
description: 'Current password for verification',
example: 'currentPassword123',
})
@IsNotEmpty()
@IsString()
currentPassword: string;
@ApiProperty({
description: 'New password',
example: 'newSecurePassword123',
minLength: 6,
})
@IsNotEmpty()
@IsString()
@MinLength(6)
newPassword: string;
}
import { ApiProperty } from '@nestjs/swagger';
import {
IsEmail,
IsEnum,
IsNotEmpty,
IsString,
MinLength,
} from 'class-validator';
import { UserRole } from '../../../../generated/prisma';
export class CreateUserDto {
@ApiProperty({
description: 'User first name',
example: 'John',
})
@IsNotEmpty()
@IsString()
firstName: string;
@ApiProperty({
description: 'User last name',
example: 'Doe',
})
@IsNotEmpty()
@IsString()
lastName: string;
@ApiProperty({
description: 'User email address',
example: 'john.doe@example.com',
})
@IsEmail()
@IsNotEmpty()
email: string;
@ApiProperty({
description: 'User password',
example: 'securePassword123',
minLength: 6,
})
@IsNotEmpty()
@IsString()
@MinLength(6)
password: string;
@ApiProperty({
description: 'User role',
enum: UserRole,
example: UserRole.USER,
default: UserRole.USER,
})
@IsEnum(UserRole)
role?: UserRole = UserRole.USER;
}
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { CreateUserDto } from './create-user.dto';
import { UserRole } from '../../../../generated/prisma';
export class UpdateUserDto extends PartialType(CreateUserDto) {
@ApiPropertyOptional({
description: 'User role',
enum: UserRole,
example: UserRole.USER,
})
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
}
import { ApiProperty } from '@nestjs/swagger';
import { UserRole } from '../../../../generated/prisma';
export class UserResponseDto {
@ApiProperty({
description: 'User unique identifier',
example: 'clxyz123abc456def',
})
id: string;
@ApiProperty({
description: 'User first name',
example: 'John',
})
firstName: string;
@ApiProperty({
description: 'User last name',
example: 'Doe',
})
lastName: string;
@ApiProperty({
description: 'User email address',
example: 'john.doe@example.com',
})
email: string;
@ApiProperty({
description: 'User role',
enum: UserRole,
example: UserRole.USER,
})
role: UserRole;
@ApiProperty({
description: 'User creation timestamp',
example: '2024-01-01T12:00:00.000Z',
})
createdAt: Date;
@ApiProperty({
description: 'User last update timestamp',
example: '2024-01-01T12:00:00.000Z',
})
updatedAt: Date;
}
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBody,
ApiBearerAuth,
} from '@nestjs/swagger';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { AdminChangePasswordDto } from './dto/admin-change-password.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@ApiTags('users')
@ApiBearerAuth('JWT-auth')
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
@ApiOperation({ summary: 'Create a new user' })
@ApiBody({ type: CreateUserDto })
@ApiResponse({
status: 201,
description: 'User created successfully',
type: UserResponseDto,
})
@ApiResponse({
status: 409,
description: 'User with this email already exists',
})
@ApiResponse({
status: 400,
description: 'Invalid input data',
})
async create(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
return this.userService.create(createUserDto);
}
@Get()
@ApiOperation({ summary: 'Get all users' })
@ApiResponse({
status: 200,
description: 'List of all users',
type: [UserResponseDto],
})
async findAll(): Promise<UserResponseDto[]> {
return this.userService.findAll();
}
@Get('count')
@ApiOperation({ summary: 'Get total number of users' })
@ApiResponse({
status: 200,
description: 'Total number of users',
schema: {
type: 'object',
properties: {
count: {
type: 'number',
example: 42,
},
},
},
})
async getCount(): Promise<{ count: number }> {
const count = await this.userService.count();
return { count };
}
@Get(':id')
@ApiOperation({ summary: 'Get user by ID' })
@ApiParam({
name: 'id',
description: 'User unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 200,
description: 'User found',
type: UserResponseDto,
})
@ApiResponse({
status: 404,
description: 'User not found',
})
async findOne(@Param('id') id: string): Promise<UserResponseDto> {
return this.userService.findOne(id);
}
@Get('email/:email')
@ApiOperation({ summary: 'Get user by email' })
@ApiParam({
name: 'email',
description: 'User email address',
example: 'john.doe@example.com',
})
@ApiResponse({
status: 200,
description: 'User found',
type: UserResponseDto,
})
@ApiResponse({
status: 404,
description: 'User not found',
})
async findByEmail(
@Param('email') email: string,
): Promise<UserResponseDto | null> {
return this.userService.findByEmail(email);
}
@Patch(':id')
@ApiOperation({ summary: 'Update user by ID' })
@ApiParam({
name: 'id',
description: 'User unique identifier',
example: 'clxyz123abc456def',
})
@ApiBody({ type: UpdateUserDto })
@ApiResponse({
status: 200,
description: 'User updated successfully',
type: UserResponseDto,
})
@ApiResponse({
status: 404,
description: 'User not found',
})
@ApiResponse({
status: 409,
description: 'User with this email already exists',
})
@ApiResponse({
status: 400,
description: 'Invalid input data',
})
async update(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
): Promise<UserResponseDto> {
return this.userService.update(id, updateUserDto);
}
@Patch('change-password')
@ApiOperation({ summary: 'Change current user password' })
@ApiBody({ type: ChangePasswordDto })
@ApiResponse({
status: 200,
description: 'Password changed successfully',
schema: {
type: 'object',
properties: {
message: {
type: 'string',
example: 'Password changed successfully',
},
},
},
})
@ApiResponse({
status: 401,
description: 'Current password is incorrect',
})
@ApiResponse({
status: 400,
description: 'Invalid input data',
})
async changePassword(
@CurrentUser() user: UserResponseDto,
@Body() changePasswordDto: ChangePasswordDto,
): Promise<{ message: string }> {
return this.userService.changePassword(user.id, changePasswordDto);
}
@Patch(':id/admin-change-password')
@ApiOperation({ summary: 'Admin: Change any user password' })
@ApiParam({
name: 'id',
description: 'User unique identifier',
example: 'clxyz123abc456def',
})
@ApiBody({ type: AdminChangePasswordDto })
@ApiResponse({
status: 200,
description: 'Password changed successfully by admin',
schema: {
type: 'object',
properties: {
message: {
type: 'string',
example: 'Password changed successfully by admin',
},
},
},
})
@ApiResponse({
status: 404,
description: 'User not found',
})
@ApiResponse({
status: 400,
description: 'Invalid input data',
})
async adminChangePassword(
@Param('id') id: string,
@Body() adminChangePasswordDto: AdminChangePasswordDto,
): Promise<{ message: string }> {
return this.userService.adminChangePassword(id, adminChangePasswordDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete user by ID' })
@ApiParam({
name: 'id',
description: 'User unique identifier',
example: 'clxyz123abc456def',
})
@ApiResponse({
status: 204,
description: 'User deleted successfully',
})
@ApiResponse({
status: 404,
description: 'User not found',
})
async remove(@Param('id') id: string): Promise<void> {
return this.userService.remove(id);
}
}
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { PrismaService } from '../../common/prisma.service';
@Module({
controllers: [UserController],
providers: [UserService, PrismaService],
exports: [UserService], // Export service for use in other modules
})
export class UserModule {}
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { AdminChangePasswordDto } from './dto/admin-change-password.dto';
import * as bcrypt from 'bcrypt';
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) {}
async create(createUserDto: CreateUserDto): Promise<UserResponseDto> {
// Check if user with email already exists
const existingUser = await this.prisma.user.findUnique({
where: { email: createUserDto.email },
});
if (existingUser) {
throw new ConflictException('User with this email already exists');
}
// Hash password
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
// Create user
const user = await this.prisma.user.create({
data: {
...createUserDto,
password: hashedPassword,
},
select: {
id: true,
firstName: true,
lastName: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
},
});
return user;
}
async findAll(): Promise<UserResponseDto[]> {
const users = await this.prisma.user.findMany({
select: {
id: true,
firstName: true,
lastName: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
},
orderBy: {
createdAt: 'desc',
},
});
return users;
}
async findOne(id: string): Promise<UserResponseDto> {
const user = await this.prisma.user.findUnique({
where: { id },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
async findByEmail(email: string): Promise<UserResponseDto | null> {
const user = await this.prisma.user.findUnique({
where: { email },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
},
});
return user;
}
async update(
id: string,
updateUserDto: UpdateUserDto,
): Promise<UserResponseDto> {
// Check if user exists
await this.findOne(id);
// If email is being updated, check for conflicts
if (updateUserDto.email) {
const existingUser = await this.prisma.user.findUnique({
where: { email: updateUserDto.email },
});
if (existingUser && existingUser.id !== id) {
throw new ConflictException('User with this email already exists');
}
}
// Hash password if provided
const updateData = { ...updateUserDto };
if (updateUserDto.password) {
updateData.password = await bcrypt.hash(updateUserDto.password, 10);
}
const user = await this.prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
firstName: true,
lastName: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
},
});
return user;
}
async remove(id: string): Promise<void> {
// Check if user exists
await this.findOne(id);
await this.prisma.user.delete({
where: { id },
});
}
async count(): Promise<number> {
return this.prisma.user.count();
}
async changePassword(
userId: string,
changePasswordDto: ChangePasswordDto,
): Promise<{ message: string }> {
// Get user with password for verification
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
password: true,
},
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
// Verify current password
const isCurrentPasswordValid = await bcrypt.compare(
changePasswordDto.currentPassword,
user.password,
);
if (!isCurrentPasswordValid) {
throw new UnauthorizedException('Current password is incorrect');
}
// Hash new password
const hashedNewPassword = await bcrypt.hash(
changePasswordDto.newPassword,
10,
);
// Update password
await this.prisma.user.update({
where: { id: userId },
data: { password: hashedNewPassword },
});
return { message: 'Password changed successfully' };
}
async adminChangePassword(
userId: string,
adminChangePasswordDto: AdminChangePasswordDto,
): Promise<{ message: string }> {
// Check if user exists
await this.findOne(userId);
// Hash new password
const hashedNewPassword = await bcrypt.hash(
adminChangePasswordDto.newPassword,
10,
);
// Update password
await this.prisma.user.update({
where: { id: userId },
data: { password: hashedNewPassword },
});
return { message: 'Password changed successfully by admin' };
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment