Commit e9c035ff by Augusto

inspection schuedule

parent 9430dc5a
# Automatic Inspection Scheduling
This feature automatically creates new inspections for sites that need them, based on the annual inspection cycle.
## How it works
1. **Annual Cycle**: Each site should be inspected annually
2. **3-Month Advance Notice**: New inspections are automatically created 3 months before the 1-year anniversary of the last inspection
3. **Automatic Creation**: The system creates inspections with default "NA" (Not Applicable) responses that need to be completed by inspectors
## Features
### Manual Endpoints
#### Check Sites Needing Inspections
```
GET /api/inspection/scheduling/check
```
Returns a list of sites that need new inspections within the next 3 months.
**Response:**
```json
[
{
"siteId": 1,
"siteCode": "SITE001",
"siteName": "Site Name",
"lastInspectionDate": "2024-05-21T13:00:00.000Z",
"nextInspectionDate": "2025-05-21T13:00:00.000Z"
}
]
```
#### Create Automatic Inspections
```
POST /api/inspection/scheduling/create
```
Automatically creates new inspections for all sites that need them.
**Response:**
```json
{
"created": 5,
"sites": [
{
"siteId": 1,
"siteCode": "SITE001",
"siteName": "Site Name",
"inspectionId": 123
}
]
}
```
### Scheduled Tasks
#### Daily Check (6:00 AM)
- Automatically checks for sites needing inspections
- Creates new inspections if needed
- Logs the results
#### Weekly Check (Monday 8:00 AM)
- Backup check to ensure no sites are missed
- Logs sites that still need inspections
- Does not create inspections automatically
## Logic
### When a site needs an inspection:
1. **Never inspected**: Site has no previous inspections
2. **Annual anniversary**: 1 year has passed since the last inspection
3. **3-month advance**: The 1-year anniversary is within the next 3 months
### Example timeline:
- **May 2024**: Site is inspected
- **February 2025**: System detects that the 1-year anniversary (May 2025) is within 3 months
- **February 2025**: Automatic inspection is created with "NA" responses
- **May 2025**: Inspector should complete the inspection
## Permissions
- **Check endpoint**: ADMIN, MANAGER, OPERATOR
- **Create endpoint**: ADMIN, MANAGER, OPERATOR
- **Scheduled tasks**: Run automatically without authentication
## Configuration
The scheduling is configured in `src/modules/inspection/inspection-scheduler.service.ts`:
- Daily check: `CronExpression.EVERY_DAY_AT_6AM`
- Weekly check: `0 8 * * 1` (Monday 8:00 AM)
## Monitoring
Check the application logs for:
- Daily inspection scheduling results
- Weekly inspection check results
- Any errors during the process
## Manual Override
If you need to manually trigger the process:
1. Use the check endpoint to see what sites need inspections
2. Use the create endpoint to manually create inspections
3. The scheduled tasks will continue to run automatically
## Notes
- Inspections are created with the current date and status "PENDING"
- All questions are set to "NA" (Not Applicable) by default
- Comments indicate the inspection was automatically generated
- Inspectors need to complete the responses and add photos
- The system logs all activities for monitoring
## Inspection Status
Inspections now have a status field to track their lifecycle:
- **PENDING**: Inspection is created but not yet started (default for automatic inspections)
- **IN_PROGRESS**: Inspection is currently being performed
- **COMPLETED**: Inspection has been finished with all responses
- **CANCELLED**: Inspection was cancelled and won't be completed
### Status Management
- **Update Status**: Use `PATCH /api/inspection/{id}/status` to change the status
- **Filter by Status**: Use the `status` query parameter in `GET /api/inspection` to filter inspections
- **Automatic Status**: New inspections created automatically start with "PENDING" status
# Real-time Inspection Collaboration with WebSockets
This feature enables multiple inspectors to work on the same inspection simultaneously in real-time, seeing each other's updates instantly.
## Features
### Real-time Collaboration
- **Live Updates**: When one inspector updates a response, all other inspectors see it immediately
- **Status Synchronization**: Inspection status changes are broadcast to all connected users
- **User Presence**: See who else is currently working on the inspection
- **Typing Indicators**: Know when someone is typing a response
- **Room Management**: Automatic cleanup of empty inspection rooms
### WebSocket Events
#### Client to Server Events
| Event | Description | Payload |
| ----------------- | ------------------------ | --------------------------------------------------------------------------------- |
| `joinInspection` | Join an inspection room | `{ inspectionId: number }` |
| `leaveInspection` | Leave an inspection room | `{ inspectionId: number }` |
| `updateResponse` | Update a response | `{ inspectionId: number, response: CreateInspectionResponseDto, userId: number }` |
| `updateStatus` | Update inspection status | `{ inspectionId: number, status: string, userId: number }` |
| `typing` | Send typing indicator | `{ inspectionId: number, questionId: number, isTyping: boolean }` |
| `getActiveUsers` | Get list of active users | `{ inspectionId: number }` |
#### Server to Client Events
| Event | Description | Payload |
| ----------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
| `inspectionData` | Current inspection data | `InspectionDto` |
| `responseUpdated` | Response was updated | `{ inspectionId: number, response: InspectionResponseDto, updatedBy: string, timestamp: Date }` |
| `statusUpdated` | Status was updated | `{ inspectionId: number, status: string, updatedBy: string, timestamp: Date }` |
| `userJoined` | User joined the room | `{ userId: string, inspectionId: number, timestamp: Date }` |
| `userLeft` | User left the room | `{ userId: string, inspectionId: number, timestamp: Date }` |
| `userTyping` | User is typing | `{ userId: string, inspectionId: number, questionId: number, isTyping: boolean, timestamp: Date }` |
| `activeUsers` | List of active users | `{ inspectionId: number, users: string[], count: number, timestamp: Date }` |
| `error` | Error occurred | `{ message: string, details?: string }` |
## Connection
### WebSocket URL
```
ws://localhost:3000/inspection
```
### Authentication
Connect with JWT token in the auth object:
```javascript
const socket = io('http://localhost:3000/inspection', {
auth: {
token: 'your-jwt-token-here',
},
});
```
## Usage Examples
### JavaScript/TypeScript Client
```javascript
import { io } from 'socket.io-client';
// Connect to WebSocket
const socket = io('http://localhost:3000/inspection', {
auth: {
token: 'your-jwt-token',
},
});
// Join an inspection
socket.emit('joinInspection', { inspectionId: 123 });
// Listen for updates
socket.on('responseUpdated', (data) => {
console.log('Response updated:', data.response);
// Update your UI here
});
socket.on('statusUpdated', (data) => {
console.log('Status updated:', data.status);
// Update status in UI
});
socket.on('userJoined', (data) => {
console.log('User joined:', data.userId);
// Show user joined notification
});
// Update a response
socket.emit('updateResponse', {
inspectionId: 123,
response: {
questionId: 1,
response: 'YES',
comment: 'Equipment is working properly',
},
userId: 1,
});
// Update status
socket.emit('updateStatus', {
inspectionId: 123,
status: 'IN_PROGRESS',
userId: 1,
});
// Send typing indicator
socket.emit('typing', {
inspectionId: 123,
questionId: 1,
isTyping: true,
});
// Get active users
socket.emit('getActiveUsers', { inspectionId: 123 });
socket.on('activeUsers', (data) => {
console.log('Active users:', data.users);
});
```
### React Hook Example
```javascript
import { useEffect, useState } from 'react';
import { io } from 'socket.io-client';
export const useInspectionWebSocket = (inspectionId, token) => {
const [socket, setSocket] = useState(null);
const [responses, setResponses] = useState([]);
const [status, setStatus] = useState('PENDING');
const [activeUsers, setActiveUsers] = useState([]);
useEffect(() => {
if (!inspectionId || !token) return;
const newSocket = io('http://localhost:3000/inspection', {
auth: { token },
});
newSocket.emit('joinInspection', { inspectionId });
newSocket.on('inspectionData', (data) => {
setResponses(data.responses);
setStatus(data.status);
});
newSocket.on('responseUpdated', (data) => {
setResponses((prev) =>
prev.map((r) => (r.id === data.response.id ? data.response : r)),
);
});
newSocket.on('statusUpdated', (data) => {
setStatus(data.status);
});
newSocket.on('activeUsers', (data) => {
setActiveUsers(data.users);
});
setSocket(newSocket);
return () => {
newSocket.emit('leaveInspection', { inspectionId });
newSocket.disconnect();
};
}, [inspectionId, token]);
const updateResponse = (response) => {
if (socket) {
socket.emit('updateResponse', {
inspectionId,
response,
userId: 1, // Get from auth context
});
}
};
const updateStatus = (newStatus) => {
if (socket) {
socket.emit('updateStatus', {
inspectionId,
status: newStatus,
userId: 1, // Get from auth context
});
}
};
return {
responses,
status,
activeUsers,
updateResponse,
updateStatus,
};
};
```
## Security
- **JWT Authentication**: All WebSocket connections require valid JWT tokens
- **Room Isolation**: Users can only access inspection rooms they have permission for
- **Input Validation**: All incoming data is validated before processing
- **Error Handling**: Comprehensive error handling and logging
## Performance
- **Room Cleanup**: Empty rooms are automatically cleaned up after 5 minutes
- **Efficient Broadcasting**: Updates are only sent to users in the relevant inspection room
- **Connection Management**: Proper handling of connections and disconnections
## Testing
Use the provided `websocket-client-example.html` file to test the WebSocket functionality:
1. Start your NestJS application
2. Open the HTML file in a browser
3. Enter your JWT token
4. Join an inspection room
5. Test real-time updates with multiple browser tabs
## Integration with Frontend
The WebSocket gateway integrates seamlessly with your existing REST API:
- **REST API**: For initial data loading and non-real-time operations
- **WebSocket**: For real-time collaboration and live updates
- **Hybrid Approach**: Use REST for CRUD operations, WebSocket for live updates
## Troubleshooting
### Common Issues
1. **Connection Failed**: Check if the server is running and the token is valid
2. **Updates Not Received**: Ensure you're in the correct inspection room
3. **Authentication Errors**: Verify your JWT token is valid and not expired
### Debug Mode
Enable debug logging by setting the log level in your NestJS application:
```typescript
// In main.ts
const app = await NestFactory.create(AppModule, {
logger: ['debug', 'error', 'warn', 'log'],
});
```
## Room Management
- **Automatic Join**: Users automatically join inspection-specific rooms
- **Presence Tracking**: System tracks who is currently in each inspection
- **Cleanup**: Empty rooms are automatically cleaned up to prevent memory leaks
- **Activity Logging**: All room activities are logged for debugging
......@@ -28,7 +28,10 @@
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.0",
"@nestjs/platform-socket.io": "^11.1.3",
"@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.1.1",
"@nestjs/websockets": "^11.1.3",
"@prisma/client": "^6.9.0",
"@types/nodemailer": "^6.4.17",
"@types/uuid": "^10.0.0",
......@@ -47,6 +50,7 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.1",
"socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0",
"xlsx": "^0.18.5"
......
-- Add InspectionStatus enum
CREATE TYPE "InspectionStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED');
-- Add status column to Inspection table with default value
ALTER TABLE "Inspection" ADD COLUMN "status" "InspectionStatus" NOT NULL DEFAULT 'PENDING';
-- Add index on status column
CREATE INDEX "Inspection_status_idx" ON "Inspection"("status");
\ No newline at end of file
-- Add new inspection statuses
ALTER TYPE "InspectionStatus" ADD VALUE 'APPROVING';
ALTER TYPE "InspectionStatus" ADD VALUE 'REJECTED';
ALTER TYPE "InspectionStatus" ADD VALUE 'APPROVED';
\ No newline at end of file
......@@ -20,17 +20,17 @@ model User {
resetToken String?
resetTokenExpiry DateTime?
isActive Boolean @default(false)
partnerId Int?
candidatesCreated Candidate[] @relation("CandidateCreator")
candidatesUpdated Candidate[] @relation("CandidateUpdater")
Comment Comment[]
inspectionsCreated Inspection[] @relation("InspectionCreator")
inspectionsUpdated Inspection[] @relation("InspectionUpdater")
refreshTokens RefreshToken[]
sitesCreated Site[] @relation("SiteCreator")
sitesUpdated Site[] @relation("SiteUpdater")
inspectionsCreated Inspection[] @relation("InspectionCreator")
inspectionsUpdated Inspection[] @relation("InspectionUpdater")
associatedSites UserSite[] // New relation for PARTNER role
partner Partner? @relation(fields: [partnerId], references: [id])
partnerId Int?
associatedSites UserSite[]
@@index([email])
@@index([role])
......@@ -64,10 +64,10 @@ model Site {
createdById Int?
updatedById Int?
candidates CandidateSite[]
inspections Inspection[]
createdBy User? @relation("SiteCreator", fields: [createdById], references: [id])
updatedBy User? @relation("SiteUpdater", fields: [updatedById], references: [id])
partners UserSite[] // New relation for PARTNER role
inspections Inspection[]
partners UserSite[]
@@index([siteCode])
}
......@@ -85,13 +85,13 @@ model Candidate {
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
partnerId Int?
createdBy User? @relation("CandidateCreator", fields: [createdById], references: [id])
partner Partner? @relation(fields: [partnerId], references: [id])
updatedBy User? @relation("CandidateUpdater", fields: [updatedById], references: [id])
sites CandidateSite[]
comments Comment[]
photos Photo[]
partnerId Int? // To track which partner created the candidate
partner Partner? @relation(fields: [partnerId], references: [id])
@@index([candidateCode])
@@index([currentStatus])
......@@ -156,30 +156,14 @@ model UserSite {
siteId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, siteId])
@@index([userId])
@@index([siteId])
}
enum CompanyName {
VODAFONE
MEO
NOS
DIGI
}
enum Role {
ADMIN
MANAGER
OPERATOR
VIEWER
SUPERADMIN
PARTNER
}
model Partner {
id Int @id @default(autoincrement())
name String @unique
......@@ -187,18 +171,12 @@ model Partner {
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
candidates Candidate[]
users User[]
@@index([name])
}
enum InspectionResponseOption {
YES
NO
NA
}
model InspectionQuestion {
id Int @id @default(autoincrement())
question String
......@@ -216,8 +194,8 @@ model InspectionResponse {
inspectionId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
question InspectionQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
inspection Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
question InspectionQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
@@index([questionId])
@@index([inspectionId])
......@@ -232,15 +210,17 @@ model Inspection {
updatedAt DateTime @updatedAt
createdById Int?
updatedById Int?
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
status InspectionStatus @default(PENDING)
createdBy User? @relation("InspectionCreator", fields: [createdById], references: [id])
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
updatedBy User? @relation("InspectionUpdater", fields: [updatedById], references: [id])
responses InspectionResponse[]
photos InspectionPhoto[]
responses InspectionResponse[]
@@index([siteId])
@@index([createdById])
@@index([updatedById])
@@index([status])
}
model InspectionPhoto {
......@@ -256,3 +236,35 @@ model InspectionPhoto {
@@index([inspectionId])
}
enum CompanyName {
VODAFONE
MEO
NOS
DIGI
}
enum Role {
ADMIN
MANAGER
OPERATOR
VIEWER
SUPERADMIN
PARTNER
}
enum InspectionResponseOption {
YES
NO
NA
}
enum InspectionStatus {
PENDING
IN_PROGRESS
COMPLETED
CANCELLED
APPROVING
REJECTED
APPROVED
}
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './common/prisma/prisma.module';
......@@ -22,6 +23,7 @@ import { InspectionModule } from './modules/inspection/inspection.module';
ConfigModule.forRoot({
isGlobal: true,
}),
ScheduleModule.forRoot(),
MailerModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (config: ConfigService) => ({
......
......@@ -64,7 +64,7 @@ async function bootstrap() {
SwaggerModule.setup('docs', app, document, swaggerOptions);
const port = process.env.PORT ?? 3004;
const port = process.env.PORT ?? 3002;
await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`);
console.log(
......
import { CanActivate, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';
@Injectable()
export class WsJwtAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: any): Promise<boolean> {
try {
const client: Socket = context.switchToWs().getClient();
const token = this.extractTokenFromHeader(client);
if (!token) {
throw new WsException('Unauthorized access');
}
const payload = await this.jwtService.verifyAsync(token, {
secret: process.env.JWT_SECRET,
});
// Attach the user to the socket for later use
client.data.user = payload;
return true;
} catch (error) {
throw new WsException('Unauthorized access');
}
}
private extractTokenFromHeader(client: Socket): string | undefined {
const auth =
client.handshake.auth?.token || client.handshake.headers?.authorization;
if (!auth) {
return undefined;
}
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
return auth.substring(7);
}
return auth;
}
}
import { IsString, IsNotEmpty, IsInt, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
export class CreateCommentDto {
......@@ -14,6 +15,7 @@ export class CreateCommentDto {
description: 'The ID of the candidate this comment is for',
example: 64,
})
@Type(() => Number)
@IsInt()
@IsNotEmpty()
candidateId: number;
......@@ -24,6 +26,7 @@ export class CreateCommentDto {
example: 1,
required: false,
})
@Type(() => Number)
@IsInt()
@IsOptional()
createdById?: number;
......
......@@ -53,6 +53,7 @@ export class CreateInspectionDto {
example: 1,
type: Number,
})
@Type(() => Number)
@IsInt()
siteId: number;
......
import { IsDateString, IsInt, IsOptional } from 'class-validator';
import { IsDateString, IsInt, IsOptional, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { InspectionStatus } from '@prisma/client';
export class FindInspectionDto {
@ApiPropertyOptional({
......@@ -7,6 +9,7 @@ export class FindInspectionDto {
example: 1,
type: Number,
})
@Type(() => Number)
@IsInt()
@IsOptional()
siteId?: number;
......@@ -30,4 +33,14 @@ export class FindInspectionDto {
@IsDateString()
@IsOptional()
endDate?: string;
@ApiPropertyOptional({
description: 'Filter inspection records by status',
enum: InspectionStatus,
example: InspectionStatus.PENDING,
enumName: 'InspectionStatus',
})
@IsEnum(InspectionStatus)
@IsOptional()
status?: InspectionStatus;
}
import { InspectionResponseOption } from './inspection-response-option.enum';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
// Import the status enum from Prisma
import { InspectionStatus } from '@prisma/client';
export class InspectionQuestionDto {
@ApiProperty({
description: 'Unique identifier of the inspection question',
......@@ -107,6 +110,14 @@ export class InspectionDto {
siteId: number;
@ApiProperty({
description: 'Current status of the inspection',
enum: InspectionStatus,
example: InspectionStatus.PENDING,
enumName: 'InspectionStatus',
})
status: InspectionStatus;
@ApiProperty({
description: 'Date and time when the record was created',
example: '2025-05-21T13:15:30.000Z',
type: Date,
......
import { IsInt, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class StartInspectionDto {
@ApiProperty({
description: 'ID of the inspection to start',
example: 1,
type: Number,
})
@IsInt()
inspectionId: number;
@ApiPropertyOptional({
description: 'Optional comment when starting the inspection',
example: 'Starting annual inspection with team',
type: String,
})
@IsOptional()
comment?: string;
}
import { IsEnum } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { InspectionStatus } from '@prisma/client';
export class UpdateInspectionStatusDto {
@ApiProperty({
description: 'New status for the inspection',
enum: InspectionStatus,
example: InspectionStatus.IN_PROGRESS,
enumName: 'InspectionStatus',
})
@IsEnum(InspectionStatus)
status: InspectionStatus;
}
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InspectionService } from './inspection.service';
@Injectable()
export class InspectionSchedulerService {
private readonly logger = new Logger(InspectionSchedulerService.name);
constructor(private readonly inspectionService: InspectionService) {}
/**
* Daily scheduled task to check for sites needing inspections
* Runs every day at 6:00 AM
*/
@Cron(CronExpression.EVERY_DAY_AT_6AM)
async handleAutomaticInspections() {
this.logger.log('Starting daily inspection scheduling check...');
try {
// Check for sites that need inspections
const sitesNeedingInspections =
await this.inspectionService.checkSitesNeedingInspections();
if (sitesNeedingInspections.length === 0) {
this.logger.log('No sites need new inspections at this time.');
return;
}
this.logger.log(
`Found ${sitesNeedingInspections.length} sites that need new inspections.`,
);
// Create automatic inspections
const result = await this.inspectionService.createAutomaticInspections();
this.logger.log(
`Successfully created ${result.created} automatic inspections.`,
);
// Log details of created inspections
for (const site of result.sites) {
this.logger.log(
`Created inspection ${site.inspectionId} for site ${site.siteCode} (${site.siteName})`,
);
}
} catch (error) {
this.logger.error('Error during automatic inspection scheduling:', error);
}
}
/**
* Weekly scheduled task to check for sites needing inspections (backup)
* Runs every Monday at 8:00 AM
*/
@Cron('0 8 * * 1')
async handleWeeklyInspectionCheck() {
this.logger.log('Starting weekly inspection scheduling check...');
try {
const sitesNeedingInspections =
await this.inspectionService.checkSitesNeedingInspections();
if (sitesNeedingInspections.length > 0) {
this.logger.log(
`Weekly check: ${sitesNeedingInspections.length} sites still need inspections.`,
);
// Log details of sites that need inspections
for (const site of sitesNeedingInspections) {
this.logger.log(
`Site ${site.siteCode} (${site.siteName}) needs inspection by ${site.nextInspectionDate.toISOString().split('T')[0]}`,
);
}
} else {
this.logger.log(
'Weekly check: All sites are up to date with inspections.',
);
}
} catch (error) {
this.logger.error('Error during weekly inspection check:', error);
}
}
}
......@@ -4,6 +4,7 @@ import {
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
Req,
......@@ -22,6 +23,8 @@ import {
CreateInspectionResponseDto,
} from './dto/create-inspection.dto';
import { FindInspectionDto } from './dto/find-inspection.dto';
import { UpdateInspectionStatusDto } from './dto/update-inspection-status.dto';
import { StartInspectionDto } from './dto/start-inspection.dto';
import { multerConfig } from '../../common/multer/multer.config';
import {
ApiTags,
......@@ -48,7 +51,7 @@ export class InspectionController {
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@UseInterceptors(FilesInterceptor('photos', 10, multerConfig))
@ApiOperation({
summary: 'Create a new inspection record',
......@@ -157,6 +160,21 @@ export class InspectionController {
description: 'Filter by end date (inclusive)',
example: '2025-12-31T23:59:59.999Z',
})
@ApiQuery({
name: 'status',
required: false,
enum: [
'PENDING',
'IN_PROGRESS',
'COMPLETED',
'CANCELLED',
'APPROVING',
'REJECTED',
'APPROVED',
],
description: 'Filter by inspection status',
example: 'PENDING',
})
async findAllInspection(@Query() findInspectionDto: FindInspectionDto) {
return this.inspectionService.findAllInspection(findInspectionDto);
}
......@@ -251,7 +269,7 @@ export class InspectionController {
@Post(':id/responses')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@ApiOperation({
summary: 'Add responses to an existing inspection record',
description:
......@@ -293,4 +311,225 @@ export class InspectionController {
req.user.id,
);
}
@Get('scheduling/check')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN)
@ApiOperation({
summary: 'Check for sites that need new inspections',
description:
'Checks for sites that need new inspections (3 months before the 1-year anniversary of their last inspection). Returns a list of sites that need inspections.',
})
@ApiResponse({
status: 200,
description: 'List of sites that need new inspections.',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
siteId: { type: 'number', example: 1 },
siteCode: { type: 'string', example: 'SITE001' },
siteName: { type: 'string', example: 'Site Name' },
lastInspectionDate: {
type: 'string',
format: 'date-time',
nullable: true,
example: '2024-05-21T13:00:00.000Z',
},
nextInspectionDate: {
type: 'string',
format: 'date-time',
example: '2025-05-21T13:00:00.000Z',
},
},
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
async checkSitesNeedingInspections() {
return this.inspectionService.checkSitesNeedingInspections();
}
@Post('scheduling/create')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN)
@ApiOperation({
summary: 'Automatically create new inspections for sites that need them',
description:
'Automatically creates new inspections for sites that need them (3 months before the 1-year anniversary). Creates inspections with default "NA" responses that need to be completed.',
})
@ApiResponse({
status: 201,
description: 'Automatic inspections have been created.',
schema: {
type: 'object',
properties: {
created: {
type: 'number',
example: 5,
description: 'Number of inspections created',
},
sites: {
type: 'array',
items: {
type: 'object',
properties: {
siteId: { type: 'number', example: 1 },
siteCode: { type: 'string', example: 'SITE001' },
siteName: { type: 'string', example: 'Site Name' },
inspectionId: { type: 'number', example: 123 },
},
},
},
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
async createAutomaticInspections(@Req() req) {
return this.inspectionService.createAutomaticInspections(req.user.id);
}
@Patch(':id/status')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@ApiOperation({
summary: 'Update the status of an inspection',
description:
'Updates the status of an inspection (PENDING, IN_PROGRESS, COMPLETED, CANCELLED).',
})
@ApiResponse({
status: 200,
description: 'The inspection status has been successfully updated.',
type: InspectionDto,
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({ status: 404, description: 'Inspection not found.' })
@ApiParam({
name: 'id',
type: Number,
description: 'ID of the inspection to update',
example: 1,
})
@ApiBody({
description: 'New status for the inspection',
type: UpdateInspectionStatusDto,
})
async updateInspectionStatus(
@Param('id', ParseIntPipe) id: number,
@Body() updateStatusDto: UpdateInspectionStatusDto,
@Req() req,
) {
return this.inspectionService.updateInspectionStatus(
id,
updateStatusDto.status,
req.user.id,
);
}
@Post('redo-rejected/:siteId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@ApiOperation({
summary: 'Redo a rejected inspection for a site',
description:
'Creates a new PENDING inspection for a site that has a REJECTED inspection. Only users with ADMIN, MANAGER, OPERATOR, or PARTNER roles can redo inspections.',
})
@ApiResponse({
status: 201,
description: 'The new inspection has been successfully created.',
type: InspectionDto,
})
@ApiResponse({ status: 400, description: 'Invalid input data.' })
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({
status: 404,
description: 'Site not found or no rejected inspection found.',
})
@ApiParam({
name: 'siteId',
description: 'ID of the site to redo inspection for',
type: Number,
example: 1,
})
@ApiBody({
description: 'Optional comment for the redo inspection',
schema: {
type: 'object',
properties: {
comment: {
type: 'string',
example: 'Redoing inspection due to previous rejection',
description:
'Optional comment explaining why the inspection is being redone',
},
},
},
})
async redoRejectedInspection(
@Param('siteId', ParseIntPipe) siteId: number,
@Body() body: { comment?: string },
@Req() req,
) {
return this.inspectionService.redoRejectedInspection(
siteId,
req.user.id,
body.comment,
);
}
@Post('start')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.PARTNER, Role.SUPERADMIN)
@ApiOperation({
summary: 'Start a pending inspection',
description:
'Starts a pending inspection by changing its status to IN_PROGRESS. Only PENDING inspections can be started.',
})
@ApiResponse({
status: 201,
description: 'The inspection has been successfully started.',
type: InspectionDto,
})
@ApiResponse({
status: 400,
description: 'Invalid input data or inspection cannot be started.',
})
@ApiResponse({ status: 401, description: 'Unauthorized.' })
@ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions.',
})
@ApiResponse({ status: 404, description: 'Inspection not found.' })
@ApiBody({
description: 'Inspection start data',
type: StartInspectionDto,
})
async startInspection(
@Body() startInspectionDto: StartInspectionDto,
@Req() req,
) {
return this.inspectionService.startInspection(
startInspectionDto.inspectionId,
req.user.id,
startInspectionDto.comment,
);
}
}
import { Module } from '@nestjs/common';
import { InspectionController } from './inspection.controller';
import { InspectionService } from './inspection.service';
import { InspectionSchedulerService } from './inspection-scheduler.service';
import { InspectionGateway } from './inspection.gateway';
import { PrismaModule } from '../../common/prisma/prisma.module';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [PrismaModule],
imports: [
PrismaModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '1d' },
}),
],
controllers: [InspectionController],
providers: [InspectionService],
providers: [InspectionService, InspectionSchedulerService, InspectionGateway],
exports: [InspectionService],
})
export class InspectionModule {}
......@@ -107,6 +107,10 @@ export class InspectionService {
filter.siteId = dto.siteId;
}
if (dto.status) {
filter.status = dto.status;
}
if (dto.startDate || dto.endDate) {
filter.date = {};
......@@ -312,12 +316,319 @@ export class InspectionService {
}));
}
/**
* Check for sites that need new inspections (3 months before 1-year anniversary)
*/
async checkSitesNeedingInspections(): Promise<
{
siteId: number;
siteCode: string;
siteName: string;
lastInspectionDate: Date | null;
nextInspectionDate: Date;
}[]
> {
const threeMonthsFromNow = new Date();
threeMonthsFromNow.setMonth(threeMonthsFromNow.getMonth() + 3);
// Get all sites with their latest inspection
const sitesWithLatestInspection = await this.prisma.site.findMany({
include: {
inspections: {
orderBy: {
date: 'desc',
},
take: 1,
},
},
});
const sitesNeedingInspections: {
siteId: number;
siteCode: string;
siteName: string;
lastInspectionDate: Date | null;
nextInspectionDate: Date;
}[] = [];
for (const site of sitesWithLatestInspection) {
if (site.inspections.length === 0) {
// Site has never been inspected, add it to the list
sitesNeedingInspections.push({
siteId: site.id,
siteCode: site.siteCode,
siteName: site.siteName,
lastInspectionDate: null,
nextInspectionDate: new Date(), // Should be inspected now
});
continue;
}
const lastInspection = site.inspections[0];
const oneYearAfterLastInspection = new Date(lastInspection.date);
oneYearAfterLastInspection.setFullYear(
oneYearAfterLastInspection.getFullYear() + 1,
);
// Check if the next inspection date is within 3 months
if (oneYearAfterLastInspection <= threeMonthsFromNow) {
sitesNeedingInspections.push({
siteId: site.id,
siteCode: site.siteCode,
siteName: site.siteName,
lastInspectionDate: lastInspection.date,
nextInspectionDate: oneYearAfterLastInspection,
});
}
}
return sitesNeedingInspections;
}
/**
* Automatically create new inspections for sites that need them
*/
async createAutomaticInspections(userId?: number): Promise<{
created: number;
sites: {
siteId: number;
siteCode: string;
siteName: string;
inspectionId: number;
}[];
}> {
const sitesNeedingInspections = await this.checkSitesNeedingInspections();
const createdInspections: {
siteId: number;
siteCode: string;
siteName: string;
inspectionId: number;
}[] = [];
for (const site of sitesNeedingInspections) {
try {
// Get all inspection questions
const questions = await this.prisma.inspectionQuestion.findMany({
orderBy: {
orderIndex: 'asc',
},
});
// Create empty responses for all questions
const responses = questions.map((question) => ({
questionId: question.id,
response: 'NA' as any, // Default to NA (Not Applicable)
comment: 'Automatically generated inspection - pending completion',
}));
// Create the inspection
const inspection = await this.prisma.inspection.create({
data: {
date: new Date(), // Set to current date
comment: `Automatic inspection created for annual review. Last inspection: ${site.lastInspectionDate ? site.lastInspectionDate.toISOString().split('T')[0] : 'Never'}`,
status: 'PENDING', // Set status to PENDING for automatic inspections
site: { connect: { id: site.siteId } },
createdBy: userId ? { connect: { id: userId } } : undefined,
responses: {
create: responses.map((response) => ({
response: response.response,
comment: response.comment,
question: { connect: { id: response.questionId } },
})),
},
},
});
createdInspections.push({
siteId: site.siteId,
siteCode: site.siteCode,
siteName: site.siteName,
inspectionId: inspection.id,
});
} catch (error) {
console.error(
`Error creating automatic inspection for site ${site.siteCode}:`,
error,
);
}
}
return {
created: createdInspections.length,
sites: createdInspections,
};
}
/**
* Start an inspection (change status from PENDING to IN_PROGRESS)
*/
async startInspection(
inspectionId: number,
userId: number,
comment?: string,
): Promise<InspectionDto> {
const inspection = await this.prisma.inspection.findUnique({
where: { id: inspectionId },
});
if (!inspection) {
throw new NotFoundException(
`Inspection with ID ${inspectionId} not found`,
);
}
if (inspection.status !== 'PENDING') {
throw new Error(
`Cannot start inspection. Current status is ${inspection.status}. Only PENDING inspections can be started.`,
);
}
const updatedInspection = await this.prisma.inspection.update({
where: { id: inspectionId },
data: {
status: 'IN_PROGRESS',
comment: comment
? `${inspection.comment || ''}\n\nStarted by user ${userId}: ${comment}`
: inspection.comment,
updatedBy: { connect: { id: userId } },
},
include: {
responses: {
include: {
question: true,
},
},
photos: true,
},
});
return this.mapToDto(updatedInspection);
}
/**
* Update the status of an inspection
*/
async updateInspectionStatus(
inspectionId: number,
status: any,
userId: number,
): Promise<InspectionDto> {
const inspection = await this.prisma.inspection.findUnique({
where: { id: inspectionId },
});
if (!inspection) {
throw new NotFoundException(
`Inspection with ID ${inspectionId} not found`,
);
}
const updatedInspection = await this.prisma.inspection.update({
where: { id: inspectionId },
data: {
status,
updatedBy: { connect: { id: userId } },
},
include: {
responses: {
include: {
question: true,
},
},
photos: true,
},
});
return this.mapToDto(updatedInspection);
}
/**
* Redo a rejected inspection by creating a new inspection for the same site
*/
async redoRejectedInspection(
siteId: number,
userId: number,
comment?: string,
): Promise<InspectionDto> {
// Check if site exists
const site = await this.prisma.site.findUnique({
where: { id: siteId },
});
if (!site) {
throw new NotFoundException(`Site with ID ${siteId} not found`);
}
// Get the latest inspection for this site
const latestInspection = await this.prisma.inspection.findFirst({
where: { siteId },
orderBy: { createdAt: 'desc' },
});
if (!latestInspection) {
throw new NotFoundException(
`No inspection found for site ${siteId}. Cannot redo inspection.`,
);
}
if (latestInspection.status !== 'REJECTED') {
throw new Error(
`Cannot redo inspection. Latest inspection status is ${latestInspection.status}. Only REJECTED inspections can be redone.`,
);
}
// Get all inspection questions
const questions = await this.prisma.inspectionQuestion.findMany({
orderBy: {
orderIndex: 'asc',
},
});
// Create empty responses for all questions
const responses = questions.map((question) => ({
questionId: question.id,
response: 'NA' as any, // Default to NA (Not Applicable)
comment: 'Redo inspection - pending completion',
}));
// Create the new inspection
const newInspection = await this.prisma.inspection.create({
data: {
date: new Date(),
comment: comment
? `Redo inspection created by user ${userId}: ${comment}\n\nPrevious inspection ID: ${latestInspection.id}`
: `Redo inspection created by user ${userId}. Previous inspection ID: ${latestInspection.id}`,
status: 'PENDING',
site: { connect: { id: siteId } },
createdBy: { connect: { id: userId } },
responses: {
create: responses.map((response) => ({
response: response.response,
comment: response.comment,
question: { connect: { id: response.questionId } },
})),
},
},
include: {
responses: {
include: {
question: true,
},
},
photos: true,
},
});
return this.mapToDto(newInspection);
}
private mapToDto(inspection: any): InspectionDto {
return {
id: inspection.id,
date: inspection.date,
comment: inspection.comment,
siteId: inspection.siteId,
status: inspection.status,
createdAt: inspection.createdAt,
updatedAt: inspection.updatedAt,
responses: inspection.responses.map((response) => ({
......
......@@ -8,6 +8,7 @@ import {
IsBoolean,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { InspectionStatus } from '@prisma/client';
export enum OrderDirection {
ASC = 'asc',
......@@ -98,4 +99,13 @@ export class FindSitesDto {
@IsBoolean()
@IsOptional()
isReported?: boolean;
@ApiProperty({
required: false,
enum: InspectionStatus,
description: 'Filter sites by their latest inspection status',
})
@IsOptional()
@IsEnum(InspectionStatus)
inspectionStatus?: InspectionStatus;
}
......@@ -100,4 +100,29 @@ export class SiteResponseDto {
nullable: true,
})
highestCandidateStatus?: string | null;
@ApiProperty({
description: 'Latest inspection status for this site',
enum: [
'PENDING',
'IN_PROGRESS',
'COMPLETED',
'CANCELLED',
'APPROVING',
'REJECTED',
'APPROVED',
],
required: false,
nullable: true,
})
latestInspectionStatus?: string | null;
@ApiProperty({
description:
'Last day to do the inspection for the current cycle (the day before the last inspection date in the previous year)',
type: Date,
required: false,
nullable: true,
})
nextInspectionDueDate?: Date | null;
}
......@@ -73,7 +73,7 @@ export class SitesController {
@ApiOperation({
summary: 'Get all sites for list view (with pagination)',
description:
'Returns paginated sites with applied filters and ordering. Use withCandidates=true to filter sites that have candidates. You can filter by type, siteCode, siteName, address, city, state, country, and isDigi status (true/false).',
'Returns paginated sites with applied filters and ordering. Use withCandidates=true to filter sites that have candidates. You can filter by type, siteCode, siteName, address, city, state, country, isDigi status (true/false), and inspectionStatus (PENDING, IN_PROGRESS, COMPLETED, CANCELLED, APPROVING, REJECTED, APPROVED).',
})
@ApiResponse({
status: 200,
......
......@@ -51,6 +51,35 @@ export class SitesService {
);
}
// Helper method to calculate next inspection due date
private calculateNextInspectionDueDate(
lastInspectionDate: Date | null,
): Date | null {
if (!lastInspectionDate) {
return null;
}
// Get the current year
const currentYear = new Date().getFullYear();
// Create a new date with the same month and day as the last inspection, but in the current year
const nextDueDate = new Date(
currentYear,
lastInspectionDate.getMonth(),
lastInspectionDate.getDate(),
);
// If the calculated date is in the past for this year, add one year
if (nextDueDate < new Date()) {
nextDueDate.setFullYear(currentYear + 1);
}
// Subtract one day to get the day before
nextDueDate.setDate(nextDueDate.getDate() - 1);
return nextDueDate;
}
async create(createSiteDto: CreateSiteDto, userId: number) {
try {
return await this.prisma.site.create({
......@@ -108,6 +137,7 @@ export class SitesService {
type,
isDigi,
isReported,
inspectionStatus,
} = findSitesDto;
const skip = (page - 1) * limit;
......@@ -145,6 +175,95 @@ export class SitesService {
};
}
// If filtering by inspection status, we need to fetch all sites first
// since we need to check the latest inspection status
if (inspectionStatus) {
const allSites = await this.prisma.site.findMany({
where,
include: {
_count: {
select: {
candidates: true,
},
},
candidates: {
where: partnerId
? {
candidate: {
partnerId: partnerId,
},
}
: undefined,
include: {
candidate: {
select: {
currentStatus: true,
},
},
},
},
inspections: {
orderBy: {
createdAt: 'desc',
},
take: 1,
select: {
status: true,
createdAt: true,
},
},
},
});
// Filter sites by inspection status
const filteredSites = allSites.filter((site) => {
const latestInspectionStatus =
site.inspections && site.inspections.length > 0
? site.inspections[0].status
: null;
return latestInspectionStatus === inspectionStatus;
});
// Apply pagination to filtered results
const total = filteredSites.length;
const paginatedSites = filteredSites.slice(skip, skip + limit);
// Add highest priority status and latest inspection status to all sites
const sitesWithHighestStatus = paginatedSites.map((site) => {
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
const latestInspectionStatus =
site.inspections && site.inspections.length > 0
? site.inspections[0].status
: null;
const lastInspectionDate =
site.inspections && site.inspections.length > 0
? site.inspections[0].createdAt
: null;
const nextInspectionDueDate =
this.calculateNextInspectionDueDate(lastInspectionDate);
return {
...site,
highestCandidateStatus,
latestInspectionStatus,
nextInspectionDueDate,
candidates: withCandidates ? site.candidates : undefined,
};
});
return {
data: sitesWithHighestStatus,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
// Original logic for when no inspection status filter is applied
const [sites, total] = await Promise.all([
this.prisma.site.findMany({
where,
......@@ -173,19 +292,41 @@ export class SitesService {
},
},
},
inspections: {
orderBy: {
createdAt: 'desc',
},
take: 1,
select: {
status: true,
createdAt: true,
},
},
},
}),
this.prisma.site.count({ where }),
]);
// Add highest priority status to all sites
// Add highest priority status and latest inspection status to all sites
const sitesWithHighestStatus = sites.map((site) => {
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
const latestInspectionStatus =
site.inspections && site.inspections.length > 0
? site.inspections[0].status
: null;
const lastInspectionDate =
site.inspections && site.inspections.length > 0
? site.inspections[0].createdAt
: null;
const nextInspectionDueDate =
this.calculateNextInspectionDueDate(lastInspectionDate);
return {
...site,
highestCandidateStatus,
latestInspectionStatus,
nextInspectionDueDate,
candidates: withCandidates ? site.candidates : undefined, // Only include full candidates data if withCandidates is true
};
});
......@@ -241,6 +382,16 @@ export class SitesService {
},
},
},
inspections: {
orderBy: {
createdAt: 'desc',
},
take: 1,
select: {
status: true,
createdAt: true,
},
},
_count: {
select: {
candidates: true,
......@@ -253,18 +404,29 @@ export class SitesService {
throw new NotFoundException(`Site with ID ${id} not found`);
}
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(
site.candidates,
);
return {
...site,
highestCandidateStatus,
};
}
const highestCandidateStatus =
site.candidates && site.candidates.length > 0
? this.getHighestPriorityStatus(site.candidates)
: null;
// Get the latest inspection status
const latestInspectionStatus =
site.inspections && site.inspections.length > 0
? site.inspections[0].status
: null;
const lastInspectionDate =
site.inspections && site.inspections.length > 0
? site.inspections[0].createdAt
: null;
const nextInspectionDueDate =
this.calculateNextInspectionDueDate(lastInspectionDate);
return site;
return {
...site,
highestCandidateStatus,
latestInspectionStatus,
nextInspectionDueDate,
};
}
async findOneFilteredByPartner(id: number, partnerId: number | null) {
......
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Inspection WebSocket Client Example</title>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
.connected {
background-color: #d4edda;
color: #155724;
}
.disconnected {
background-color: #f8d7da;
color: #721c24;
}
.message {
background-color: #d1ecf1;
color: #0c5460;
margin: 5px 0;
padding: 10px;
border-radius: 3px;
}
.error {
background-color: #f8d7da;
color: #721c24;
}
.user-activity {
background-color: #fff3cd;
color: #856404;
}
input,
button,
select {
margin: 5px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 3px;
}
button {
background-color: #007bff;
color: white;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
.response-form {
border: 1px solid #ddd;
padding: 15px;
margin: 10px 0;
border-radius: 5px;
}
</style>
</head>
<body>
<h1>Real-time Inspection Collaboration</h1>
<div class="status" id="connectionStatus">Disconnected</div>
<div>
<label for="token">JWT Token:</label>
<input
type="text"
id="token"
placeholder="Enter your JWT token"
style="width: 300px"
/>
</div>
<div>
<label for="inspectionId">Inspection ID:</label>
<input
type="number"
id="inspectionId"
placeholder="Enter inspection ID"
/>
<button onclick="joinInspection()">Join Inspection</button>
<button onclick="leaveInspection()">Leave Inspection</button>
</div>
<div>
<h3>Update Response</h3>
<div class="response-form">
<label for="questionId">Question ID:</label>
<input type="number" id="questionId" placeholder="Question ID" />
<label for="response">Response:</label>
<select id="response">
<option value="YES">YES</option>
<option value="NO">NO</option>
<option value="NA">NA</option>
</select>
<label for="comment">Comment:</label>
<input type="text" id="comment" placeholder="Optional comment" />
<button onclick="updateResponse()">Update Response</button>
</div>
</div>
<div>
<h3>Update Status</h3>
<select id="status">
<option value="PENDING">PENDING</option>
<option value="IN_PROGRESS">IN_PROGRESS</option>
<option value="COMPLETED">COMPLETED</option>
<option value="CANCELLED">CANCELLED</option>
</select>
<button onclick="updateStatus()">Update Status</button>
</div>
<div>
<h3>Active Users</h3>
<button onclick="getActiveUsers()">Get Active Users</button>
<div id="activeUsers"></div>
</div>
<div>
<h3>Real-time Messages</h3>
<div id="messages"></div>
</div>
<script>
let socket;
let currentInspectionId;
let userId = 1; // This should come from your authentication
function connect() {
const token = document.getElementById('token').value;
if (!token) {
alert('Please enter a JWT token');
return;
}
// Disconnect if already connected
if (socket) {
socket.disconnect();
}
// Connect to WebSocket
socket = io('http://localhost:3000/inspection', {
auth: {
token: token,
},
});
// Connection events
socket.on('connect', () => {
document.getElementById('connectionStatus').textContent = 'Connected';
document.getElementById('connectionStatus').className =
'status connected';
addMessage('Connected to WebSocket server');
});
socket.on('disconnect', () => {
document.getElementById('connectionStatus').textContent =
'Disconnected';
document.getElementById('connectionStatus').className =
'status disconnected';
addMessage('Disconnected from WebSocket server');
});
socket.on('connect_error', (error) => {
addMessage('Connection error: ' + error.message, 'error');
});
// Inspection events
socket.on('inspectionData', (data) => {
addMessage(
'Received inspection data: ' + JSON.stringify(data, null, 2),
);
});
socket.on('responseUpdated', (data) => {
addMessage(
'Response updated by ' +
data.updatedBy +
': ' +
JSON.stringify(data.response),
'user-activity',
);
});
socket.on('statusUpdated', (data) => {
addMessage(
'Status updated by ' + data.updatedBy + ': ' + data.status,
'user-activity',
);
});
socket.on('userJoined', (data) => {
addMessage(
'User ' + data.userId + ' joined the inspection',
'user-activity',
);
});
socket.on('userLeft', (data) => {
addMessage(
'User ' + data.userId + ' left the inspection',
'user-activity',
);
});
socket.on('userTyping', (data) => {
if (data.isTyping) {
addMessage(
'User ' +
data.userId +
' is typing on question ' +
data.questionId,
'user-activity',
);
}
});
socket.on('activeUsers', (data) => {
document.getElementById('activeUsers').innerHTML =
`<p>Active users: ${data.count}</p><p>Users: ${data.users.join(', ')}</p>`;
});
socket.on('error', (data) => {
addMessage('Error: ' + data.message, 'error');
});
}
function joinInspection() {
if (!socket) {
alert('Please connect first');
return;
}
const inspectionId = parseInt(
document.getElementById('inspectionId').value,
);
if (!inspectionId) {
alert('Please enter an inspection ID');
return;
}
currentInspectionId = inspectionId;
socket.emit('joinInspection', { inspectionId });
addMessage('Joining inspection ' + inspectionId);
}
function leaveInspection() {
if (!socket || !currentInspectionId) {
alert('Not in an inspection');
return;
}
socket.emit('leaveInspection', { inspectionId: currentInspectionId });
addMessage('Leaving inspection ' + currentInspectionId);
currentInspectionId = null;
}
function updateResponse() {
if (!socket || !currentInspectionId) {
alert('Please join an inspection first');
return;
}
const questionId = parseInt(
document.getElementById('questionId').value,
);
const response = document.getElementById('response').value;
const comment = document.getElementById('comment').value;
if (!questionId) {
alert('Please enter a question ID');
return;
}
socket.emit('updateResponse', {
inspectionId: currentInspectionId,
response: {
questionId: questionId,
response: response,
comment: comment || undefined,
},
userId: userId,
});
addMessage('Updating response for question ' + questionId);
}
function updateStatus() {
if (!socket || !currentInspectionId) {
alert('Please join an inspection first');
return;
}
const status = document.getElementById('status').value;
socket.emit('updateStatus', {
inspectionId: currentInspectionId,
status: status,
userId: userId,
});
addMessage('Updating status to ' + status);
}
function getActiveUsers() {
if (!socket || !currentInspectionId) {
alert('Please join an inspection first');
return;
}
socket.emit('getActiveUsers', { inspectionId: currentInspectionId });
}
function addMessage(message, className = 'message') {
const messagesDiv = document.getElementById('messages');
const messageDiv = document.createElement('div');
messageDiv.className = className;
messageDiv.textContent =
new Date().toLocaleTimeString() + ': ' + message;
messagesDiv.appendChild(messageDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
// Auto-connect when token is entered
document.getElementById('token').addEventListener('change', connect);
</script>
</body>
</html>
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