feature: version one notification feature implemented.

This commit is contained in:
2026-07-18 01:09:54 +03:30
parent 0740493384
commit 9f6ec193d2
40 changed files with 1679 additions and 224 deletions

View File

@@ -19,6 +19,7 @@ import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis
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';
import { RealtimeModule } from './realtime/realtime.module';
@Module({
imports: [
@@ -41,6 +42,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
OrganizationModule,
TodayModule,
NotificationsModule,
RealtimeModule,
AdminModule.forRoot(),
],
controllers: [AppController],

View File

@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { createReadStream, existsSync } from 'fs';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
import {
@@ -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 { UserNotificationService } from '../notifications/user-notification.service';
import { LabCaseAccessService } from './lab-case-access.service';
const labCaseListInclude = {
@@ -96,6 +97,7 @@ export class CasesService {
private readonly prosthesisCatalog: ProsthesisCatalogService,
private readonly catalogLabels: CatalogLabelService,
private readonly labCaseActivity: LabCaseActivityService,
private readonly userNotifications: UserNotificationService,
private readonly labCaseAccess: LabCaseAccessService,
) {}
@@ -385,6 +387,14 @@ export class CasesService {
type: LabCaseActivityType.CASE_IMPORTANT,
actorUserId,
});
void this.userNotifications.notify({
organizationId: labOrganizationId,
type: UserNotificationType.CASE_IMPORTANT,
href: `/cases?caseId=${encodeURIComponent(labCaseId)}`,
actorUserId,
payload: { labCaseId },
requiredPermission: 'TAB_CASES_READ',
});
}
const labCase = await this.prisma.labCase.findFirstOrThrow({
@@ -476,6 +486,17 @@ export class CasesService {
},
});
if (assigneeUserId && assigneeUserId !== actorUserId) {
void this.userNotifications.notify({
organizationId: labOrganizationId,
type: UserNotificationType.TASK_ASSIGNED,
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(labCaseId)}`,
actorUserId,
payload: { labCaseId, taskId },
recipientUserIds: [assigneeUserId],
});
}
const labCase = await this.prisma.labCase.findFirstOrThrow({
where: { id: labCaseId },
include: labCaseListInclude,

View File

@@ -3,12 +3,13 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LabCaseCommentSide, LabCaseActivityType, Prisma } from '@prisma/client';
import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { UserNotificationService } from '../notifications/user-notification.service';
const commentInclude = {
authorUser: { select: { id: true, name: true } },
@@ -24,6 +25,7 @@ export class LabCaseCommentsService {
constructor(
private readonly prisma: PrismaService,
private readonly labCaseActivity: LabCaseActivityService,
private readonly userNotifications: UserNotificationService,
) {}
// ---------- Lab side (TAB_TASKS_EDIT) ----------
@@ -67,6 +69,31 @@ export class LabCaseCommentsService {
visibleToClinic: created.visibleToClinic,
},
});
void this.userNotifications.notify({
organizationId: labOrganizationId,
type: UserNotificationType.LAB_COMMENT,
href: `/cases?caseId=${encodeURIComponent(caseId)}`,
actorUserId,
payload: { labCaseId: caseId, commentId: created.id },
requiredPermission: 'TAB_TASKS_READ',
});
if (created.visibleToClinic) {
const clinicOrgId = await this.clinicOrgIdForCase(caseId);
if (clinicOrgId) {
void this.userNotifications.notify({
organizationId: clinicOrgId,
type: UserNotificationType.LAB_COMMENT_CLINIC,
href: `/treatment?labCaseId=${encodeURIComponent(caseId)}`,
actorUserId,
payload: { labCaseId: caseId, commentId: created.id },
requiredPermission: 'TAB_TREATMENT_READ',
labCaseIdForProviderScope: caseId,
});
}
}
return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) };
}
@@ -130,6 +157,17 @@ export class LabCaseCommentsService {
actorUserId,
payload: { commentId: created.id },
});
const labOrgId = await this.labOrgIdForCase(caseId);
if (labOrgId) {
void this.userNotifications.notify({
organizationId: labOrgId,
type: UserNotificationType.CLINIC_COMMENT,
href: `/cases?caseId=${encodeURIComponent(caseId)}`,
actorUserId,
payload: { labCaseId: caseId, commentId: created.id },
requiredPermission: 'TAB_CASES_READ',
});
}
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
}
@@ -172,6 +210,17 @@ export class LabCaseCommentsService {
actorUserId,
payload: { commentId: created.id },
});
const labOrgId = await this.labOrgIdForCase(caseId);
if (labOrgId) {
void this.userNotifications.notify({
organizationId: labOrgId,
type: UserNotificationType.CLINIC_COMMENT,
href: `/cases?caseId=${encodeURIComponent(caseId)}`,
actorUserId,
payload: { labCaseId: caseId, commentId: created.id },
requiredPermission: 'TAB_CASES_READ',
});
}
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
}
@@ -336,4 +385,21 @@ export class LabCaseCommentsService {
}
throw new ForbiddenException('You do not have access to treatment cases');
}
private async labOrgIdForCase(caseId: string): Promise<string | null> {
const send = await this.prisma.labCaseSend.findFirst({
where: { labCaseId: caseId },
orderBy: { sentAt: 'asc' },
select: { organizationId: true },
});
return send?.organizationId ?? null;
}
private async clinicOrgIdForCase(caseId: string): Promise<string | null> {
const labCase = await this.prisma.labCase.findUnique({
where: { id: caseId },
select: { treatment: { select: { organizationId: true } } },
});
return labCase?.treatment.organizationId ?? null;
}
}

View File

@@ -1,4 +1,5 @@
import { IsEnum, IsUUID } from 'class-validator';
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { LabCaseTabReadTarget } from '@prisma/client';
export class MarkTabReadDto {
@@ -10,3 +11,16 @@ export class MarkCaseReadDto {
@IsUUID()
labCaseId!: string;
}
export class ListInboxQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit?: number;
@IsOptional()
@IsString()
cursor?: string;
}

View File

@@ -1,15 +1,34 @@
import { Body, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Post, Query, Req, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
ParseUUIDPipe,
Post,
Query,
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 {
ListInboxQueryDto,
MarkCaseReadDto,
MarkTabReadDto,
} from './dto/notifications.dto';
import { LabCaseActivityService } from './lab-case-activity.service';
import { UserNotificationService } from './user-notification.service';
@ApiTags('notifications')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('notifications')
export class NotificationsController {
constructor(private readonly labCaseActivityService: LabCaseActivityService) {}
constructor(
private readonly labCaseActivityService: LabCaseActivityService,
private readonly userNotifications: UserNotificationService,
) {}
@Get('tab-counts')
@ApiOperation({ summary: 'Unread activity counts for sidebar tab badges' })
@@ -69,4 +88,53 @@ export class NotificationsController {
dto.labCaseId,
);
}
@Get('inbox')
@ApiOperation({ summary: 'Paginated notification inbox for the current user and org' })
listInbox(
@Query() query: ListInboxQueryDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = req.user.organizationId;
if (!organizationId) {
return { success: true, data: { items: [], nextCursor: null } };
}
return this.userNotifications.listInbox(req.user.id, organizationId, {
limit: query.limit,
cursor: query.cursor,
});
}
@Get('inbox/unread-count')
@ApiOperation({ summary: 'Unread inbox count for the header bell' })
inboxUnreadCount(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = req.user.organizationId;
if (!organizationId) {
return { success: true, data: { count: 0 } };
}
return this.userNotifications.unreadCount(req.user.id, organizationId);
}
@Post('inbox/read-all')
@ApiOperation({ summary: 'Mark all inbox notifications as read for the current org' })
markInboxReadAll(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = req.user.organizationId;
if (!organizationId) {
return { success: true };
}
return this.userNotifications.markAllRead(req.user.id, organizationId);
}
@Post('inbox/:id/read')
@ApiOperation({ summary: 'Mark a single inbox notification as read' })
markInboxRead(
@Param('id', ParseUUIDPipe) id: string,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = req.user.organizationId;
if (!organizationId) {
return { success: true, data: null };
}
return this.userNotifications.markRead(req.user.id, organizationId, id);
}
}

View File

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

View File

@@ -0,0 +1,207 @@
import { Injectable } from '@nestjs/common';
import { Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { isActorTreatmentProvider } from '../../common/treatment-provider-scope';
import { RealtimeEmitter } from '../../realtime/realtime.emitter';
const membershipInclude = {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
} satisfies Prisma.MembershipInclude;
export type UserNotificationDto = {
id: string;
type: UserNotificationType;
href: string;
payload: Record<string, unknown> | null;
readAt: string | null;
createdAt: string;
actorUserId: string | null;
};
type FanoutInput = {
organizationId: string;
type: UserNotificationType;
href: string;
actorUserId?: string | null;
payload?: Record<string, unknown> | null;
/** Explicit recipient user ids (e.g. task assignee). Skips permission fan-out. */
recipientUserIds?: string[];
/** Required tab permission when resolving org members. */
requiredPermission?: string;
/** When set, only clinic members who are the treatment provider for this case. */
labCaseIdForProviderScope?: string;
};
@Injectable()
export class UserNotificationService {
constructor(
private readonly prisma: PrismaService,
private readonly realtime: RealtimeEmitter,
) {}
async notify(input: FanoutInput): Promise<void> {
const recipientIds = input.recipientUserIds?.length
? [...new Set(input.recipientUserIds.filter((id) => id && id !== input.actorUserId))]
: await this.resolveRecipients(input);
if (recipientIds.length === 0) return;
const rows = await this.prisma.userNotification.createManyAndReturn({
data: recipientIds.map((userId) => ({
userId,
organizationId: input.organizationId,
type: input.type,
actorUserId: input.actorUserId ?? null,
payload: (input.payload ?? Prisma.JsonNull) as Prisma.InputJsonValue,
href: input.href,
})),
});
for (const row of rows) {
const dto = this.mapRow(row);
this.realtime.emitToUserOrg(row.userId, row.organizationId, 'notification.created', {
notification: dto,
});
const unreadCount = await this.countUnread(row.userId, row.organizationId);
this.realtime.emitToUserOrg(row.userId, row.organizationId, 'notification.unreadCount', {
count: unreadCount,
});
}
}
async listInbox(
userId: string,
organizationId: string,
options?: { limit?: number; cursor?: string },
) {
const limit = Math.min(Math.max(options?.limit ?? 20, 1), 50);
const rows = await this.prisma.userNotification.findMany({
where: { userId, organizationId },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: limit + 1,
...(options?.cursor
? {
cursor: { id: options.cursor },
skip: 1,
}
: {}),
});
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore ? page[page.length - 1]?.id ?? null : null;
return {
success: true as const,
data: {
items: page.map((row) => this.mapRow(row)),
nextCursor,
},
};
}
async unreadCount(userId: string, organizationId: string) {
const count = await this.countUnread(userId, organizationId);
return { success: true as const, data: { count } };
}
async markRead(userId: string, organizationId: string, notificationId: string) {
const existing = await this.prisma.userNotification.findFirst({
where: { id: notificationId, userId, organizationId },
});
if (!existing) {
return { success: true as const, data: null };
}
if (!existing.readAt) {
const updated = await this.prisma.userNotification.update({
where: { id: notificationId },
data: { readAt: new Date() },
});
const count = await this.countUnread(userId, organizationId);
this.realtime.emitToUserOrg(userId, organizationId, 'notification.unreadCount', {
count,
});
return { success: true as const, data: this.mapRow(updated) };
}
return { success: true as const, data: this.mapRow(existing) };
}
async markAllRead(userId: string, organizationId: string) {
await this.prisma.userNotification.updateMany({
where: { userId, organizationId, readAt: null },
data: { readAt: new Date() },
});
this.realtime.emitToUserOrg(userId, organizationId, 'notification.unreadCount', {
count: 0,
});
return { success: true as const };
}
private async countUnread(userId: string, organizationId: string): Promise<number> {
return this.prisma.userNotification.count({
where: { userId, organizationId, readAt: null },
});
}
private mapRow(row: {
id: string;
type: UserNotificationType;
href: string;
payload: Prisma.JsonValue;
readAt: Date | null;
createdAt: Date;
actorUserId: string | null;
}): UserNotificationDto {
return {
id: row.id,
type: row.type,
href: row.href,
payload:
row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload)
? (row.payload as Record<string, unknown>)
: null,
readAt: row.readAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
actorUserId: row.actorUserId,
};
}
private async resolveRecipients(input: FanoutInput): Promise<string[]> {
if (!input.requiredPermission) return [];
const memberships = await this.prisma.membership.findMany({
where: {
organizationId: input.organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: membershipInclude,
});
let userIds = memberships
.filter((m) => hasEffectivePermission(m, input.requiredPermission!))
.map((m) => m.userId)
.filter((id) => id !== input.actorUserId);
if (input.labCaseIdForProviderScope) {
const labCase = await this.prisma.labCase.findUnique({
where: { id: input.labCaseIdForProviderScope },
select: {
treatment: {
select: {
providerUserId: true,
appointment: { select: { providerUserId: true } },
},
},
},
});
if (!labCase?.treatment) return [];
userIds = userIds.filter((userId) =>
isActorTreatmentProvider(labCase.treatment, userId),
);
}
return [...new Set(userIds)];
}
}

View File

@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { CasesModule } from '../cases/cases.module';
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { OrganizationController } from './organization.controller';
import { OrganizationService } from './organization.service';
@Module({
imports: [CasesModule, LabCaseCommentsModule],
imports: [CasesModule, LabCaseCommentsModule, NotificationsModule],
controllers: [OrganizationController],
providers: [OrganizationService, PrismaService],
})

View File

@@ -5,7 +5,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LinkStatus } from '@prisma/client';
import { LinkStatus, UserNotificationType } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
@@ -13,6 +13,7 @@ import { ListLabCasesDto } from '../cases/dto/cases.dto';
import { CasesService } from '../cases/cases.service';
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
import { UserNotificationService } from '../notifications/user-notification.service';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
@@ -36,6 +37,7 @@ export class OrganizationService {
private readonly prisma: PrismaService,
private readonly casesService: CasesService,
private readonly commentsService: LabCaseCommentsService,
private readonly userNotifications: UserNotificationService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -269,6 +271,15 @@ export class OrganizationService {
},
});
void this.userNotifications.notify({
organizationId: dto.targetOrganizationId,
type: UserNotificationType.CONNECTION_REQUEST,
href: '/organizations',
actorUserId: userId,
payload: { organizationLinkId: created.id, fromOrganizationId: organizationId },
requiredPermission: 'TAB_ORGANIZATIONS_READ',
});
return {
success: true,
data: { id: created.id, status: created.status },

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { StaffController } from './staff.controller';
import { StaffService } from './staff.service';
import { StaffWorkingHoursService } from './staff-working-hours.service';
@Module({
imports: [NotificationsModule],
controllers: [StaffController],
providers: [StaffService, StaffWorkingHoursService, PrismaService],
exports: [StaffWorkingHoursService],

View File

@@ -7,7 +7,7 @@ import {
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { Prisma } from '@prisma/client';
import { Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
@@ -17,10 +17,14 @@ import {
} from '../../common/organization-type';
import { InviteStaffDto } from './dto/invite-staff.dto';
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
import { UserNotificationService } from '../notifications/user-notification.service';
@Injectable()
export class StaffService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly userNotifications: UserNotificationService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
@@ -217,6 +221,19 @@ export class StaffService {
};
});
void this.userNotifications.notify({
organizationId,
type: UserNotificationType.STAFF_INVITE,
href: '/staff',
actorUserId: userId,
payload: {
membershipId: result.membershipId,
staffInvitationId: result.invitationId,
email,
},
requiredPermission: 'TAB_STAFF_READ',
});
return {
success: true,
data: {

View File

@@ -4,7 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
import {
@@ -16,6 +16,7 @@ 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';
import { UserNotificationService } from '../notifications/user-notification.service';
const taskListInclude = {
lastStatusChangedBy: { select: { id: true, name: true } },
@@ -42,6 +43,7 @@ export class TasksService {
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
private readonly labCaseActivity: LabCaseActivityService,
private readonly userNotifications: UserNotificationService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -323,6 +325,30 @@ export class TasksService {
return result;
});
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
void this.userNotifications.notify({
organizationId: labOrganizationId,
type: UserNotificationType.TASK_COMPLETED,
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(task.labCaseId)}`,
actorUserId,
payload: { labCaseId: task.labCaseId, taskId },
requiredPermission: 'TAB_TASKS_READ',
});
const clinicOrgId = task.labCase?.treatment?.organization?.id;
if (clinicOrgId) {
void this.userNotifications.notify({
organizationId: clinicOrgId,
type: UserNotificationType.TASK_COMPLETED,
href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`,
actorUserId,
payload: { labCaseId: task.labCaseId, taskId },
requiredPermission: 'TAB_TREATMENT_READ',
labCaseIdForProviderScope: task.labCaseId,
});
}
}
const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,

View File

@@ -6,7 +6,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/client';
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client';
import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
@@ -27,6 +27,7 @@ import {
} from '../../common/lab-case-due-date';
import { CLINIC_TREATMENT_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { UserNotificationService } from '../notifications/user-notification.service';
import {
generateTreatmentTitle,
normalizeTeeth,
@@ -130,6 +131,7 @@ export class TreatmentsService {
private readonly treatmentCatalog: TreatmentCatalogService,
private readonly prosthesisCatalog: ProsthesisCatalogService,
private readonly labCaseActivity: LabCaseActivityService,
private readonly userNotifications: UserNotificationService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -815,6 +817,17 @@ export class TreatmentsService {
}
});
if (isFirstSend && labCase.destinationOrganizationId) {
void this.userNotifications.notify({
organizationId: labCase.destinationOrganizationId,
type: UserNotificationType.CASE_SENT,
href: `/cases?caseId=${encodeURIComponent(labCaseId)}`,
actorUserId,
payload: { labCaseId },
requiredPermission: 'TAB_CASES_READ',
});
}
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
where: { id: labCaseId },
include: {

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { Server } from 'socket.io';
export function userOrgRoom(userId: string, organizationId: string): string {
return `user:${userId}:org:${organizationId}`;
}
@Injectable()
export class RealtimeEmitter {
private server: Server | null = null;
setServer(server: Server) {
this.server = server;
}
emitToUserOrg(userId: string, organizationId: string, event: string, payload: unknown) {
if (!this.server) return;
this.server.to(userOrgRoom(userId, organizationId)).emit(event, payload);
}
}

View File

@@ -0,0 +1,81 @@
import {
OnGatewayConnection,
OnGatewayInit,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
import { RealtimeEmitter, userOrgRoom } from './realtime.emitter';
type AccessPayload = {
sub?: string;
organizationId?: string;
type?: string;
};
@WebSocketGateway({
namespace: '/realtime',
cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:3001',
credentials: true,
},
})
export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection {
private readonly logger = new Logger(RealtimeGateway.name);
@WebSocketServer()
server!: Server;
constructor(
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly emitter: RealtimeEmitter,
) {}
afterInit(server: Server) {
this.emitter.setServer(server);
}
async handleConnection(client: Socket) {
try {
const token = this.readAccessToken(client);
if (!token) {
client.disconnect(true);
return;
}
const payload = await this.jwt.verifyAsync<AccessPayload>(token, {
secret: this.config.get<string>('jwt.secret'),
});
if (!payload?.sub || !payload.organizationId || payload.type !== 'access') {
client.disconnect(true);
return;
}
const room = userOrgRoom(payload.sub, payload.organizationId);
await client.join(room);
client.data.userId = payload.sub;
client.data.organizationId = payload.organizationId;
} catch (error) {
this.logger.debug(`Realtime auth failed: ${String(error)}`);
client.disconnect(true);
}
}
private readAccessToken(client: Socket): string | null {
const cookieHeader = client.handshake.headers.cookie;
if (!cookieHeader) return null;
const parts = cookieHeader.split(';');
for (const part of parts) {
const [rawKey, ...rest] = part.trim().split('=');
if (rawKey === 'accessToken') {
return decodeURIComponent(rest.join('='));
}
}
return null;
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RealtimeEmitter } from './realtime.emitter';
import { RealtimeGateway } from './realtime.gateway';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('jwt.secret'),
}),
}),
],
providers: [RealtimeEmitter, RealtimeGateway],
exports: [RealtimeEmitter],
})
export class RealtimeModule {}