improvement: QRcode and shared link added to cases inorder to make it possible for staff users to share a case info with other staffs or other clinics.
This commit is contained in:
@@ -42,6 +42,7 @@ export const ErrorCode = {
|
||||
PERMISSION_ACCESS_STAFF: 'PERMISSION_ACCESS_STAFF',
|
||||
PERMISSION_EDIT_STAFF: 'PERMISSION_EDIT_STAFF',
|
||||
PERMISSION_ORG_NOT_FOUND: 'PERMISSION_ORG_NOT_FOUND',
|
||||
LAB_CASE_ACCESS_DENIED: 'LAB_CASE_ACCESS_DENIED',
|
||||
|
||||
// Validation
|
||||
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
||||
|
||||
11
backend/src/common/lab-case-access-token.ts
Normal file
11
backend/src/common/lab-case-access-token.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export function generateLabCaseAccessToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
export function buildLabCaseShareUrl(accessToken: string, locale = 'en'): string {
|
||||
const appUrl = (process.env.FRONTEND_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const normalizedLocale = locale.trim() || 'en';
|
||||
return `${appUrl}/${normalizedLocale}/lab-case/${accessToken}`;
|
||||
}
|
||||
@@ -3,13 +3,18 @@ import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
|
||||
import { CasesController } from './cases.controller';
|
||||
import { CasesService } from './cases.service';
|
||||
import { LabCaseAccessController } from './lab-case-access.controller';
|
||||
import { LabCaseAccessService } from './lab-case-access.service';
|
||||
|
||||
@Module({
|
||||
imports: [ProsthesisCatalogModule, NotificationsModule],
|
||||
controllers: [CasesController],
|
||||
providers: [CasesService, PrismaService, LabOrgGuard],
|
||||
exports: [CasesService],
|
||||
imports: [ProsthesisCatalogModule, NotificationsModule, TasksModule, CatalogModule, LabCaseCommentsModule],
|
||||
controllers: [LabCaseAccessController, CasesController],
|
||||
providers: [CasesService, LabCaseAccessService, PrismaService, LabOrgGuard],
|
||||
exports: [CasesService, LabCaseAccessService],
|
||||
})
|
||||
export class CasesModule {}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { normalizeTaskTeeth } from './lab-case-task.util';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
|
||||
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
||||
import { LabCaseAccessService } from './lab-case-access.service';
|
||||
|
||||
const labCaseListInclude = {
|
||||
treatment: {
|
||||
@@ -95,6 +96,7 @@ export class CasesService {
|
||||
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||
private readonly catalogLabels: CatalogLabelService,
|
||||
private readonly labCaseActivity: LabCaseActivityService,
|
||||
private readonly labCaseAccess: LabCaseAccessService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -605,6 +607,12 @@ export class CasesService {
|
||||
locale,
|
||||
);
|
||||
const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels);
|
||||
const accessToken = lc.sentAt
|
||||
? await this.labCaseAccess.ensureAccessToken(lc.id)
|
||||
: null;
|
||||
const shareUrl = accessToken
|
||||
? this.labCaseAccess.buildShareUrl(accessToken, locale)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: lc.id,
|
||||
@@ -612,6 +620,7 @@ export class CasesService {
|
||||
dueDate: lc.dueDate?.toISOString() ?? null,
|
||||
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
|
||||
isImportant: lc.isImportant,
|
||||
shareUrl,
|
||||
clinic: lc.treatment.organization,
|
||||
patient: lc.treatment.patient,
|
||||
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
||||
|
||||
89
backend/src/modules/cases/lab-case-access.controller.ts
Normal file
89
backend/src/modules/cases/lab-case-access.controller.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Body, Controller, Get, HttpStatus, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { LabCaseAccessService } from './lab-case-access.service';
|
||||
import {
|
||||
CreateLabCaseCommentDto,
|
||||
SetCommentVisibilityDto,
|
||||
} from '../lab-case-comments/dto/lab-case-comment.dto';
|
||||
|
||||
@ApiTags('lab-cases')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('lab-cases/access')
|
||||
export class LabCaseAccessController {
|
||||
constructor(private readonly accessService: LabCaseAccessService) {}
|
||||
|
||||
private requireOrganizationId(req: { user?: { organizationId?: string } }): string {
|
||||
const organizationId = req.user?.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return organizationId;
|
||||
}
|
||||
|
||||
@Get(':token')
|
||||
@ApiOperation({ summary: 'Resolve a lab case share link for the current user' })
|
||||
resolve(@Param('token') token: string, @Req() req) {
|
||||
return this.accessService.resolveByToken(
|
||||
token,
|
||||
req.user.id,
|
||||
this.requireOrganizationId(req),
|
||||
req.user.language,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':token/tasks')
|
||||
@ApiOperation({ summary: 'List all tasks for a lab case share link' })
|
||||
listTasks(@Param('token') token: string, @Req() req) {
|
||||
return this.accessService.listTasksByToken(
|
||||
token,
|
||||
req.user.id,
|
||||
this.requireOrganizationId(req),
|
||||
req.user.language,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':token/comments')
|
||||
@ApiOperation({ summary: 'List comments for a lab case share link' })
|
||||
listComments(@Param('token') token: string, @Req() req) {
|
||||
return this.accessService.listCommentsByToken(
|
||||
token,
|
||||
req.user.id,
|
||||
this.requireOrganizationId(req),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':token/comments')
|
||||
@ApiOperation({ summary: 'Add a comment via a lab case share link' })
|
||||
addComment(
|
||||
@Param('token') token: string,
|
||||
@Body() dto: CreateLabCaseCommentDto,
|
||||
@Req() req,
|
||||
) {
|
||||
return this.accessService.addCommentByToken(
|
||||
token,
|
||||
req.user.id,
|
||||
this.requireOrganizationId(req),
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':token/comments/:commentId/visibility')
|
||||
@ApiOperation({ summary: 'Toggle clinic visibility for a lab comment on a share link' })
|
||||
setCommentVisibility(
|
||||
@Param('token') token: string,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() dto: SetCommentVisibilityDto,
|
||||
@Req() req,
|
||||
) {
|
||||
return this.accessService.setCommentVisibilityByToken(
|
||||
token,
|
||||
commentId,
|
||||
req.user.id,
|
||||
this.requireOrganizationId(req),
|
||||
dto.visibleToClinic,
|
||||
);
|
||||
}
|
||||
}
|
||||
329
backend/src/modules/cases/lab-case-access.service.ts
Normal file
329
backend/src/modules/cases/lab-case-access.service.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import {
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CatalogEntityKind, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import {
|
||||
buildLabCaseShareUrl,
|
||||
generateLabCaseAccessToken,
|
||||
} from '../../common/lab-case-access-token';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import {
|
||||
CatalogLabelService,
|
||||
normalizeCatalogLocale,
|
||||
} from '../catalog/catalog-label.service';
|
||||
import { isActorTreatmentProvider } from '../../common/treatment-provider-scope';
|
||||
import { normalizeTaskTeeth } from './lab-case-task.util';
|
||||
import { isLabCaseOverdue } from '../../common/lab-case-due-date';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
|
||||
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
|
||||
|
||||
const accessCaseInclude = {
|
||||
treatment: {
|
||||
select: {
|
||||
providerUserId: true,
|
||||
organization: { select: { id: true, name: true } },
|
||||
patient: { select: { id: true, firstName: true, lastName: true } },
|
||||
appointment: { select: { providerUserId: true } },
|
||||
},
|
||||
},
|
||||
sends: {
|
||||
orderBy: [{ sentAt: 'asc' as const }],
|
||||
include: { organization: { select: { id: true, name: true } } },
|
||||
},
|
||||
tasks: { select: { status: true } },
|
||||
} satisfies Prisma.LabCaseInclude;
|
||||
|
||||
type ResolvedAccess =
|
||||
| {
|
||||
kind: 'lab';
|
||||
canEditTaskStatus: boolean;
|
||||
canPostComments: boolean;
|
||||
canToggleCommentVisibility: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'clinic';
|
||||
canEditTaskStatus: false;
|
||||
canPostComments: boolean;
|
||||
canToggleCommentVisibility: false;
|
||||
}
|
||||
| { kind: 'denied' };
|
||||
|
||||
@Injectable()
|
||||
export class LabCaseAccessService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalogLabels: CatalogLabelService,
|
||||
private readonly tasksService: TasksService,
|
||||
private readonly commentsService: LabCaseCommentsService,
|
||||
) {}
|
||||
|
||||
async ensureAccessToken(labCaseId: string): Promise<string | null> {
|
||||
const labCase = await this.prisma.labCase.findUnique({
|
||||
where: { id: labCaseId },
|
||||
select: { sentAt: true, accessToken: true },
|
||||
});
|
||||
if (!labCase?.sentAt) return null;
|
||||
if (labCase.accessToken) return labCase.accessToken;
|
||||
|
||||
const accessToken = generateLabCaseAccessToken();
|
||||
await this.prisma.labCase.update({
|
||||
where: { id: labCaseId },
|
||||
data: { accessToken },
|
||||
});
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
buildShareUrl(accessToken: string, locale?: string | null): string {
|
||||
return buildLabCaseShareUrl(accessToken, locale ?? 'en');
|
||||
}
|
||||
|
||||
async resolveByToken(
|
||||
token: string,
|
||||
actorUserId: string,
|
||||
organizationId: string,
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
const labCase = await this.findCaseByToken(token);
|
||||
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
|
||||
|
||||
if (access.kind === 'denied') {
|
||||
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
const prosthesisCodes = [
|
||||
...new Set(
|
||||
(
|
||||
await this.prisma.labCaseTask.findMany({
|
||||
where: { labCaseId: labCase.id },
|
||||
select: { prosthesisTypeCode: true },
|
||||
})
|
||||
)
|
||||
.map((t) => t.prosthesisTypeCode)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||
prosthesisCodes,
|
||||
locale,
|
||||
);
|
||||
|
||||
const prosthesisGroups = await this.buildProsthesisGroups(labCase.id, prosthesisLabels);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
labCaseId: labCase.id,
|
||||
accessMode: access.kind,
|
||||
canEditTaskStatus: access.canEditTaskStatus,
|
||||
canPostComments: access.canPostComments,
|
||||
canToggleCommentVisibility: access.canToggleCommentVisibility,
|
||||
shareUrl: this.buildShareUrl(token, locale),
|
||||
sentAt: labCase.sentAt?.toISOString() ?? null,
|
||||
dueDate: labCase.dueDate?.toISOString() ?? null,
|
||||
isOverdue: isLabCaseOverdue(labCase.dueDate, labCase.tasks),
|
||||
isImportant: labCase.isImportant,
|
||||
clinic: labCase.treatment.organization,
|
||||
lab: labCase.sends[0]?.organization ?? null,
|
||||
patient: labCase.treatment.patient,
|
||||
prosthesisGroups,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listTasksByToken(
|
||||
token: string,
|
||||
actorUserId: string,
|
||||
organizationId: string,
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
const labCase = await this.findCaseByToken(token);
|
||||
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
|
||||
|
||||
if (access.kind === 'denied') {
|
||||
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
return this.tasksService.listTasksForLabCaseId(labCase.id, localeInput);
|
||||
}
|
||||
|
||||
async listCommentsByToken(
|
||||
token: string,
|
||||
actorUserId: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
const labCase = await this.findCaseByToken(token);
|
||||
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
|
||||
|
||||
if (access.kind === 'denied') {
|
||||
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
if (access.kind === 'lab') {
|
||||
return this.commentsService.listForLabViewer(labCase.id, organizationId, actorUserId);
|
||||
}
|
||||
|
||||
return this.commentsService.listForClinicTreatmentCase(
|
||||
labCase.id,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
);
|
||||
}
|
||||
|
||||
async addCommentByToken(
|
||||
token: string,
|
||||
actorUserId: string,
|
||||
organizationId: string,
|
||||
dto: CreateLabCaseCommentDto,
|
||||
) {
|
||||
const labCase = await this.findCaseByToken(token);
|
||||
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
|
||||
|
||||
if (access.kind === 'denied' || !access.canPostComments) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
if (access.kind === 'lab') {
|
||||
return this.commentsService.addForLab(labCase.id, organizationId, actorUserId, dto);
|
||||
}
|
||||
|
||||
return this.commentsService.addForClinicTreatmentCase(
|
||||
labCase.id,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
async setCommentVisibilityByToken(
|
||||
token: string,
|
||||
commentId: string,
|
||||
actorUserId: string,
|
||||
organizationId: string,
|
||||
visibleToClinic: boolean,
|
||||
) {
|
||||
const labCase = await this.findCaseByToken(token);
|
||||
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
|
||||
|
||||
if (access.kind !== 'lab' || !access.canToggleCommentVisibility) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
return this.commentsService.setVisibility(
|
||||
commentId,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
visibleToClinic,
|
||||
);
|
||||
}
|
||||
|
||||
private async findCaseByToken(token: string) {
|
||||
const labCase = await this.prisma.labCase.findFirst({
|
||||
where: { accessToken: token, sentAt: { not: null } },
|
||||
include: accessCaseInclude,
|
||||
});
|
||||
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
}
|
||||
|
||||
return labCase;
|
||||
}
|
||||
|
||||
private async resolveAccess(
|
||||
labCase: Prisma.LabCaseGetPayload<{ include: typeof accessCaseInclude }>,
|
||||
actorUserId: string,
|
||||
organizationId: string,
|
||||
): Promise<ResolvedAccess> {
|
||||
const clinicOrgId = labCase.treatment.organization.id;
|
||||
const labOrgId =
|
||||
labCase.destinationOrganizationId ?? labCase.sends[0]?.organizationId ?? null;
|
||||
|
||||
if (!labOrgId) {
|
||||
return { kind: 'denied' };
|
||||
}
|
||||
|
||||
if (organizationId === labOrgId) {
|
||||
const membership = await this.getMembership(actorUserId, labOrgId);
|
||||
if (!membership) return { kind: 'denied' };
|
||||
if (
|
||||
hasEffectivePermission(membership, 'TAB_TASKS_READ') ||
|
||||
hasEffectivePermission(membership, 'TAB_TASKS_EDIT')
|
||||
) {
|
||||
const canEdit = hasEffectivePermission(membership, 'TAB_TASKS_EDIT');
|
||||
return {
|
||||
kind: 'lab',
|
||||
canEditTaskStatus: canEdit,
|
||||
canPostComments: canEdit,
|
||||
canToggleCommentVisibility: canEdit,
|
||||
};
|
||||
}
|
||||
return { kind: 'denied' };
|
||||
}
|
||||
|
||||
if (organizationId === clinicOrgId) {
|
||||
const membership = await this.getMembership(actorUserId, clinicOrgId);
|
||||
if (!membership) return { kind: 'denied' };
|
||||
if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
|
||||
return { kind: 'denied' };
|
||||
}
|
||||
if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) {
|
||||
return { kind: 'denied' };
|
||||
}
|
||||
return {
|
||||
kind: 'clinic',
|
||||
canEditTaskStatus: false,
|
||||
canPostComments: true,
|
||||
canToggleCommentVisibility: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'denied' };
|
||||
}
|
||||
|
||||
private async buildProsthesisGroups(
|
||||
labCaseId: string,
|
||||
prosthesisLabels: Map<string, string>,
|
||||
) {
|
||||
const tasks = await this.prisma.labCaseTask.findMany({
|
||||
where: { labCaseId },
|
||||
select: { prosthesisTypeCode: true, teeth: true },
|
||||
orderBy: [{ prosthesisTypeCode: 'asc' }],
|
||||
});
|
||||
|
||||
const byCode = new Map<string, string[]>();
|
||||
for (const task of tasks) {
|
||||
if (!task.prosthesisTypeCode) continue;
|
||||
const teeth = normalizeTaskTeeth(task.teeth);
|
||||
const list = byCode.get(task.prosthesisTypeCode) ?? [];
|
||||
list.push(...teeth);
|
||||
byCode.set(task.prosthesisTypeCode, list);
|
||||
}
|
||||
|
||||
return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
|
||||
prosthesisTypeCode,
|
||||
prosthesisTypeLabel: prosthesisLabels.get(prosthesisTypeCode) ?? prosthesisTypeCode,
|
||||
teeth: [...new Set(teeth)].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })),
|
||||
}));
|
||||
}
|
||||
|
||||
private async getMembership(userId: string, organizationId: string) {
|
||||
return this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
include: {
|
||||
permissions: { include: { permission: true } },
|
||||
organization: { include: { type: true, plan: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,12 @@ export class LabCaseCommentsService {
|
||||
return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) };
|
||||
}
|
||||
|
||||
async listForLabViewer(caseId: string, labOrganizationId: string, actorUserId: string) {
|
||||
await this.assertLabCanViewCase(caseId, labOrganizationId, actorUserId);
|
||||
const comments = await this.fetchComments(caseId);
|
||||
return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) };
|
||||
}
|
||||
|
||||
async addForLab(
|
||||
caseId: string,
|
||||
labOrganizationId: string,
|
||||
@@ -219,6 +225,18 @@ export class LabCaseCommentsService {
|
||||
caseId: string,
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertLabCanViewCase(caseId, labOrganizationId, actorUserId);
|
||||
const membership = await this.getLabMembership(actorUserId, labOrganizationId);
|
||||
if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) {
|
||||
throw new ForbiddenException('You do not have access to task comments');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLabCanViewCase(
|
||||
caseId: string,
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
const labCase = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
@@ -232,10 +250,20 @@ export class LabCaseCommentsService {
|
||||
throw new NotFoundException('Case not found');
|
||||
}
|
||||
|
||||
const membership = await this.getLabMembership(actorUserId, labOrganizationId);
|
||||
if (
|
||||
!hasEffectivePermission(membership, 'TAB_TASKS_READ') &&
|
||||
!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')
|
||||
) {
|
||||
throw new ForbiddenException('You do not have access to tasks');
|
||||
}
|
||||
}
|
||||
|
||||
private async getLabMembership(userId: string, organizationId: string) {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: actorUserId,
|
||||
organizationId: labOrganizationId,
|
||||
userId,
|
||||
organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
include: {
|
||||
@@ -246,9 +274,7 @@ export class LabCaseCommentsService {
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) {
|
||||
throw new ForbiddenException('You do not have access to task comments');
|
||||
}
|
||||
return membership;
|
||||
}
|
||||
|
||||
private async assertClinicOwnsCase(
|
||||
|
||||
@@ -8,5 +8,6 @@ import { TasksService } from './tasks.service';
|
||||
imports: [CatalogModule, NotificationsModule],
|
||||
controllers: [TasksController],
|
||||
providers: [TasksService],
|
||||
exports: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
|
||||
@@ -95,6 +95,58 @@ export class TasksService {
|
||||
};
|
||||
}
|
||||
|
||||
async listForLabCase(
|
||||
labCaseId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
await this.assertCanReadTasks(actorUserId, organizationId);
|
||||
|
||||
const labCase = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
}
|
||||
|
||||
return this.listTasksForLabCaseId(labCaseId, localeInput);
|
||||
}
|
||||
|
||||
async listTasksForLabCaseId(labCaseId: string, localeInput?: string | null) {
|
||||
const items = await this.prisma.labCaseTask.findMany({
|
||||
where: { labCaseId },
|
||||
include: taskListInclude,
|
||||
orderBy: [
|
||||
{ treatmentDetailId: 'asc' },
|
||||
{ prosthesisTypeCode: 'asc' },
|
||||
{ stepOrder: 'asc' },
|
||||
{ id: 'asc' },
|
||||
],
|
||||
});
|
||||
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))];
|
||||
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||
prosthesisCodes,
|
||||
locale,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: items.map((task) => this.mapTaskListItem(task, prosthesisLabels)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async locateTaskPage(
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/
|
||||
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { generateLabCaseAccessToken } from '../../common/lab-case-access-token';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
|
||||
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
||||
@@ -754,7 +755,10 @@ export class TreatmentsService {
|
||||
if (!labCase.sentAt) {
|
||||
await tx.labCase.update({
|
||||
where: { id: labCaseId },
|
||||
data: { sentAt: now },
|
||||
data: {
|
||||
sentAt: now,
|
||||
accessToken: generateLabCaseAccessToken(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user