mejoras al formulario de registro organizaciones productivas

This commit is contained in:
2026-01-22 14:28:24 -04:00
parent 69b3aab02a
commit 08a5567d60
34 changed files with 4297 additions and 1102 deletions

3
apps/api/.gitignore vendored
View File

@@ -54,3 +54,6 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Uploads
/uploads/training/*

View File

@@ -49,7 +49,8 @@
"pg": "8.13.3",
"pino-pretty": "13.0.0",
"reflect-metadata": "0.2.0",
"rxjs": "7.8.1"
"rxjs": "7.8.1",
"sharp": "^0.34.5"
},
"devDependencies": {
"@nestjs-modules/mailer": "^2.0.2",

View File

@@ -0,0 +1,36 @@
import { Injectable, PipeTransform } from '@nestjs/common';
import * as path from 'path';
import sharp from 'sharp';
@Injectable()
export class ImageProcessingPipe implements PipeTransform {
async transform(
files: Express.Multer.File[] | Express.Multer.File,
): Promise<Express.Multer.File[] | Express.Multer.File> {
if (!files) return files;
const processItem = async (
file: Express.Multer.File,
): Promise<Express.Multer.File> => {
const processedBuffer = await sharp(file.buffer)
.webp({ quality: 80 })
.toBuffer();
const originalName = path.parse(file.originalname).name;
return {
...file,
buffer: processedBuffer,
originalname: `${originalName}.webp`,
mimetype: 'image/webp',
size: processedBuffer.length,
};
};
if (Array.isArray(files)) {
return await Promise.all(files.map((file) => processItem(file)));
}
return await processItem(files);
}
}

View File

@@ -0,0 +1,4 @@
ALTER TABLE "training_surveys" ALTER COLUMN "current_status" SET DEFAULT 'ACTIVA';--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "photo2" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "photo3" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "product_count" integer DEFAULT 0 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,13 @@
"when": 1764883378610,
"tag": "0009_eminent_ares",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769097895095,
"tag": "0010_dashing_bishop",
"breakpoints": true
}
]
}

View File

@@ -1,9 +1,8 @@
import { sql } from 'drizzle-orm';
import * as t from 'drizzle-orm/pg-core';
import { eq, lt, gte, ne, sql } from 'drizzle-orm';
import { timestamps } from '../timestamps';
import { users } from './auth';
import { states, municipalities, parishes } from './general';
import { municipalities, parishes, states } from './general';
// Tabla surveys
export const surveys = t.pgTable(
@@ -19,9 +18,7 @@ export const surveys = t.pgTable(
...timestamps,
},
(surveys) => ({
surveysIndex: t
.index('surveys_index_00')
.on(surveys.title),
surveysIndex: t.index('surveys_index_00').on(surveys.title),
}),
);
@@ -55,9 +52,15 @@ export const trainingSurveys = t.pgTable(
lastname: t.text('lastname').notNull(),
visitDate: t.timestamp('visit_date').notNull(),
// ubicacion
state: t.integer('state').references(() => states.id, { onDelete: 'set null' }),
municipality: t.integer('municipality').references(() => municipalities.id, { onDelete: 'set null' }),
parish: t.integer('parish').references(() => parishes.id, { onDelete: 'set null' }),
state: t
.integer('state')
.references(() => states.id, { onDelete: 'set null' }),
municipality: t
.integer('municipality')
.references(() => municipalities.id, { onDelete: 'set null' }),
parish: t
.integer('parish')
.references(() => parishes.id, { onDelete: 'set null' }),
siturCodeCommune: t.text('situr_code_commune').notNull(),
communalCouncil: t.text('communal_council').notNull(),
siturCodeCommunalCouncil: t.text('situr_code_communal_council').notNull(),
@@ -67,10 +70,13 @@ export const trainingSurveys = t.pgTable(
ospRif: t.text('osp_rif').notNull(),
ospType: t.text('osp_type').notNull(),
productiveActivity: t.text('productive_activity').notNull(),
financialRequirementDescription: t.text('financial_requirement_description').notNull(),
currentStatus: t.text('current_status').notNull(),
financialRequirementDescription: t
.text('financial_requirement_description')
.notNull(),
currentStatus: t.text('current_status').notNull().default('ACTIVA'),
companyConstitutionYear: t.integer('company_constitution_year').notNull(),
producerCount: t.integer('producer_count').notNull(),
productCount: t.integer('product_count').notNull().default(0),
productDescription: t.text('product_description').notNull(),
installedCapacity: t.text('installed_capacity').notNull(),
operationalCapacity: t.text('operational_capacity').notNull(),
@@ -88,13 +94,15 @@ export const trainingSurveys = t.pgTable(
paralysisReason: t.text('paralysis_reason').notNull(),
// fotos
photo1: t.text('photo1').notNull(),
photo2: t.text('photo2').notNull(),
photo3: t.text('photo3').notNull(),
photo2: t.text('photo2'),
photo3: t.text('photo3'),
...timestamps,
},
(trainingSurveys) => ({
trainingSurveysIndex: t.index('training_surveys_index_00').on(trainingSurveys.firstname),
})
trainingSurveysIndex: t
.index('training_surveys_index_00')
.on(trainingSurveys.firstname),
}),
);
export const viewSurveys = t.pgView('v_surveys', {
@@ -103,6 +111,7 @@ export const viewSurveys = t.pgView('v_surveys', {
description: t.text('description'),
created_at: t.timestamp('created_at'),
closingDate: t.date('closing_date'),
targetAudience: t.varchar('target_audience')
}).as(sql`select id as survey_id, title, description, created_at, closing_date, target_audience from surveys
where published = true`);
targetAudience: t.varchar('target_audience'),
})
.as(sql`select id as survey_id, title, description, created_at, closing_date, target_audience from surveys
where published = true`);

View File

@@ -1,140 +1,149 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsString, IsDateString, IsOptional } from 'class-validator';
import { IsDateString, IsInt, IsOptional, IsString } from 'class-validator';
export class CreateTrainingDto {
@ApiProperty()
@IsString()
firstname: string;
@ApiProperty()
@IsString()
firstname: string;
@ApiProperty()
@IsString()
lastname: string;
@ApiProperty()
@IsString()
lastname: string;
@ApiProperty()
@IsDateString()
visitDate: string;
@ApiProperty()
@IsDateString()
visitDate: string;
@ApiProperty()
@IsString()
productiveActivity: string;
@ApiProperty()
@IsString()
productiveActivity: string;
@ApiProperty()
@IsString()
financialRequirementDescription: string;
@ApiProperty()
@IsString()
financialRequirementDescription: string;
@ApiProperty()
@IsInt()
state: number;
@ApiProperty()
@IsInt()
state: number;
@ApiProperty()
@IsInt()
municipality: number;
@ApiProperty()
@IsInt()
municipality: number;
@ApiProperty()
@IsInt()
parish: number;
@ApiProperty()
@IsInt()
parish: number;
@ApiProperty()
@IsString()
siturCodeCommune: string;
@ApiProperty()
@IsString()
siturCodeCommune: string;
@ApiProperty()
@IsString()
communalCouncil: string;
@ApiProperty()
@IsString()
communalCouncil: string;
@ApiProperty()
@IsString()
siturCodeCommunalCouncil: string;
@ApiProperty()
@IsString()
siturCodeCommunalCouncil: string;
@ApiProperty()
@IsString()
ospName: string;
@ApiProperty()
@IsString()
ospName: string;
@ApiProperty()
@IsString()
ospAddress: string;
@ApiProperty()
@IsString()
ospAddress: string;
@ApiProperty()
@IsString()
ospRif: string;
@ApiProperty()
@IsString()
ospRif: string;
@ApiProperty()
@IsString()
ospType: string;
@ApiProperty()
@IsString()
ospType: string;
@ApiProperty()
@IsString()
currentStatus: string;
@ApiProperty()
@IsString()
currentStatus: string;
@ApiProperty()
@IsInt()
companyConstitutionYear: number;
@ApiProperty()
@IsInt()
companyConstitutionYear: number;
@ApiProperty()
@IsInt()
producerCount: number;
@ApiProperty()
@IsInt()
producerCount: number;
@ApiProperty()
@IsString()
productDescription: string;
@ApiProperty()
@IsInt()
@IsOptional()
productCount: number;
@ApiProperty()
@IsString()
installedCapacity: string;
@ApiProperty()
@IsString()
productDescription: string;
@ApiProperty()
@IsString()
operationalCapacity: string;
@ApiProperty()
@IsString()
installedCapacity: string;
@ApiProperty()
@IsString()
ospResponsibleFullname: string;
@ApiProperty()
@IsString()
operationalCapacity: string;
@ApiProperty()
@IsString()
ospResponsibleCedula: string;
@ApiProperty()
@IsString()
ospResponsibleFullname: string;
@ApiProperty()
@IsString()
ospResponsibleRif: string;
@ApiProperty()
@IsString()
ospResponsibleCedula: string;
@ApiProperty()
@IsString()
ospResponsiblePhone: string;
@ApiProperty()
@IsString()
ospResponsibleRif: string;
@ApiProperty()
@IsString()
ospResponsibleEmail: string;
@ApiProperty()
@IsString()
ospResponsiblePhone: string;
@ApiProperty()
@IsString()
civilState: string;
@ApiProperty()
@IsString()
ospResponsibleEmail: string;
@ApiProperty()
@IsInt()
familyBurden: number;
@ApiProperty()
@IsString()
civilState: string;
@ApiProperty()
@IsInt()
numberOfChildren: number;
@ApiProperty()
@IsInt()
familyBurden: number;
@ApiProperty()
@IsString()
generalObservations: string;
@ApiProperty()
@IsInt()
numberOfChildren: number;
@ApiProperty()
@IsString()
photo1: string;
@ApiProperty()
@IsString()
generalObservations: string;
@ApiProperty()
@IsString()
photo2: string;
@ApiProperty()
@IsString()
@IsOptional()
photo1?: string;
@ApiProperty()
@IsString()
photo3: string;
@ApiProperty()
@IsString()
@IsOptional()
photo2?: string;
@ApiProperty()
@IsString()
paralysisReason: string;
@ApiProperty()
@IsString()
@IsOptional()
photo3?: string;
@ApiProperty()
@IsString()
@IsOptional()
paralysisReason: string;
}

View File

@@ -1,68 +1,114 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common';
import { TrainingService } from './training.service';
import { CreateTrainingDto } from './dto/create-training.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import {
ApiConsumes,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { ImageProcessingPipe } from '../../common/pipes/image-processing.pipe';
import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
import { TrainingService } from './training.service';
@ApiTags('training')
@Controller('training')
export class TrainingController {
constructor(private readonly trainingService: TrainingService) { }
constructor(private readonly trainingService: TrainingService) {}
@Get()
@ApiOperation({ summary: 'Get all training records with pagination and filters' })
@ApiResponse({ status: 200, description: 'Return paginated training records.' })
async findAll(@Query() paginationDto: PaginationDto) {
const result = await this.trainingService.findAll(paginationDto);
return {
message: 'Training records fetched successfully',
data: result.data,
meta: result.meta
};
}
@Get()
@ApiOperation({
summary: 'Get all training records with pagination and filters',
})
@ApiResponse({
status: 200,
description: 'Return paginated training records.',
})
async findAll(@Query() paginationDto: PaginationDto) {
const result = await this.trainingService.findAll(paginationDto);
return {
message: 'Training records fetched successfully',
data: result.data,
meta: result.meta,
};
}
@Get('statistics')
@ApiOperation({ summary: 'Get training statistics' })
@ApiResponse({ status: 200, description: 'Return training statistics.' })
async getStatistics(@Query() filterDto: TrainingStatisticsFilterDto) {
const data = await this.trainingService.getStatistics(filterDto);
return { message: 'Training statistics fetched successfully', data };
}
@Get('statistics')
@ApiOperation({ summary: 'Get training statistics' })
@ApiResponse({ status: 200, description: 'Return training statistics.' })
async getStatistics(@Query() filterDto: TrainingStatisticsFilterDto) {
const data = await this.trainingService.getStatistics(filterDto);
return { message: 'Training statistics fetched successfully', data };
}
@Get(':id')
@ApiOperation({ summary: 'Get a training record by ID' })
@ApiResponse({ status: 200, description: 'Return the training record.' })
@ApiResponse({ status: 404, description: 'Training record not found.' })
async findOne(@Param('id') id: string) {
const data = await this.trainingService.findOne(+id);
return { message: 'Training record fetched successfully', data };
}
@Get(':id')
@ApiOperation({ summary: 'Get a training record by ID' })
@ApiResponse({ status: 200, description: 'Return the training record.' })
@ApiResponse({ status: 404, description: 'Training record not found.' })
async findOne(@Param('id') id: string) {
const data = await this.trainingService.findOne(+id);
return { message: 'Training record fetched successfully', data };
}
@Post()
@ApiOperation({ summary: 'Create a new training record' })
@ApiResponse({ status: 201, description: 'Training record created successfully.' })
async create(@Body() createTrainingDto: CreateTrainingDto) {
const data = await this.trainingService.create(createTrainingDto);
return { message: 'Training record created successfully', data };
}
@Post()
@UseInterceptors(FilesInterceptor('files', 3))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new training record' })
@ApiResponse({
status: 201,
description: 'Training record created successfully.',
})
async create(
@Body() createTrainingDto: CreateTrainingDto,
@UploadedFiles(ImageProcessingPipe) files: Express.Multer.File[],
) {
const data = await this.trainingService.create(createTrainingDto, files);
return { message: 'Training record created successfully', data };
}
@Patch(':id')
@ApiOperation({ summary: 'Update a training record' })
@ApiResponse({ status: 200, description: 'Training record updated successfully.' })
@ApiResponse({ status: 404, description: 'Training record not found.' })
async update(@Param('id') id: string, @Body() updateTrainingDto: UpdateTrainingDto) {
const data = await this.trainingService.update(+id, updateTrainingDto);
return { message: 'Training record updated successfully', data };
}
@Patch(':id')
@UseInterceptors(FilesInterceptor('files', 3))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Update a training record' })
@ApiResponse({
status: 200,
description: 'Training record updated successfully.',
})
@ApiResponse({ status: 404, description: 'Training record not found.' })
async update(
@Param('id') id: string,
@Body() updateTrainingDto: UpdateTrainingDto,
@UploadedFiles(ImageProcessingPipe) files: Express.Multer.File[],
) {
const data = await this.trainingService.update(
+id,
updateTrainingDto,
files,
);
return { message: 'Training record updated successfully', data };
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a training record' })
@ApiResponse({ status: 200, description: 'Training record deleted successfully.' })
@ApiResponse({ status: 404, description: 'Training record not found.' })
async remove(@Param('id') id: string) {
return await this.trainingService.remove(+id);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a training record' })
@ApiResponse({
status: 200,
description: 'Training record deleted successfully.',
})
@ApiResponse({ status: 404, description: 'Training record not found.' })
async remove(@Param('id') id: string) {
return await this.trainingService.remove(+id);
}
}

View File

@@ -1,223 +1,324 @@
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
import { Inject, Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { and, eq, gte, ilike, lte, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as fs from 'fs';
import * as path from 'path';
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
import * as schema from 'src/database/index';
import { trainingSurveys } from 'src/database/index';
import { eq, like, or, and, gte, lte, SQL, sql } from 'drizzle-orm';
import { CreateTrainingDto } from './dto/create-training.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { states } from 'src/database/index';
import { states, trainingSurveys } from 'src/database/index';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
@Injectable()
export class TrainingService {
constructor(
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
) { }
constructor(
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
) {}
async findAll(paginationDto?: PaginationDto) {
const {
page = 1,
limit = 10,
search = '',
sortBy = 'id',
sortOrder = 'asc',
} = paginationDto || {};
async findAll(paginationDto?: PaginationDto) {
const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {};
const offset = (page - 1) * limit;
const offset = (page - 1) * limit;
let searchCondition: SQL<unknown> | undefined;
if (search) {
searchCondition = or(
like(trainingSurveys.firstname, `%${search}%`),
like(trainingSurveys.lastname, `%${search}%`),
like(trainingSurveys.ospName, `%${search}%`),
like(trainingSurveys.ospRif, `%${search}%`)
);
}
const orderBy = sortOrder === 'asc'
? sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} asc`
: sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} desc`;
const totalCountResult = await this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(searchCondition);
const totalCount = Number(totalCountResult[0].count);
const totalPages = Math.ceil(totalCount / limit);
const data = await this.drizzle
.select()
.from(trainingSurveys)
.where(searchCondition)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
const meta = {
page,
limit,
totalCount,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
nextPage: page < totalPages ? page + 1 : null,
previousPage: page > 1 ? page - 1 : null,
};
return { data, meta };
let searchCondition: SQL<unknown> | undefined;
if (search) {
searchCondition = or(ilike(trainingSurveys.ospName, `%${search}%`));
}
async getStatistics(filterDto: TrainingStatisticsFilterDto) {
const { startDate, endDate, stateId, municipalityId, parishId, ospType } = filterDto;
const orderBy =
sortOrder === 'asc'
? sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} asc`
: sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} desc`;
const filters: SQL[] = [];
const totalCountResult = await this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(searchCondition);
if (startDate) {
filters.push(gte(trainingSurveys.visitDate, new Date(startDate)));
}
const totalCount = Number(totalCountResult[0].count);
const totalPages = Math.ceil(totalCount / limit);
if (endDate) {
filters.push(lte(trainingSurveys.visitDate, new Date(endDate)));
}
const data = await this.drizzle
.select()
.from(trainingSurveys)
.where(searchCondition)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
if (stateId) {
filters.push(eq(trainingSurveys.state, stateId));
}
const meta = {
page,
limit,
totalCount,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
nextPage: page < totalPages ? page + 1 : null,
previousPage: page > 1 ? page - 1 : null,
};
if (municipalityId) {
filters.push(eq(trainingSurveys.municipality, municipalityId));
}
return { data, meta };
}
if (parishId) {
filters.push(eq(trainingSurveys.parish, parishId));
}
async getStatistics(filterDto: TrainingStatisticsFilterDto) {
const { startDate, endDate, stateId, municipalityId, parishId, ospType } =
filterDto;
if (ospType) {
filters.push(eq(trainingSurveys.ospType, ospType));
}
const filters: SQL[] = [];
const whereCondition = filters.length > 0 ? and(...filters) : undefined;
const totalOspsResult = await this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(whereCondition);
const totalOsps = Number(totalOspsResult[0].count);
const totalProducersResult = await this.drizzle
.select({ sum: sql<number>`sum(${trainingSurveys.producerCount})` })
.from(trainingSurveys)
.where(whereCondition);
const totalProducers = Number(totalProducersResult[0].sum || 0);
const statusDistribution = await this.drizzle
.select({
name: trainingSurveys.currentStatus,
value: sql<number>`count(*)`
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.currentStatus);
const activityDistribution = await this.drizzle
.select({
name: trainingSurveys.productiveActivity,
value: sql<number>`count(*)`
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.productiveActivity);
const typeDistribution = await this.drizzle
.select({
name: trainingSurveys.ospType,
value: sql<number>`count(*)`
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.ospType);
// New Aggregations
const stateDistribution = await this.drizzle
.select({
name: states.name,
value: sql<number>`count(${trainingSurveys.id})`
})
.from(trainingSurveys)
.leftJoin(states, eq(trainingSurveys.state, states.id))
.where(whereCondition)
.groupBy(states.name);
const yearDistribution = await this.drizzle
.select({
name: sql<string>`cast(${trainingSurveys.companyConstitutionYear} as text)`,
value: sql<number>`count(*)`
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.companyConstitutionYear)
.orderBy(trainingSurveys.companyConstitutionYear);
return {
totalOsps,
totalProducers,
statusDistribution: statusDistribution.map(item => ({ ...item, value: Number(item.value) })),
activityDistribution: activityDistribution.map(item => ({ ...item, value: Number(item.value) })),
typeDistribution: typeDistribution.map(item => ({ ...item, value: Number(item.value) })),
stateDistribution: stateDistribution.map(item => ({ ...item, value: Number(item.value) })),
yearDistribution: yearDistribution.map(item => ({ ...item, value: Number(item.value) })),
};
if (startDate) {
filters.push(gte(trainingSurveys.visitDate, new Date(startDate)));
}
async findOne(id: number) {
const find = await this.drizzle
.select()
.from(trainingSurveys)
.where(eq(trainingSurveys.id, id));
if (endDate) {
filters.push(lte(trainingSurveys.visitDate, new Date(endDate)));
}
if (find.length === 0) {
throw new HttpException('Training record not found', HttpStatus.NOT_FOUND);
if (stateId) {
filters.push(eq(trainingSurveys.state, stateId));
}
if (municipalityId) {
filters.push(eq(trainingSurveys.municipality, municipalityId));
}
if (parishId) {
filters.push(eq(trainingSurveys.parish, parishId));
}
if (ospType) {
filters.push(eq(trainingSurveys.ospType, ospType));
}
const whereCondition = filters.length > 0 ? and(...filters) : undefined;
const totalOspsResult = await this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(whereCondition);
const totalOsps = Number(totalOspsResult[0].count);
const totalProducersResult = await this.drizzle
.select({ sum: sql<number>`sum(${trainingSurveys.producerCount})` })
.from(trainingSurveys)
.where(whereCondition);
const totalProducers = Number(totalProducersResult[0].sum || 0);
const statusDistribution = await this.drizzle
.select({
name: trainingSurveys.currentStatus,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.currentStatus);
const activityDistribution = await this.drizzle
.select({
name: trainingSurveys.productiveActivity,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.productiveActivity);
const typeDistribution = await this.drizzle
.select({
name: trainingSurveys.ospType,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.ospType);
// New Aggregations
const stateDistribution = await this.drizzle
.select({
name: states.name,
value: sql<number>`count(${trainingSurveys.id})`,
})
.from(trainingSurveys)
.leftJoin(states, eq(trainingSurveys.state, states.id))
.where(whereCondition)
.groupBy(states.name);
const yearDistribution = await this.drizzle
.select({
name: sql<string>`cast(${trainingSurveys.companyConstitutionYear} as text)`,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.companyConstitutionYear)
.orderBy(trainingSurveys.companyConstitutionYear);
return {
totalOsps,
totalProducers,
statusDistribution: statusDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
activityDistribution: activityDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
typeDistribution: typeDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
stateDistribution: stateDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
yearDistribution: yearDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
};
}
async findOne(id: number) {
const find = await this.drizzle
.select()
.from(trainingSurveys)
.where(eq(trainingSurveys.id, id));
if (find.length === 0) {
throw new HttpException(
'Training record not found',
HttpStatus.NOT_FOUND,
);
}
return find[0];
}
private async saveFiles(files: Express.Multer.File[]): Promise<string[]> {
if (!files || files.length === 0) return [];
const uploadDir = './uploads/training';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const savedPaths: string[] = [];
for (const file of files) {
const fileName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${path.extname(file.originalname)}`;
const filePath = path.join(uploadDir, fileName);
fs.writeFileSync(filePath, file.buffer);
savedPaths.push(`/assets/training/${fileName}`);
}
return savedPaths;
}
private deleteFile(assetPath: string) {
if (!assetPath) return;
// Map /assets/training/filename.webp back to ./uploads/training/filename.webp
const relativePath = assetPath.replace('/assets/training/', '');
const fullPath = path.join('./uploads/training', relativePath);
if (fs.existsSync(fullPath)) {
try {
fs.unlinkSync(fullPath);
} catch (err) {
console.error(`Error deleting file ${fullPath}:`, err);
}
}
}
async create(
createTrainingDto: CreateTrainingDto,
files: Express.Multer.File[],
) {
const photoPaths = await this.saveFiles(files);
const [newRecord] = await this.drizzle
.insert(trainingSurveys)
.values({
...createTrainingDto,
visitDate: new Date(createTrainingDto.visitDate),
photo1: photoPaths[0] || '',
photo2: photoPaths[1] || null,
photo3: photoPaths[2] || null,
})
.returning();
return newRecord;
}
async update(
id: number,
updateTrainingDto: UpdateTrainingDto,
files: Express.Multer.File[],
) {
const currentRecord = await this.findOne(id);
const photoPaths = await this.saveFiles(files);
const updateData: any = { ...updateTrainingDto };
// Handle photo updates/removals
const photoFields = ['photo1', 'photo2', 'photo3'] as const;
// 1. If we have NEW files, they replace any old files or occupy empty slots
if (photoPaths.length > 0) {
photoPaths.forEach((newPath, idx) => {
const fieldName = photoFields[idx];
const oldPath = currentRecord[fieldName];
if (oldPath && oldPath !== newPath) {
this.deleteFile(oldPath);
}
return find[0];
updateData[fieldName] = newPath;
});
}
async create(createTrainingDto: CreateTrainingDto) {
const [newRecord] = await this.drizzle
.insert(trainingSurveys)
.values({
...createTrainingDto,
visitDate: new Date(createTrainingDto.visitDate),
})
.returning();
// 2. If the user explicitly cleared a photo field (updateData.photoX === '')
photoFields.forEach((field) => {
if (updateData[field] === '') {
const oldPath = currentRecord[field];
if (oldPath) this.deleteFile(oldPath);
updateData[field] = null; // Set to null in DB
}
});
return newRecord;
if (updateTrainingDto.visitDate) {
updateData.visitDate = new Date(updateTrainingDto.visitDate);
}
async update(id: number, updateTrainingDto: UpdateTrainingDto) {
await this.findOne(id);
const [updatedRecord] = await this.drizzle
.update(trainingSurveys)
.set(updateData)
.where(eq(trainingSurveys.id, id))
.returning();
const updateData: any = { ...updateTrainingDto };
if (updateTrainingDto.visitDate) {
updateData.visitDate = new Date(updateTrainingDto.visitDate);
}
return updatedRecord;
}
const [updatedRecord] = await this.drizzle
.update(trainingSurveys)
.set(updateData)
.where(eq(trainingSurveys.id, id))
.returning();
async remove(id: number) {
const record = await this.findOne(id);
return updatedRecord;
}
// Delete associated files
if (record.photo1) this.deleteFile(record.photo1);
if (record.photo2) this.deleteFile(record.photo2);
if (record.photo3) this.deleteFile(record.photo3);
async remove(id: number) {
await this.findOne(id);
const [deletedRecord] = await this.drizzle
.delete(trainingSurveys)
.where(eq(trainingSurveys.id, id))
.returning();
const [deletedRecord] = await this.drizzle
.delete(trainingSurveys)
.where(eq(trainingSurveys.id, id))
.returning();
return { message: 'Training record deleted successfully', data: deletedRecord };
}
return {
message: 'Training record deleted successfully',
data: deletedRecord,
};
}
}