Files
lcbp3/backend/src/modules/document-numbering/services/reservation.service.ts
T
admin c83588ab43
CI / CD Pipeline / build (push) Failing after 21m19s
CI / CD Pipeline / deploy (push) Has been skipped
260330:1011 Addied correspondence_revieion_attcahments table table #01
2026-03-30 10:11:40 +07:00

207 lines
5.9 KiB
TypeScript

import {
Injectable,
NotFoundException,
GoneException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Cron } from '@nestjs/schedule';
import { v4 as uuidv4 } from 'uuid';
import {
DocumentNumberReservation,
ReservationStatus,
} from '../entities/document-number-reservation.entity';
import {
ReserveNumberDto,
ReserveNumberResponseDto,
} from '../dto/reserve-number.dto';
import {
ConfirmReservationDto,
ConfirmReservationResponseDto,
} from '../dto/confirm-reservation.dto';
import { CounterService } from './counter.service';
import { FormatService } from './format.service';
import { buildCounterKey } from '../dto/counter-key.dto';
@Injectable()
export class ReservationService {
private readonly logger = new Logger(ReservationService.name);
private readonly RESERVATION_TTL_MINUTES = 5;
constructor(
@InjectRepository(DocumentNumberReservation)
private reservationRepo: Repository<DocumentNumberReservation>,
private counterService: CounterService,
private formatService: FormatService
) {}
/**
* Reserve a document number (Phase 1 of Two-Phase Commit)
*/
async reserve(
dto: ReserveNumberDto,
userId: number,
ipAddress: string,
userAgent: string
): Promise<ReserveNumberResponseDto> {
// Build counter key
const counterKey = buildCounterKey({
projectId: dto.projectId,
originatorOrgId: dto.originatorOrganizationId,
recipientOrgId: dto.recipientOrganizationId,
correspondenceTypeId: dto.correspondenceTypeId,
subTypeId: dto.subTypeId,
rfaTypeId: dto.rfaTypeId,
disciplineId: dto.disciplineId,
isRFA: dto.rfaTypeId !== undefined && dto.rfaTypeId > 0,
});
// Increment counter
const sequence = await this.counterService.incrementCounter(counterKey);
// Format document number
const { previewNumber: documentNumber } = await this.formatService.format({
...dto,
sequence,
resetScope: counterKey.resetScope,
});
// Create reservation
const token = uuidv4();
const expiresAt = new Date(
Date.now() + this.RESERVATION_TTL_MINUTES * 60 * 1000
);
const _reservation = await this.reservationRepo.save({
token,
documentNumber,
status: ReservationStatus.RESERVED,
expiresAt,
userId,
ipAddress,
userAgent,
projectId: dto.projectId,
correspondenceTypeId: dto.correspondenceTypeId,
originatorOrganizationId: dto.originatorOrganizationId,
recipientOrganizationId: dto.recipientOrganizationId || 0,
metadata: dto.metadata,
});
this.logger.log(
`Reserved: ${documentNumber} for user ${userId} (token: ${token})`
);
return {
token,
documentNumber,
expiresAt,
};
}
/**
* Confirm a reservation (Phase 2 of Two-Phase Commit)
*/
async confirm(
dto: ConfirmReservationDto,
userId: number
): Promise<ConfirmReservationResponseDto> {
const reservation = await this.reservationRepo.findOne({
where: {
token: dto.token,
status: ReservationStatus.RESERVED,
},
});
if (!reservation) {
throw new NotFoundException('Reservation not found or already used');
}
// Check expiration
if (new Date() > reservation.expiresAt) {
await this.cancel(dto.token, userId, 'Expired');
throw new GoneException(
'Reservation expired. Please reserve a new number.'
);
}
// Confirm
reservation.status = ReservationStatus.CONFIRMED;
reservation.documentId = dto.documentId ?? null;
reservation.confirmedAt = new Date();
await this.reservationRepo.save(reservation);
this.logger.log(
`Confirmed: ${reservation.documentNumber} → document ${dto.documentId}`
);
return {
documentNumber: reservation.documentNumber,
confirmedAt: reservation.confirmedAt,
};
}
/**
* Cancel a reservation
*/
async cancel(token: string, userId: number, reason?: string): Promise<void> {
const reservation = await this.reservationRepo.findOne({
where: { token },
});
if (reservation && reservation.status === ReservationStatus.RESERVED) {
reservation.status = ReservationStatus.CANCELLED;
reservation.cancelledAt = new Date();
reservation.metadata = {
...reservation.metadata,
cancelReason: reason,
cancelledBy: userId,
};
await this.reservationRepo.save(reservation);
this.logger.log(
`Cancelled: ${reservation.documentNumber} by user ${userId}`
);
}
}
/**
* Cron job: Cleanup expired reservations every 5 minutes
* ใช้ try/catch เพื่อรองรับ transient ECONNRESET — TypeORM pool จะ reconnect อัตโนมัติใน tick ถัดไป
*/
@Cron('*/5 * * * *')
async cleanupExpired(): Promise<void> {
try {
const result = await this.reservationRepo
.createQueryBuilder()
.update()
.set({
status: ReservationStatus.CANCELLED,
cancelledAt: () => 'NOW()',
})
.where('document_number_status = :status', {
status: ReservationStatus.RESERVED,
})
.andWhere('expires_at < NOW()')
.execute();
if ((result.affected ?? 0) > 0) {
this.logger.log(`Cleaned up ${result.affected} expired reservations`);
}
} catch (error: unknown) {
// ECONNRESET บน idle connection — TypeORM จะ reconnect pool ใน request ถัดไปโดยอัตโนมัติ
const msg = error instanceof Error ? error.message : String(error);
this.logger.warn(
`[cleanupExpired] Transient DB error (will retry next tick): ${msg}`
);
}
}
/**
* Get reservation by token
*/
async getByToken(token: string): Promise<DocumentNumberReservation | null> {
return this.reservationRepo.findOne({ where: { token } });
}
}