Files
lcbp3/backend/src/modules/ai/prompts/ai-prompts.service.ts
T
admin 09e304de84
CI / CD Pipeline / build (push) Successful in 7m5s
CI / CD Pipeline / deploy (push) Failing after 20m14s
690618:1444 237 #02
2026-06-18 14:44:46 +07:00

807 lines
30 KiB
TypeScript

// File: backend/src/modules/ai/prompts/ai-prompts.service.ts
// Change Log
// - 2026-05-25: Created AiPromptsService for dynamic prompt management (ADR-029)
// - 2026-05-25: Fixed BusinessException and NotFoundException constructor signatures
// - 2026-05-25: Cast getRawOne() to resolve TypeScript type assertion error in ESLint
// - 2026-06-15: Added optimistic locking error handling for @VersionColumn (T067)
import {
Injectable,
Logger,
ForbiddenException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import { randomUUID } from 'crypto';
import { AiPrompt } from './ai-prompts.entity';
import { AuditLog } from '../../../common/entities/audit-log.entity';
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
import { ContextConfigDto } from '../dto/context-config.dto';
import {
BusinessException,
ValidationException,
NotFoundException,
} from '../../../common/exceptions';
import { readPromptContextScope } from './prompt-context-scope.util';
/**
* Service สำหรับจัดการ Prompt Versioning และการดึงข้อมูล Prompt ล่าสุดที่พร้อมใช้งาน
*/
@Injectable()
export class AiPromptsService {
private readonly logger = new Logger(AiPromptsService.name);
private readonly cachePrefix = 'ai:prompt:active:';
constructor(
@InjectRepository(AiPrompt)
private readonly aiPromptRepo: Repository<AiPrompt>,
@InjectRepository(AuditLog)
private readonly auditLogRepo: Repository<AuditLog>,
@InjectRedis()
private readonly redis: Redis,
private readonly dataSource: DataSource
) {}
/**
* ค้นหาและเตรียมข้อมูล Master Data อ้างอิงโครงการ (Context Resolution)
* ดึงโครงการ, องค์กร, สาขางาน, ประเภทเอกสาร, และแท็ก
* พร้อมทั้งทำหน้าที่เป็น Gatekeeper ป้องกันความสอดคล้องความปลอดภัย (ADR-030)
* @param activePrompt Prompt version ที่ใช้งานอยู่
* @param overrideProjectPublicId UUID โครงการสำหรับ override (optional)
* @param overrideContractPublicId UUID สัญญาสำหรับ override (optional)
* @returns Master data context ที่กรองแล้ว
*/
async resolveContext(
activePrompt: AiPrompt,
overrideProjectPublicId?: string,
overrideContractPublicId?: string
): Promise<Record<string, unknown>> {
const scope = readPromptContextScope(activePrompt.contextConfig);
let targetProjectId: number | null = null;
if (scope.projectPublicId) {
const foundProject = await this.dataSource.manager
.createQueryBuilder()
.select('p.id', 'id')
.from('projects', 'p')
.where('p.uuid = :uuid', { uuid: scope.projectPublicId })
.andWhere('p.deleted_at IS NULL')
.getRawOne<{ id: number }>();
if (!foundProject) {
throw new NotFoundException('Project', scope.projectPublicId);
}
targetProjectId = Number(foundProject.id);
}
let targetContractId: number | null = null;
let targetContractProjectId: number | null = null;
if (scope.contractPublicId) {
const foundContract = await this.dataSource.manager
.createQueryBuilder()
.select(['c.id as id', 'c.project_id as projectId'])
.from('contracts', 'c')
.where('c.uuid = :uuid', { uuid: scope.contractPublicId })
.getRawOne<{ id: number; projectId: number }>();
if (!foundContract) {
throw new NotFoundException('Contract', scope.contractPublicId);
}
targetContractId = Number(foundContract.id);
targetContractProjectId = Number(foundContract.projectId);
if (
targetProjectId !== null &&
targetContractProjectId !== targetProjectId
) {
throw new ForbiddenException(
`Cross-project boundary violation: Contract belongs to project ID ${targetContractProjectId} but template is restricted to project ID ${targetProjectId}`
);
}
}
// 1. Logic ตรวจสอบ Override และทำหน้าที่ Gatekeeper ป้องกัน Cross-project data leak
if (overrideProjectPublicId) {
// ค้นหาโครงการเป้าหมายตาม UUID สาธารณะ
const foundProject = await this.dataSource.manager
.createQueryBuilder()
.select('p.id', 'id')
.from('projects', 'p')
.where('p.uuid = :uuid', { uuid: overrideProjectPublicId })
.andWhere('p.deleted_at IS NULL')
.getRawOne<{ id: number }>();
if (!foundProject) {
throw new NotFoundException('Project', overrideProjectPublicId);
}
const overrideProjectId = Number(foundProject.id);
// ตรวจสอบความสอดคล้องระดับโครงการ (Gatekeeper Rule)
if (targetProjectId !== null && targetProjectId !== overrideProjectId) {
throw new ForbiddenException(
`Cross-project boundary violation: Template is restricted to project ID ${targetProjectId} but requested override is ${overrideProjectId}`
);
}
// หากผ่านการคัดกรอง หรือเป็น Global template ให้ใช้ค่า override project ID นี้
targetProjectId = overrideProjectId;
}
let overrideContractProjectId: number | null = targetContractProjectId;
let overrideContractId: number | null = null;
if (overrideContractPublicId) {
const foundContract = await this.dataSource.manager
.createQueryBuilder()
.select(['c.id as id', 'c.project_id as projectId'])
.from('contracts', 'c')
.where('c.uuid = :uuid', { uuid: overrideContractPublicId })
.getRawOne<{ id: number; projectId: number }>();
if (!foundContract) {
throw new NotFoundException('Contract', overrideContractPublicId);
}
overrideContractId = Number(foundContract.id);
overrideContractProjectId = Number(foundContract.projectId);
if (
targetContractId !== null &&
targetContractId !== overrideContractId
) {
throw new ForbiddenException(
`Cross-contract boundary violation: Template is restricted to contract ID ${targetContractId} but requested override is ${overrideContractId}`
);
}
if (
targetProjectId !== null &&
overrideContractProjectId !== targetProjectId
) {
throw new ForbiddenException(
`Cross-project boundary violation: Contract belongs to project ID ${overrideContractProjectId} but requested project is ${targetProjectId}`
);
}
}
const targetContractIdResolved =
overrideContractId !== null ? overrideContractId : targetContractId;
if (targetProjectId === null && overrideContractProjectId !== null) {
targetProjectId = overrideContractProjectId;
}
// 2. ดึง Master Data ภายใต้ Project/Contract scope ที่จำกัด
const projectsQuery = this.dataSource.manager
.createQueryBuilder()
.select([
'p.project_code as projectCode',
'p.uuid as uuid',
'p.project_name as projectName',
])
.from('projects', 'p')
.where('p.deleted_at IS NULL');
if (targetProjectId) {
projectsQuery.andWhere('p.id = :projectId', {
projectId: targetProjectId,
});
}
const projects = await projectsQuery.getRawMany<{
projectCode: string;
uuid: string;
projectName: string;
}>();
const orgsQuery = this.dataSource.manager
.createQueryBuilder()
.select([
'o.organization_code as organizationCode',
'o.uuid as uuid',
'o.organization_name as organizationName',
])
.from('organizations', 'o')
.where('o.deleted_at IS NULL');
if (targetProjectId) {
// ค้นหาองค์กรที่ผูกอยู่ในโครงการนั้นๆ
orgsQuery
.innerJoin('project_organizations', 'po', 'po.organization_id = o.id')
.andWhere('po.project_id = :projectId', { projectId: targetProjectId });
}
const organizations = await orgsQuery.getRawMany<{
organizationCode: string;
uuid: string;
organizationName: string;
}>();
const disciplinesQuery = this.dataSource.manager
.createQueryBuilder()
.select([
'd.discipline_code as disciplineCode',
'd.code_name_th as codeNameTh',
])
.from('disciplines', 'd')
.where('d.is_active = 1');
if (targetContractIdResolved) {
disciplinesQuery.andWhere('d.contract_id = :contractId', {
contractId: targetContractIdResolved,
});
} else if (targetProjectId) {
// ดึงจากสัญญาทั้งหมดที่อยู่ภายใต้โครงการเป้าหมาย
disciplinesQuery
.innerJoin('contracts', 'c', 'c.id = d.contract_id')
.andWhere('c.project_id = :projectId', { projectId: targetProjectId });
}
const disciplines = await disciplinesQuery.getRawMany<{
disciplineCode: string;
codeNameTh: string;
}>();
const correspondenceTypes = await this.dataSource.manager
.createQueryBuilder()
.select(['t.type_code as typeCode', 't.type_name as typeName'])
.from('correspondence_types', 't')
.where('t.is_active = 1')
.andWhere('t.deleted_at IS NULL')
.getRawMany<{ typeCode: string; typeName: string }>();
const tagsQuery = this.dataSource.manager
.createQueryBuilder()
.select(['tg.tag_name as tagName', 'tg.color_code as colorCode'])
.from('tags', 'tg')
.where('tg.deleted_at IS NULL');
if (targetProjectId) {
tagsQuery.andWhere(
'(tg.project_id = :projectId OR tg.project_id IS NULL)',
{ projectId: targetProjectId }
);
} else {
tagsQuery.andWhere('tg.project_id IS NULL');
}
const tags = await tagsQuery.getRawMany<{
tagName: string;
colorCode: string;
}>();
return {
availableProjects: projects.map((p) => ({
code: p.projectCode,
uuid: p.uuid,
name: p.projectName,
})),
availableOrganizations: organizations.map((o) => ({
code: o.organizationCode,
uuid: o.uuid,
name: o.organizationName,
})),
availableDisciplines: disciplines.map((d) => ({
code: d.disciplineCode,
name: d.codeNameTh,
})),
availableCorrespondenceTypes: correspondenceTypes.map((t) => ({
code: t.typeCode,
name: t.typeName,
})),
availableTags: tags.map((t) => ({
name: tgName(t.tagName),
color: t.colorCode,
})),
};
}
/**
* ดึงรายการเวอร์ชันทั้งหมดของ prompt_type ที่กำหนด
* @param promptType ประเภทของ prompt (เช่น 'ocr_extraction')
* @returns รายการ prompt versions เรียงตาม versionNumber ล่าสุดก่อน
*/
async findAll(promptType: string): Promise<AiPrompt[]> {
const cacheKey = `${this.cachePrefix}versions:${promptType}`;
try {
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as AiPrompt[];
}
} catch (err: unknown) {
this.logger.warn(
`Redis unavailable, falling back to DB query: ${err instanceof Error ? err.message : String(err)}`
);
}
const prompts = await this.aiPromptRepo.find({
where: { promptType },
order: { versionNumber: 'DESC' },
});
try {
await this.redis.setex(cacheKey, 60, JSON.stringify(prompts));
} catch (err: unknown) {
this.logger.warn(
`Failed to set Redis cache: ${err instanceof Error ? err.message : String(err)}`
);
}
return prompts;
}
/**
* ดึง Active prompt จาก Redis cache หรือ DB fallback
* @param promptType ประเภทของ prompt
* @returns Prompt version ที่เปิดใช้งานอยู่ หรือ null หากไม่พบ
*/
async getActive(promptType: string): Promise<AiPrompt | null> {
const cacheKey = `${this.cachePrefix}${promptType}`;
try {
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as AiPrompt;
}
} catch (err: unknown) {
this.logger.warn(
`Redis unavailable, falling back to DB query: ${err instanceof Error ? err.message : String(err)}`
);
}
const prompt = await this.aiPromptRepo.findOne({
where: { promptType, isActive: true },
});
if (prompt) {
try {
await this.redis.setex(cacheKey, 60, JSON.stringify(prompt));
} catch (err: unknown) {
this.logger.warn(
`Failed to set Redis cache: ${err instanceof Error ? err.message : String(err)}`
);
}
}
return prompt;
}
/**
* ดึง Prompt version ตาม versionNumber ที่ระบุ
* @param promptType ประเภทของ prompt
* @param versionNumber เลข version ที่ต้องการ
* @returns Prompt version ที่ตรงกับ versionNumber หรือ null หากไม่พบ
*/
async findByVersion(
promptType: string,
versionNumber: number
): Promise<AiPrompt | null> {
return this.aiPromptRepo.findOne({
where: { promptType, versionNumber },
});
}
/**
* ค้นหา prompt ที่มีผลใช้งานจริง และแทนที่ placeholder {{ocr_text}} ด้วยข้อความ OCR
* @param promptType ประเภทของ prompt
* @param ocrText ข้อความที่สกัดจาก OCR
* @returns Prompt ที่แทนที่ placeholder แล้ว พร้อม version number
* @throws BusinessException หากไม่พบ active prompt
*/
async resolveActive(
promptType: string,
ocrText: string
): Promise<{ resolvedPrompt: string; versionNumber: number }> {
const prompt = await this.getActive(promptType);
if (!prompt) {
throw new BusinessException(
'NO_ACTIVE_PROMPT',
`No active prompt found for type: ${promptType}`,
'ไม่พบ Prompt Version ที่เปิดใช้งานในระบบ'
);
}
const resolvedPrompt = prompt.template.replace('{{ocr_text}}', ocrText);
return { resolvedPrompt, versionNumber: prompt.versionNumber };
}
/**
* สร้าง Prompt Version ใหม่พร้อมการตรวจสอบ placeholder และ character limit
* @param promptType ประเภทของ prompt
* @param dto ข้อมูล template และ contextConfig
* @param userId ID ของผู้สร้าง
* @returns Prompt version ที่สร้างใหม่
* @throws ValidationException หาก template ไม่มี placeholder หรือเกิน character limit
*/
async create(
promptType: string,
dto: CreateAiPromptDto,
userId: number
): Promise<AiPrompt> {
// ocr_system: free-form system prompt, no required placeholders
if (promptType === 'ocr_system') {
// No validation required - system prompt is free-form
} else if (promptType === 'ocr_extraction') {
if (!dto.template.includes('{{ocr_text}}')) {
throw new ValidationException(
'template ต้องมี {{ocr_text}} placeholder'
);
}
} else if (promptType === 'rag_query_prompt') {
if (
!dto.template.includes('{{query}}') ||
!dto.template.includes('{{context}}')
) {
throw new ValidationException(
'template ต้องมี {{query}} และ {{context}} placeholder'
);
}
} else if (promptType === 'rag_prep_prompt') {
if (!dto.template.includes('{{text}}')) {
throw new ValidationException('template ต้องมี {{text}} placeholder');
}
} else if (promptType === 'classification_prompt') {
if (!dto.template.includes('{{document_text}}')) {
throw new ValidationException(
'template ต้องมี {{document_text}} placeholder'
);
}
}
if (dto.template.length > 4000) {
throw new ValidationException('Template exceeds 4,000 character limit');
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const maxVersionResult = await queryRunner.manager
.createQueryBuilder(AiPrompt, 'prompt')
.select('MAX(prompt.versionNumber)', 'max')
.where('prompt.promptType = :promptType', { promptType })
.setLock('pessimistic_write')
.getRawOne<{ max: number | string | null }>();
const nextVersion =
(maxVersionResult?.max ? Number(maxVersionResult.max) : 0) + 1;
const newPrompt = this.aiPromptRepo.create({
publicId: randomUUID(),
promptType,
versionNumber: nextVersion,
template: dto.template,
fieldSchema: null,
contextConfig: dto.contextConfig || null,
isActive: false,
createdBy: userId,
});
const savedPrompt = await queryRunner.manager.save(newPrompt);
await queryRunner.commitTransaction();
try {
const cacheKey = `${this.cachePrefix}versions:${promptType}`;
await this.redis.del(cacheKey);
} catch (err: unknown) {
this.logger.warn(
`Failed to clear Redis cache after create: ${err instanceof Error ? err.message : String(err)}`
);
}
await this.saveAuditLog(
'AI_PROMPT_CREATED',
String(savedPrompt.id),
{ promptType, versionNumber: nextVersion, userId },
userId
);
return savedPrompt;
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
/**
* เปิดใช้งานเวอร์ชันที่กำหนด และยกเลิกการใช้งานเวอร์ชันอื่นทั้งหมดภายใต้ prompt_type เดียวกัน
* @param promptType ประเภทของ prompt
* @param versionNumber เลขเวอร์ชันที่ต้องการเปิดใช้งาน
* @param userId ID ของผู้ดำเนินการ
* @param expectedVersion เวอร์ชันที่คาดหวังสำหรับ optimistic locking (optional)
* @returns Prompt version ที่เปิดใช้งานแล้ว
* @throws NotFoundException หากไม่พบ prompt version
* @throws ConflictException หาก version mismatch (optimistic locking)
*/
async activate(
promptType: string,
versionNumber: number,
userId: number,
expectedVersion?: number
): Promise<AiPrompt> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const promptToActivate = await queryRunner.manager.findOne(AiPrompt, {
where: { promptType, versionNumber },
lock: { mode: 'pessimistic_write' },
});
if (!promptToActivate) {
throw new NotFoundException('AiPrompt', versionNumber.toString());
}
// Optimistic locking check
if (
expectedVersion !== undefined &&
promptToActivate.version !== expectedVersion
) {
throw new ConflictException(
`Version mismatch: expected ${expectedVersion}, but current is ${promptToActivate.version}. Data was modified by another user.`
);
}
await queryRunner.manager.find(AiPrompt, {
where: { promptType, isActive: true },
lock: { mode: 'pessimistic_write' },
});
await queryRunner.manager.update(
AiPrompt,
{ promptType, isActive: true },
{ isActive: false }
);
promptToActivate.isActive = true;
promptToActivate.activatedAt = new Date();
const activatedPrompt = await queryRunner.manager.save(promptToActivate);
await queryRunner.commitTransaction();
try {
const cacheKey = `${this.cachePrefix}${promptType}`;
await this.redis.del(cacheKey);
const versionsCacheKey = `${this.cachePrefix}versions:${promptType}`;
await this.redis.del(versionsCacheKey);
} catch (err: unknown) {
this.logger.warn(
`Failed to clear Redis cache after activation: ${err instanceof Error ? err.message : String(err)}`
);
}
await this.saveAuditLog(
'AI_PROMPT_ACTIVATED',
String(activatedPrompt.id),
{ promptType, versionNumber, userId },
userId
);
return activatedPrompt;
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
/**
* ลบเวอร์ชันที่ไม่ได้ใช้งาน (ห้ามลบเวอร์ชันที่เป็น active)
* @param promptType ประเภทของ prompt
* @param versionNumber เลขเวอร์ชันที่ต้องการลบ
* @param userId ID ของผู้ดำเนินการ
* @throws NotFoundException หากไม่พบ prompt version
* @throws BusinessException หากพยายามลบ active version
*/
async delete(
promptType: string,
versionNumber: number,
userId: number
): Promise<void> {
const prompt = await this.aiPromptRepo.findOne({
where: { promptType, versionNumber },
});
if (!prompt) {
throw new NotFoundException('AiPrompt', versionNumber.toString());
}
if (prompt.isActive) {
throw new BusinessException(
'CANNOT_DELETE_ACTIVE_PROMPT',
'Cannot delete active prompt version',
'ไม่สามารถลบ active version ได้'
);
}
await this.aiPromptRepo.remove(prompt);
try {
const cacheKey = `${this.cachePrefix}versions:${promptType}`;
await this.redis.del(cacheKey);
} catch (err: unknown) {
this.logger.warn(
`Failed to clear Redis cache after delete: ${err instanceof Error ? err.message : String(err)}`
);
}
await this.saveAuditLog(
'AI_PROMPT_DELETED',
String(prompt.id),
{ promptType, versionNumber, userId },
userId
);
}
/**
* อัปเดต manual note ของเวอร์ชันที่กำหนด
* @param promptType ประเภทของ prompt
* @param versionNumber เลขเวอร์ชัน
* @param note ข้อความ note หรือ null หากต้องการลบ
* @returns Prompt version ที่อัปเดตแล้ว
* @throws NotFoundException หากไม่พบ prompt version
* @throws BusinessException หากเกิด optimistic locking conflict
*/
async updateNote(
promptType: string,
versionNumber: number,
note: string | null
): Promise<AiPrompt> {
try {
const prompt = await this.aiPromptRepo.findOne({
where: { promptType, versionNumber },
});
if (!prompt) {
throw new NotFoundException('AiPrompt', versionNumber.toString());
}
prompt.manualNote = note;
return this.aiPromptRepo.save(prompt);
} catch (err: unknown) {
if (err instanceof NotFoundException) {
throw err;
}
// Handle optimistic locking conflict
if (err instanceof Error && err.message.includes('optimistic')) {
throw new BusinessException(
'OPTIMISTIC_LOCK_CONFLICT',
'This prompt version was modified by another user. Please refresh and try again.',
'ข้อมูลถูกแก้ไขโดยผู้ใช้อื่น กรุณารีเฟรชแล้วลองใหม่'
);
}
this.logger.error(
`Failed to update prompt note: ${err instanceof Error ? err.message : String(err)}`
);
throw new BusinessException(
'UPDATE_NOTE_FAILED',
'Failed to update prompt note',
'ไม่สามารถอัปเดต note ได้ กรุณาลองใหม่'
);
}
}
/**
* บันทึกผลทดสอบของเวอร์ชันหลังจากรัน OCR Sandbox
* @param promptType ประเภทของ prompt
* @param versionNumber เลขเวอร์ชัน
* @param resultJson ผลลัพธ์การทดสอบในรูป JSON
*/
async saveTestResult(
promptType: string,
versionNumber: number,
resultJson: Record<string, unknown>
): Promise<void> {
const prompt = await this.aiPromptRepo.findOne({
where: { promptType, versionNumber },
});
if (prompt) {
prompt.testResultJson = resultJson;
prompt.lastTestedAt = new Date();
await this.aiPromptRepo.save(prompt);
}
}
/**
* ดึง Context Config ของ Prompt Version ที่กำหนด
*/
async getContextConfig(
promptType: string,
versionNumber: number
): Promise<Record<string, unknown> | null> {
const prompt = await this.aiPromptRepo.findOne({
where: { promptType, versionNumber },
});
if (!prompt) {
throw new NotFoundException('AiPrompt', versionNumber.toString());
}
return prompt.contextConfig;
}
/**
* อัปเดต Context Config ของ Prompt Version ที่กำหนด พร้อมทั้งตรวจเช็คความถูกต้องของโครงการและสัญญาใน DB
* @throws NotFoundException หากไม่พบ prompt version
* @throws BusinessException หากเกิด optimistic locking conflict
* @throws ValidationException หาก context config ไม่ถูกต้อง (T068)
*/
async updateContextConfig(
promptType: string,
versionNumber: number,
dto: ContextConfigDto
): Promise<Record<string, unknown>> {
try {
const prompt = await this.aiPromptRepo.findOne({
where: { promptType, versionNumber },
});
if (!prompt) {
throw new NotFoundException('AiPrompt', versionNumber.toString());
}
// Validation (T068): ตรวจสอบค่าของ context config
if (dto.pageSize < 1 || dto.pageSize > 1000) {
throw new ValidationException('pageSize must be between 1 and 1000');
}
if (!dto.language || dto.language.trim().length === 0) {
throw new ValidationException('language is required');
}
if (!dto.outputLanguage || dto.outputLanguage.trim().length === 0) {
throw new ValidationException('outputLanguage is required');
}
// Validation (T027): ตรวจสอบโครงการ/สัญญาใน DB
if (dto.filter?.projectId) {
const projectExists = (await this.dataSource.manager
.createQueryBuilder()
.select('p.id')
.from('projects', 'p')
.where('p.uuid = :uuid', { uuid: dto.filter.projectId })
.andWhere('p.deleted_at IS NULL')
.getRawOne()) as unknown;
if (!projectExists) {
throw new NotFoundException('Project', dto.filter.projectId);
}
}
if (dto.filter?.contractId) {
const contractExists = (await this.dataSource.manager
.createQueryBuilder()
.select('c.id')
.from('contracts', 'c')
.where('c.uuid = :uuid', { uuid: dto.filter.contractId })
.getRawOne()) as unknown;
if (!contractExists) {
throw new NotFoundException('Contract', dto.filter.contractId);
}
}
// บันทึกลง DB
const newContextConfig = {
filter: dto.filter || null,
pageSize: dto.pageSize,
language: dto.language,
outputLanguage: dto.outputLanguage,
};
prompt.contextConfig = newContextConfig;
await this.aiPromptRepo.save(prompt);
return newContextConfig;
} catch (err: unknown) {
if (
err instanceof NotFoundException ||
err instanceof ValidationException
) {
throw err;
}
// Handle optimistic locking conflict
if (err instanceof Error && err.message.includes('optimistic')) {
throw new BusinessException(
'OPTIMISTIC_LOCK_CONFLICT',
'This prompt version was modified by another user. Please refresh and try again.',
'ข้อมูลถูกแก้ไขโดยผู้ใช้อื่น กรุณารีเฟรชแล้วลองใหม่'
);
}
this.logger.error(
`Failed to update context config: ${err instanceof Error ? err.message : String(err)}`
);
throw new BusinessException(
'UPDATE_CONTEXT_CONFIG_FAILED',
'Failed to update context config',
'ไม่สามารถอัปเดต context config ได้ กรุณาลองใหม่'
);
}
}
/**
* บันทึกข้อมูลการปฏิบัติการของผู้ใช้ลงในตารางหลัก audit_logs
*/
private async saveAuditLog(
action: string,
entityId: string,
detailsJson: Record<string, unknown>,
userId?: number
): Promise<void> {
try {
const auditLog = this.auditLogRepo.create({
action,
severity: 'INFO',
entityType: 'AiPrompt',
entityId,
detailsJson,
userId,
});
await this.auditLogRepo.save(auditLog);
} catch (err: unknown) {
this.logger.error(
`Failed to save audit log: ${err instanceof Error ? err.message : String(err)}`
);
}
}
}
/** Helper function to sanitize tag name */
function tgName(name: unknown): string {
return typeof name === 'string' ? name.trim() : '';
}