improvement: attachment selection for lab dispatch added. attachment preview added to cases feature.

This commit is contained in:
2026-07-07 18:43:10 +03:30
parent b2d40b3e97
commit 86b1e3afff
25 changed files with 767 additions and 84 deletions

View File

@@ -6,8 +6,10 @@ import {
Patch,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import type { Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -42,6 +44,26 @@ export class CasesController {
return this.casesService.getOne(id, organizationId, req.user.id, req.user.language);
}
@Get(':id/attachments/:attachmentId/file')
@ApiOperation({ summary: 'Download an attachment shared with this lab case' })
async downloadAttachment(
@Param('id') id: string,
@Param('attachmentId') attachmentId: string,
@Req() req,
@Res() res: Response,
) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
const file = await this.casesService.streamCaseAttachment(
id,
attachmentId,
organizationId,
req.user.id,
);
res.setHeader('Content-Type', file.mimeType);
res.setHeader('Content-Disposition', `inline; filename="${file.fileName}"`);
file.stream.pipe(res);
}
@Patch(':id/tasks/:taskId')
@ApiOperation({ summary: 'Toggle task important flag' })
updateTask(

View File

@@ -4,6 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { createReadStream, existsSync } from 'fs';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
@@ -54,6 +55,20 @@ const labCaseListInclude = {
},
},
},
toothProsthesis: true,
attachments: {
include: {
attachment: {
select: {
id: true,
fileName: true,
mimeType: true,
sizeBytes: true,
createdAt: true,
},
},
},
},
} satisfies Prisma.LabCaseInclude;
type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
@@ -283,6 +298,43 @@ export class CasesService {
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
}
async streamCaseAttachment(
labCaseId: string,
attachmentId: string,
labOrganizationId: string,
actorUserId: string,
) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
const link = await this.prisma.labCaseAttachment.findFirst({
where: {
labCaseId,
attachmentId,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
include: {
attachment: { select: { storagePath: true, fileName: true, mimeType: true } },
},
});
if (!link?.attachment) {
throw new NotFoundException('Attachment not found');
}
if (!existsSync(link.attachment.storagePath)) {
throw new NotFoundException('Attachment file is missing on disk');
}
return {
stream: createReadStream(link.attachment.storagePath),
fileName: link.attachment.fileName,
mimeType: link.attachment.mimeType,
};
}
async updateTask(
labCaseId: string,
taskId: string,
@@ -457,6 +509,18 @@ export class CasesService {
teeth: normalizeTeeth(link.detail.teeth),
comment: link.detail.comment,
})),
toothProsthesis: lc.toothProsthesis.map((row) => ({
treatmentDetailId: row.treatmentDetailId,
tooth: row.tooth,
prosthesisTypeCode: row.prosthesisTypeCode,
})),
attachments: lc.attachments.map((row) => ({
id: row.attachment.id,
fileName: row.attachment.fileName,
mimeType: row.attachment.mimeType,
sizeBytes: row.attachment.sizeBytes,
createdAt: row.attachment.createdAt.toISOString(),
})),
sends: lc.sends.map((s) => ({
organizationId: s.organizationId,
organizationName: s.organization.name,

View File

@@ -81,6 +81,11 @@ export class SaveLabCaseDto {
@ValidateNested({ each: true })
@Type(() => LabCaseToothProsthesisDto)
toothProsthesis?: LabCaseToothProsthesisDto[];
@IsOptional()
@IsArray()
@IsUUID(undefined, { each: true })
attachmentIds?: string[];
}
export class SaveTreatmentLabCasesDto {

View File

@@ -56,6 +56,13 @@ const treatmentInclude = {
include: { organization: { select: { id: true, name: true } } },
},
toothProsthesis: true,
attachments: {
include: {
attachment: {
select: { id: true, fileName: true, mimeType: true, sizeBytes: true, createdAt: true },
},
},
},
},
},
};
@@ -427,6 +434,29 @@ export class TreatmentsService {
})),
});
}
await tx.labCaseAttachment.deleteMany({ where: { labCaseId: row.id } });
const attachmentIds = lc.attachmentIds ?? [];
if (attachmentIds.length > 0) {
const validAttachments = await tx.treatmentDetailAttachment.findMany({
where: {
id: { in: attachmentIds },
detailId: { in: lc.treatmentDetailIds },
},
select: { id: true },
});
if (validAttachments.length !== attachmentIds.length) {
throw new BadRequestException(
'One or more attachments are invalid for this lab case',
);
}
await tx.labCaseAttachment.createMany({
data: attachmentIds.map((attachmentId) => ({
labCaseId: row.id,
attachmentId,
})),
});
}
}
return tx.treatment.findUniqueOrThrow({
@@ -766,6 +796,15 @@ export class TreatmentsService {
tooth: string;
prosthesisTypeCode: string;
}>;
attachments?: Array<{
attachment: {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: Date;
};
}>;
}) {
return {
id: lc.id,
@@ -784,6 +823,13 @@ export class TreatmentsService {
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
attachments: (lc.attachments ?? []).map((row) => ({
id: row.attachment.id,
fileName: row.attachment.fileName,
mimeType: row.attachment.mimeType,
sizeBytes: row.attachment.sizeBytes,
createdAt: row.attachment.createdAt.toISOString(),
})),
sends:
lc.sends?.map((s) => ({
organizationId: s.organizationId,