improvement: added a history action button to connected orgs row inordr to see a brief report of the relevant cases between two orgs and the status of each related task.

This commit is contained in:
2026-06-28 23:19:33 +03:30
parent 2a48946a51
commit 3e81c110a3
11 changed files with 739 additions and 15 deletions

View File

@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { TreatmentCatalogModule } from '../treatment-catalog/treatment-catalog.module';
import { CasesController } from './cases.controller';
import { CasesService } from './cases.service';
@Module({
imports: [TreatmentCatalogModule],
controllers: [CasesController],
providers: [CasesService, PrismaService, LabOrgGuard],
exports: [CasesService],
})
export class CasesModule {}

View File

@@ -156,6 +156,86 @@ export class CasesService {
};
}
/** Cases exchanged between one clinic and one lab (Organizations connection history). */
async listBetweenOrganizations(
clinicOrganizationId: string,
labOrganizationId: string,
query: ListLabCasesDto,
) {
if (query.treatmentType) {
this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType);
}
const page = query.page ?? 1;
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
const skip = (page - 1) * limit;
const where: Prisma.LabCaseWhereInput = {
...this.buildListWhere(labOrganizationId, query),
treatment: { organizationId: clinicOrganizationId },
sends: { some: { organizationId: labOrganizationId } },
};
const [items, total] = await Promise.all([
this.prisma.labCase.findMany({
where,
include: {
treatment: {
include: {
organization: { select: { id: true, name: true } },
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
},
},
details: {
include: {
detail: { select: { treatmentType: true } },
},
},
tasks: { select: { id: true, status: true } },
},
orderBy: [{ sentAt: 'desc' }],
skip,
take: limit,
}),
this.prisma.labCase.count({ where }),
]);
return {
success: true,
data: {
items: items.map((lc) => this.mapLabCaseListItem(lc)),
pagination: {
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
},
},
};
}
async getOneBetweenOrganizations(
labCaseId: string,
clinicOrganizationId: string,
labOrganizationId: string,
) {
const labCase = await this.prisma.labCase.findFirst({
where: {
id: labCaseId,
sentAt: { not: null },
treatment: { organizationId: clinicOrganizationId },
sends: { some: { organizationId: labOrganizationId } },
},
include: labCaseListInclude,
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
return { success: true, data: this.mapLabCaseDetail(labCase) };
}
async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) {
await this.assertCanReadCases(actorUserId, labOrganizationId);

View File

@@ -18,6 +18,7 @@ import { InviteOrganizationDto } from './dto/invite-organization.dto';
import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.dto';
import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto';
import { OrganizationService } from './organization.service';
import { ListLabCasesDto } from '../cases/dto/cases.dto';
/**
* Counterpart orgs (clinic↔lab).
@@ -121,6 +122,40 @@ export class OrganizationController {
return this.organizationService.deleteConnection(req.user.id, organizationId, connectionId);
}
@Get('connections/:connectionId/cases')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'List cases exchanged with a connected organization' })
listConnectionCases(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('connectionId') connectionId: string,
@Query() query: ListLabCasesDto,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.listConnectionCases(
req.user.id,
organizationId,
connectionId,
query,
);
}
@Get('connections/:connectionId/cases/:caseId')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get one case exchanged with a connected organization' })
getConnectionCase(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('connectionId') connectionId: string,
@Param('caseId') caseId: string,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.getConnectionCase(
req.user.id,
organizationId,
connectionId,
caseId,
);
}
@Post('invitations/:invitationId/link')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' })

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { CasesModule } from '../cases/cases.module';
import { OrganizationController } from './organization.controller';
import { OrganizationService } from './organization.service';
@Module({
imports: [CasesModule],
controllers: [OrganizationController],
providers: [OrganizationService, PrismaService],
})

View File

@@ -9,6 +9,8 @@ import { LinkStatus } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { ListLabCasesDto } from '../cases/dto/cases.dto';
import { CasesService } from '../cases/cases.service';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
@@ -28,7 +30,10 @@ import { RespondConnectionRequestDto } from './dto/respond-connection-request.dt
*/
@Injectable()
export class OrganizationService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly casesService: CasesService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
@@ -340,6 +345,64 @@ export class OrganizationService {
};
}
async listConnectionCases(
userId: string,
organizationId: string,
connectionId: string,
query: ListLabCasesDto,
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const { clinicOrganizationId, labOrganizationId, counterpart } =
await this.resolveActiveConnectionParties(connectionId, organizationId, actor);
const result = await this.casesService.listBetweenOrganizations(
clinicOrganizationId,
labOrganizationId,
query,
);
return {
...result,
data: {
...result.data,
counterpart,
},
};
}
async getConnectionCase(
userId: string,
organizationId: string,
connectionId: string,
caseId: string,
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const { clinicOrganizationId, labOrganizationId, counterpart } =
await this.resolveActiveConnectionParties(connectionId, organizationId, actor);
const result = await this.casesService.getOneBetweenOrganizations(
caseId,
clinicOrganizationId,
labOrganizationId,
);
return {
...result,
data: {
...result.data,
counterpart,
},
};
}
/** Re-issue a shareable URL for a pending invitation (rotates token; previous URL stops working). */
async getInvitationLink(userId: string, organizationId: string, invitationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
@@ -640,6 +703,58 @@ export class OrganizationService {
};
}
private async resolveActiveConnectionParties(
connectionId: string,
organizationId: string,
actor: {
organization: { type: { name: string } };
},
) {
const link = await this.prisma.organizationLink.findFirst({
where: {
id: connectionId,
status: LinkStatus.ACTIVE,
OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }],
},
include: {
organizationA: { select: { id: true, name: true, type: true } },
organizationB: { select: { id: true, name: true, type: true } },
},
});
if (!link) {
throw new NotFoundException('Connected organization not found');
}
const counterpart =
link.organizationAId === organizationId ? link.organizationB : link.organizationA;
const orgType = actor.organization.type.name;
if (orgType === 'CLINIC') {
if (counterpart.type.name !== 'LAB') {
throw new BadRequestException('Counterpart organization is not a lab');
}
return {
clinicOrganizationId: organizationId,
labOrganizationId: counterpart.id,
counterpart: { id: counterpart.id, name: counterpart.name },
};
}
if (orgType === 'LAB') {
if (counterpart.type.name !== 'CLINIC') {
throw new BadRequestException('Counterpart organization is not a clinic');
}
return {
clinicOrganizationId: counterpart.id,
labOrganizationId: organizationId,
counterpart: { id: counterpart.id, name: counterpart.name },
};
}
throw new BadRequestException('Unknown organization type');
}
private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },