improvement: attachment selection for lab dispatch added. attachment preview added to cases feature.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
-- Per-shipment attachment selection: only checked files are visible to the lab.
|
||||
CREATE TABLE "lab_case_attachments" (
|
||||
"labCaseId" TEXT NOT NULL,
|
||||
"attachmentId" TEXT NOT NULL,
|
||||
CONSTRAINT "lab_case_attachments_pkey" PRIMARY KEY ("labCaseId", "attachmentId")
|
||||
);
|
||||
|
||||
CREATE INDEX "lab_case_attachments_attachmentId_idx" ON "lab_case_attachments"("attachmentId");
|
||||
|
||||
ALTER TABLE "lab_case_attachments"
|
||||
ADD CONSTRAINT "lab_case_attachments_labCaseId_fkey"
|
||||
FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "lab_case_attachments"
|
||||
ADD CONSTRAINT "lab_case_attachments_attachmentId_fkey"
|
||||
FOREIGN KEY ("attachmentId") REFERENCES "treatment_detail_attachments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -23,6 +23,7 @@ const prisma = new PrismaClient();
|
||||
const TABLES_IN_ORDER = [
|
||||
'lab_case_task_status_events',
|
||||
'lab_case_comments',
|
||||
'lab_case_attachments',
|
||||
'lab_case_tasks',
|
||||
'lab_case_sends',
|
||||
'lab_case_tooth_prosthesis',
|
||||
@@ -31,6 +32,7 @@ const TABLES_IN_ORDER = [
|
||||
'treatment_detail_attachments',
|
||||
'treatment_details',
|
||||
'treatments',
|
||||
'appointments',
|
||||
];
|
||||
|
||||
async function tableExists(table: string): Promise<boolean> {
|
||||
|
||||
@@ -179,6 +179,7 @@ model TreatmentDetailAttachment {
|
||||
storagePath String
|
||||
|
||||
detail TreatmentDetail? @relation(fields: [detailId], references: [id], onDelete: Cascade)
|
||||
labCaseLinks LabCaseAttachment[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -201,11 +202,24 @@ model LabCase {
|
||||
tasks LabCaseTask[]
|
||||
toothProsthesis LabCaseToothProsthesis[]
|
||||
comments LabCaseComment[]
|
||||
attachments LabCaseAttachment[]
|
||||
|
||||
@@index([treatmentId, sortOrder])
|
||||
@@map("lab_cases")
|
||||
}
|
||||
|
||||
model LabCaseAttachment {
|
||||
labCaseId String
|
||||
attachmentId String
|
||||
|
||||
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
||||
attachment TreatmentDetailAttachment @relation(fields: [attachmentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([labCaseId, attachmentId])
|
||||
@@index([attachmentId])
|
||||
@@map("lab_case_attachments")
|
||||
}
|
||||
|
||||
model LabCaseDetail {
|
||||
labCaseId String
|
||||
treatmentDetailId String @unique
|
||||
|
||||
@@ -14,8 +14,12 @@ const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const counts = {
|
||||
labCaseTaskStatusEvents: await prisma.labCaseTaskStatusEvent.count(),
|
||||
labCaseComments: await prisma.labCaseComment.count(),
|
||||
labCaseAttachments: await prisma.labCaseAttachment.count(),
|
||||
labCaseTasks: await prisma.labCaseTask.count(),
|
||||
labCaseSends: await prisma.labCaseSend.count(),
|
||||
labCaseToothProsthesis: await prisma.labCaseToothProsthesis.count(),
|
||||
labCaseDetails: await prisma.labCaseDetail.count(),
|
||||
labCases: await prisma.labCase.count(),
|
||||
attachments: await prisma.treatmentDetailAttachment.count(),
|
||||
@@ -27,8 +31,12 @@ async function main() {
|
||||
console.log('Current row counts:', counts);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.labCaseTaskStatusEvent.deleteMany(),
|
||||
prisma.labCaseComment.deleteMany(),
|
||||
prisma.labCaseAttachment.deleteMany(),
|
||||
prisma.labCaseTask.deleteMany(),
|
||||
prisma.labCaseSend.deleteMany(),
|
||||
prisma.labCaseToothProsthesis.deleteMany(),
|
||||
prisma.labCaseDetail.deleteMany(),
|
||||
prisma.labCase.deleteMany(),
|
||||
prisma.treatmentDetailAttachment.deleteMany(),
|
||||
@@ -37,7 +45,9 @@ async function main() {
|
||||
prisma.appointment.deleteMany(),
|
||||
]);
|
||||
|
||||
console.log('✅ Cleared appointments, treatments, lab cases, tasks, and attachments.');
|
||||
console.log(
|
||||
'✅ Cleared appointments, treatments, lab cases, tasks, comments, attachments, and related rows.',
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user