improvement: notify counter badge added to treatment and tasks tabs for upadted and edited cases.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
-- Lab case activity feed + per-user read cursors for tab badges
|
||||
|
||||
CREATE TYPE "LabCaseActivityType" AS ENUM (
|
||||
'CASE_SENT',
|
||||
'CLINIC_COMMENT',
|
||||
'LAB_COMMENT',
|
||||
'CASE_IMPORTANT',
|
||||
'CASE_AMENDED',
|
||||
'TASK_COMPLETED'
|
||||
);
|
||||
|
||||
CREATE TYPE "LabCaseTabReadTarget" AS ENUM ('CASES', 'TASKS', 'TREATMENT');
|
||||
|
||||
CREATE TABLE "lab_case_activities" (
|
||||
"id" TEXT NOT NULL,
|
||||
"labCaseId" TEXT NOT NULL,
|
||||
"type" "LabCaseActivityType" NOT NULL,
|
||||
"actorUserId" TEXT,
|
||||
"payload" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "lab_case_activities_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "lab_case_user_read_states" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"labCaseId" TEXT NOT NULL,
|
||||
"lastReadAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "lab_case_user_read_states_pkey" PRIMARY KEY ("userId", "organizationId", "labCaseId")
|
||||
);
|
||||
|
||||
CREATE TABLE "lab_case_user_tab_read_states" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"tab" "LabCaseTabReadTarget" NOT NULL,
|
||||
"lastReadAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "lab_case_user_tab_read_states_pkey" PRIMARY KEY ("userId", "organizationId", "tab")
|
||||
);
|
||||
|
||||
ALTER TABLE "lab_case_activities"
|
||||
ADD CONSTRAINT "lab_case_activities_labCaseId_fkey"
|
||||
FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "lab_case_activities"
|
||||
ADD CONSTRAINT "lab_case_activities_actorUserId_fkey"
|
||||
FOREIGN KEY ("actorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "lab_case_user_read_states"
|
||||
ADD CONSTRAINT "lab_case_user_read_states_labCaseId_fkey"
|
||||
FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "lab_case_activities_labCaseId_createdAt_idx"
|
||||
ON "lab_case_activities"("labCaseId", "createdAt");
|
||||
|
||||
CREATE INDEX "lab_case_activities_type_createdAt_idx"
|
||||
ON "lab_case_activities"("type", "createdAt");
|
||||
|
||||
CREATE INDEX "lab_case_user_read_states_userId_organizationId_idx"
|
||||
ON "lab_case_user_read_states"("userId", "organizationId");
|
||||
@@ -28,6 +28,7 @@ model User {
|
||||
assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee")
|
||||
labCaseTaskStatusEvents LabCaseTaskStatusEvent[]
|
||||
labCaseComments LabCaseComment[]
|
||||
labCaseActivities LabCaseActivity[]
|
||||
phoneVerificationCodes PhoneVerificationCode[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@@ -224,6 +225,8 @@ model LabCase {
|
||||
toothProsthesis LabCaseToothProsthesis[]
|
||||
comments LabCaseComment[]
|
||||
attachments LabCaseAttachment[]
|
||||
activities LabCaseActivity[]
|
||||
userReadStates LabCaseUserReadState[]
|
||||
|
||||
@@index([treatmentId, sortOrder])
|
||||
@@map("lab_cases")
|
||||
@@ -410,6 +413,60 @@ model LabCaseComment {
|
||||
@@map("lab_case_comments")
|
||||
}
|
||||
|
||||
enum LabCaseActivityType {
|
||||
CASE_SENT
|
||||
CLINIC_COMMENT
|
||||
LAB_COMMENT
|
||||
CASE_IMPORTANT
|
||||
CASE_AMENDED
|
||||
TASK_COMPLETED
|
||||
}
|
||||
|
||||
enum LabCaseTabReadTarget {
|
||||
CASES
|
||||
TASKS
|
||||
TREATMENT
|
||||
}
|
||||
|
||||
model LabCaseActivity {
|
||||
id String @id @default(uuid())
|
||||
labCaseId String
|
||||
type LabCaseActivityType
|
||||
actorUserId String?
|
||||
payload Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
||||
actorUser User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([labCaseId, createdAt])
|
||||
@@index([type, createdAt])
|
||||
@@map("lab_case_activities")
|
||||
}
|
||||
|
||||
model LabCaseUserReadState {
|
||||
userId String
|
||||
organizationId String
|
||||
labCaseId String
|
||||
lastReadAt DateTime
|
||||
|
||||
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, organizationId, labCaseId])
|
||||
@@index([userId, organizationId])
|
||||
@@map("lab_case_user_read_states")
|
||||
}
|
||||
|
||||
model LabCaseUserTabReadState {
|
||||
userId String
|
||||
organizationId String
|
||||
tab LabCaseTabReadTarget
|
||||
lastReadAt DateTime
|
||||
|
||||
@@id([userId, organizationId, tab])
|
||||
@@map("lab_case_user_tab_read_states")
|
||||
}
|
||||
|
||||
model Plan {
|
||||
id String @id @default(uuid())
|
||||
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
|
||||
|
||||
@@ -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],
|
||||
|
||||
20
backend/src/common/lab-case-activity.ts
Normal file
20
backend/src/common/lab-case-activity.ts
Normal 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,
|
||||
];
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
12
backend/src/modules/notifications/dto/notifications.dto.ts
Normal file
12
backend/src/modules/notifications/dto/notifications.dto.ts
Normal 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;
|
||||
}
|
||||
331
backend/src/modules/notifications/lab-case-activity.service.ts
Normal file
331
backend/src/modules/notifications/lab-case-activity.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/notifications/notifications.module.ts
Normal file
10
backend/src/modules/notifications/notifications.module.ts
Normal 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 {}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user