improvement: notify counter badge added to treatment and tasks tabs for upadted and edited cases.

This commit is contained in:
2026-07-13 17:44:44 +03:30
parent 547457d637
commit 2ad572f4c8
33 changed files with 908 additions and 31 deletions

View File

@@ -18,6 +18,7 @@ import { CatalogModule } from './modules/catalog/catalog.module';
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
import { TodayModule } from './modules/today/today.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
@Module({
imports: [
@@ -39,6 +40,7 @@ import { TodayModule } from './modules/today/today.module';
StaffModule,
OrganizationModule,
TodayModule,
NotificationsModule,
AdminModule.forRoot(),
],
controllers: [AppController],

View File

@@ -0,0 +1,20 @@
import { LabCaseActivityType } from '@prisma/client';
/** Lab Cases tab — new shipments, clinic comments, important flag. */
export const LAB_CASES_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.CASE_SENT,
LabCaseActivityType.CLINIC_COMMENT,
LabCaseActivityType.CASE_IMPORTANT,
];
/** Lab Tasks tab — task completions and lab-side comments. */
export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.TASK_COMPLETED,
LabCaseActivityType.LAB_COMMENT,
];
/** Clinic Treatment tab — visible lab comments and task progress. */
export const CLINIC_TREATMENT_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.LAB_COMMENT,
LabCaseActivityType.TASK_COMPLETED,
];

View File

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

View File

@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { createReadStream, existsSync } from 'fs';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
import {
@@ -20,6 +20,8 @@ import {
} from '../../common/lab-case-due-date';
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';
const labCaseListInclude = {
treatment: {
@@ -92,6 +94,7 @@ export class CasesService {
private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService,
private readonly catalogLabels: CatalogLabelService,
private readonly labCaseActivity: LabCaseActivityService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -138,10 +141,21 @@ export class CasesService {
this.prisma.labCase.count({ where }),
]);
const unreadCaseIds = await this.labCaseActivity.unreadCaseIdsInBatch(
actorUserId,
labOrganizationId,
items.map((lc) => lc.id),
LAB_CASES_TAB_ACTIVITY_TYPES,
'LAB',
);
return {
success: true,
data: {
items: items.map((lc) => this.mapLabCaseListItem(lc)),
items: items.map((lc) => ({
...this.mapLabCaseListItem(lc),
hasUnread: unreadCaseIds.has(lc.id),
})),
pagination: {
page,
limit,
@@ -356,7 +370,7 @@ export class CasesService {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
select: { id: true },
select: { id: true, isImportant: true },
});
if (!existing) {
@@ -368,6 +382,14 @@ export class CasesService {
data: { isImportant: dto.isImportant },
});
if (dto.isImportant && !existing.isImportant) {
await this.labCaseActivity.record({
labCaseId,
type: LabCaseActivityType.CASE_IMPORTANT,
actorUserId,
});
}
const labCase = await this.prisma.labCase.findFirstOrThrow({
where: { id: labCaseId },
include: labCaseListInclude,

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { NotificationsModule } from '../notifications/notifications.module';
import { LabCaseCommentsController } from './lab-case-comments.controller';
import { LabCaseCommentsService } from './lab-case-comments.service';
@Module({
imports: [NotificationsModule],
controllers: [LabCaseCommentsController],
providers: [LabCaseCommentsService, PrismaService, LabOrgGuard],
exports: [LabCaseCommentsService],

View File

@@ -3,10 +3,11 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LabCaseCommentSide, Prisma } from '@prisma/client';
import { LabCaseCommentSide, LabCaseActivityType, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
const commentInclude = {
authorUser: { select: { id: true, name: true } },
@@ -19,7 +20,10 @@ type CommentWithRelations = Prisma.LabCaseCommentGetPayload<{
@Injectable()
export class LabCaseCommentsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly labCaseActivity: LabCaseActivityService,
) {}
// ---------- Lab side (TAB_TASKS_EDIT) ----------
@@ -47,6 +51,15 @@ export class LabCaseCommentsService {
},
include: commentInclude,
});
await this.labCaseActivity.record({
labCaseId: caseId,
type: LabCaseActivityType.LAB_COMMENT,
actorUserId,
payload: {
commentId: created.id,
visibleToClinic: created.visibleToClinic,
},
});
return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) };
}
@@ -104,6 +117,12 @@ export class LabCaseCommentsService {
},
include: commentInclude,
});
await this.labCaseActivity.record({
labCaseId: caseId,
type: LabCaseActivityType.CLINIC_COMMENT,
actorUserId,
payload: { commentId: created.id },
});
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
}
@@ -140,6 +159,12 @@ export class LabCaseCommentsService {
},
include: commentInclude,
});
await this.labCaseActivity.record({
labCaseId: caseId,
type: LabCaseActivityType.CLINIC_COMMENT,
actorUserId,
payload: { commentId: created.id },
});
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
}

View File

@@ -0,0 +1,12 @@
import { IsEnum, IsUUID } from 'class-validator';
import { LabCaseTabReadTarget } from '@prisma/client';
export class MarkTabReadDto {
@IsEnum(LabCaseTabReadTarget)
tab!: LabCaseTabReadTarget;
}
export class MarkCaseReadDto {
@IsUUID()
labCaseId!: string;
}

View File

@@ -0,0 +1,331 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
LabCaseActivityType,
LabCaseTabReadTarget,
Prisma,
} from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { hasEffectivePermission } from '../../common/membership-permissions';
import {
CLINIC_TREATMENT_TAB_ACTIVITY_TYPES,
LAB_CASES_TAB_ACTIVITY_TYPES,
LAB_TASKS_TAB_ACTIVITY_TYPES,
} from '../../common/lab-case-activity';
type TxClient = Prisma.TransactionClient;
@Injectable()
export class LabCaseActivityService {
constructor(private readonly prisma: PrismaService) {}
async record(
input: {
labCaseId: string;
type: LabCaseActivityType;
actorUserId?: string | null;
payload?: Prisma.InputJsonValue;
},
tx?: TxClient,
) {
const client = tx ?? this.prisma;
await client.labCaseActivity.create({
data: {
labCaseId: input.labCaseId,
type: input.type,
actorUserId: input.actorUserId ?? null,
payload: input.payload ?? undefined,
},
});
}
async getTabCounts(userId: string, organizationId: string) {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
include: { type: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
}
await this.assertMembership(userId, organizationId);
const orgType = org.type.name;
const tabReads = await this.prisma.labCaseUserTabReadState.findMany({
where: { userId, organizationId },
});
const tabSince = (tab: LabCaseTabReadTarget) =>
tabReads.find((row) => row.tab === tab)?.lastReadAt ?? new Date(0);
if (orgType === 'LAB') {
const [cases, tasks] = await Promise.all([
this.countUnreadLabCasesForCasesTab(userId, organizationId),
this.countUnreadForTab(
userId,
organizationId,
'LAB',
LAB_TASKS_TAB_ACTIVITY_TYPES,
tabSince(LabCaseTabReadTarget.TASKS),
),
]);
return { success: true, data: { cases, tasks } };
}
if (orgType === 'CLINIC') {
const treatment = await this.countUnreadForTab(
userId,
organizationId,
'CLINIC',
CLINIC_TREATMENT_TAB_ACTIVITY_TYPES,
tabSince(LabCaseTabReadTarget.TREATMENT),
);
return { success: true, data: { treatment } };
}
return { success: true, data: {} };
}
async markTabRead(userId: string, organizationId: string, tab: LabCaseTabReadTarget) {
await this.assertMembership(userId, organizationId);
const now = new Date();
await this.prisma.labCaseUserTabReadState.upsert({
where: {
userId_organizationId_tab: { userId, organizationId, tab },
},
create: { userId, organizationId, tab, lastReadAt: now },
update: { lastReadAt: now },
});
return { success: true };
}
/** Cases with unread activity in the Cases tab bucket (per-case read cursor, not tab visit). */
async countUnreadLabCasesForCasesTab(
userId: string,
organizationId: string,
): Promise<number> {
const grouped = await this.prisma.labCaseActivity.groupBy({
by: ['labCaseId'],
where: {
type: { in: LAB_CASES_TAB_ACTIVITY_TYPES },
labCase: {
sentAt: { not: null },
sends: { some: { organizationId } },
},
OR: [{ actorUserId: null }, { actorUserId: { not: userId } }],
},
_max: { createdAt: true },
});
if (grouped.length === 0) return 0;
const caseIds = grouped.map((row) => row.labCaseId);
const readStates = await this.prisma.labCaseUserReadState.findMany({
where: { userId, organizationId, labCaseId: { in: caseIds } },
});
const readMap = new Map(readStates.map((row) => [row.labCaseId, row.lastReadAt]));
return grouped.filter((row) => {
const since = readMap.get(row.labCaseId) ?? new Date(0);
return (row._max.createdAt ?? new Date(0)) > since;
}).length;
}
async unreadCaseIdsInBatch(
userId: string,
organizationId: string,
labCaseIds: string[],
types: LabCaseActivityType[],
orgType: 'LAB' | 'CLINIC',
): Promise<Set<string>> {
if (labCaseIds.length === 0) return new Set();
const readStates = await this.prisma.labCaseUserReadState.findMany({
where: { userId, organizationId, labCaseId: { in: labCaseIds } },
});
const sinceByCase = new Map(
labCaseIds.map((id) => [
id,
readStates.find((row) => row.labCaseId === id)?.lastReadAt ?? new Date(0),
]),
);
const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType);
const activities = await this.prisma.labCaseActivity.findMany({
where: {
labCaseId: { in: labCaseIds },
type: { in: types },
AND: [
{ OR: [{ actorUserId: null }, { actorUserId: { not: userId } }] },
...(Object.keys(clinicLabCommentFilter).length ? [clinicLabCommentFilter] : []),
],
},
select: { labCaseId: true, createdAt: true },
});
const unread = new Set<string>();
for (const activity of activities) {
const since = sinceByCase.get(activity.labCaseId)!;
if (activity.createdAt > since) {
unread.add(activity.labCaseId);
}
}
return unread;
}
async markCaseRead(userId: string, organizationId: string, labCaseId: string) {
await this.assertMembership(userId, organizationId);
await this.assertCanAccessCase(userId, organizationId, labCaseId);
const now = new Date();
await this.prisma.labCaseUserReadState.upsert({
where: {
userId_organizationId_labCaseId: { userId, organizationId, labCaseId },
},
create: { userId, organizationId, labCaseId, lastReadAt: now },
update: { lastReadAt: now },
});
return { success: true };
}
private async countUnreadForTab(
userId: string,
organizationId: string,
orgType: 'LAB' | 'CLINIC',
types: LabCaseActivityType[],
since: Date,
): Promise<number> {
const labCaseScope =
orgType === 'LAB'
? {
sentAt: { not: null },
sends: { some: { organizationId } },
}
: {
sentAt: { not: null },
treatment: { organizationId },
};
const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType);
return this.prisma.labCaseActivity.count({
where: {
type: { in: types },
createdAt: { gt: since },
labCase: labCaseScope,
AND: [
{
OR: [{ actorUserId: null }, { actorUserId: { not: userId } }],
},
clinicLabCommentFilter,
],
},
});
}
private clinicLabCommentFilter(orgType: 'LAB' | 'CLINIC'): Prisma.LabCaseActivityWhereInput {
if (orgType !== 'CLINIC') return {};
return {
OR: [
{ type: { not: LabCaseActivityType.LAB_COMMENT } },
{
type: LabCaseActivityType.LAB_COMMENT,
payload: { path: ['visibleToClinic'], equals: true },
},
],
};
}
private async assertMembership(userId: string, organizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: {
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
});
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
}
}
private async assertCanAccessCase(
userId: string,
organizationId: string,
labCaseId: string,
) {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
include: { type: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
}
const labCase = await this.prisma.labCase.findFirst({
where:
org.type.name === 'LAB'
? {
id: labCaseId,
sentAt: { not: null },
sends: { some: { organizationId } },
}
: {
id: labCaseId,
treatment: { organizationId },
},
select: { id: true },
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
if (org.type.name === 'LAB') {
const membership = await this.prisma.membership.findFirst({
where: {
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
});
if (
!membership ||
!(
hasEffectivePermission(membership, 'TAB_CASES_READ') ||
hasEffectivePermission(membership, 'TAB_TASKS_READ')
)
) {
throw new ForbiddenException('You do not have access to this case');
}
return;
}
const membership = await this.prisma.membership.findFirst({
where: {
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
});
if (
!membership ||
!(
hasEffectivePermission(membership, 'TAB_TREATMENT_READ') ||
hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')
)
) {
throw new ForbiddenException('You do not have access to this case');
}
}
}

View File

@@ -0,0 +1,53 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MarkCaseReadDto, MarkTabReadDto } from './dto/notifications.dto';
import { LabCaseActivityService } from './lab-case-activity.service';
@ApiTags('notifications')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('notifications')
export class NotificationsController {
constructor(private readonly labCaseActivityService: LabCaseActivityService) {}
@Get('tab-counts')
@ApiOperation({ summary: 'Unread activity counts for sidebar tab badges' })
getTabCounts(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = req.user.organizationId;
if (!organizationId) {
return { success: true, data: {} };
}
return this.labCaseActivityService.getTabCounts(req.user.id, organizationId);
}
@Post('mark-tab-read')
@ApiOperation({ summary: 'Clear sidebar badge for a tab after the user visits it' })
markTabRead(
@Body() dto: MarkTabReadDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = req.user.organizationId;
if (!organizationId) {
return { success: true };
}
return this.labCaseActivityService.markTabRead(req.user.id, organizationId, dto.tab);
}
@Post('mark-case-read')
@ApiOperation({ summary: 'Mark a lab case as read for the current user' })
markCaseRead(
@Body() dto: MarkCaseReadDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = req.user.organizationId;
if (!organizationId) {
return { success: true };
}
return this.labCaseActivityService.markCaseRead(
req.user.id,
organizationId,
dto.labCaseId,
);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LabCaseActivityService } from './lab-case-activity.service';
import { NotificationsController } from './notifications.controller';
@Module({
controllers: [NotificationsController],
providers: [LabCaseActivityService],
exports: [LabCaseActivityService],
})
export class NotificationsModule {}

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { CatalogModule } from '../catalog/catalog.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [CatalogModule],
imports: [CatalogModule, NotificationsModule],
controllers: [TasksController],
providers: [TasksService],
})

View File

@@ -4,7 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
import {
@@ -15,6 +15,7 @@ import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date';
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
const taskListInclude = {
lastStatusChangedBy: { select: { id: true, name: true } },
@@ -37,6 +38,7 @@ export class TasksService {
constructor(
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
private readonly labCaseActivity: LabCaseActivityService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -205,6 +207,18 @@ export class TasksService {
});
}
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
await this.labCaseActivity.record(
{
labCaseId: task.labCaseId,
type: LabCaseActivityType.TASK_COMPLETED,
actorUserId,
payload: { taskId },
},
tx,
);
}
return result;
});

View File

@@ -3,11 +3,12 @@ import { PrismaService } from '../../../prisma/prisma.service';
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TreatmentsController } from './treatments.controller';
import { TreatmentsService } from './treatments.service';
@Module({
imports: [ProsthesisCatalogModule, LabCaseCommentsModule],
imports: [ProsthesisCatalogModule, LabCaseCommentsModule, NotificationsModule],
controllers: [TreatmentsController],
providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
})

View File

@@ -4,7 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LabTaskStatus, LinkStatus, Prisma } from '@prisma/client';
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/client';
import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
@@ -21,6 +21,7 @@ import {
isLabCaseFullyCompleted,
parseDueDateInput,
} from '../../common/lab-case-due-date';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import {
generateTreatmentTitle,
normalizeTeeth,
@@ -83,6 +84,7 @@ export class TreatmentsService {
private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService,
private readonly prosthesisCatalog: ProsthesisCatalogService,
private readonly labCaseActivity: LabCaseActivityService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -558,6 +560,7 @@ export class TreatmentsService {
}
const now = new Date();
const isFirstSend = !labCase.sentAt;
await this.prisma.$transaction(async (tx) => {
await tx.labCaseSend.create({
@@ -575,6 +578,17 @@ export class TreatmentsService {
}
await generateLabCaseTasks(tx, labCaseId, actorLanguage);
if (isFirstSend) {
await this.labCaseActivity.record(
{
labCaseId,
type: LabCaseActivityType.CASE_SENT,
actorUserId,
},
tx,
);
}
});
const refreshed = await this.prisma.labCase.findUniqueOrThrow({