Add Today dashboard foundation with permission-aware KPI summary API.
Replace hardcoded Today cards with a backend summary endpoint and composable frontend widgets filtered by org type and tab permissions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -37,6 +38,7 @@ import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comm
|
||||
LabCaseCommentsModule,
|
||||
StaffModule,
|
||||
OrganizationModule,
|
||||
TodayModule,
|
||||
AdminModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
20
backend/src/modules/today/dto/today-summary-query.dto.ts
Normal file
20
backend/src/modules/today/dto/today-summary-query.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsISO8601, IsOptional } from 'class-validator';
|
||||
|
||||
export class TodaySummaryQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Start of the local day range (ISO 8601). Defaults to UTC midnight today.',
|
||||
example: '2026-07-10T00:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'End of the local day range (ISO 8601, exclusive). Defaults to next UTC midnight.',
|
||||
example: '2026-07-11T00:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
to?: string;
|
||||
}
|
||||
26
backend/src/modules/today/today.controller.ts
Normal file
26
backend/src/modules/today/today.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
|
||||
import { TodayService } from './today.service';
|
||||
|
||||
@ApiTags('today')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('today')
|
||||
export class TodayController {
|
||||
constructor(private readonly todayService: TodayService) {}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Permission-aware dashboard summary for the Today tab (TAB_TODAY_READ or owner)',
|
||||
})
|
||||
getSummary(
|
||||
@Query() query: TodaySummaryQueryDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.todayService.getOrganizationIdFromUser(req.user);
|
||||
return this.todayService.getSummary(req.user.id, organizationId, query);
|
||||
}
|
||||
}
|
||||
9
backend/src/modules/today/today.module.ts
Normal file
9
backend/src/modules/today/today.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TodayController } from './today.controller';
|
||||
import { TodayService } from './today.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TodayController],
|
||||
providers: [TodayService],
|
||||
})
|
||||
export class TodayModule {}
|
||||
425
backend/src/modules/today/today.service.ts
Normal file
425
backend/src/modules/today/today.service.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { LabTaskStatus, LinkStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import {
|
||||
isUnlimitedSeats,
|
||||
normalizeTabPermissions,
|
||||
} from '../../common/permissions';
|
||||
import {
|
||||
OrganizationTypeName,
|
||||
ownerPermissionsForOrgType,
|
||||
} from '../../common/organization-type';
|
||||
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
|
||||
|
||||
type TodayWidgets = {
|
||||
appointmentsToday?: { count: number };
|
||||
patientsToday?: { count: number };
|
||||
treatmentsToday?: { count: number };
|
||||
draftTreatments?: { count: number };
|
||||
labCasesPendingSend?: { count: number };
|
||||
casesReceivedToday?: { count: number };
|
||||
tasksInProgress?: { count: number };
|
||||
importantTasks?: { count: number };
|
||||
pendingConnections?: { count: number };
|
||||
seats?: { used: number; limit: number | null; unlimited: boolean };
|
||||
pendingStaffInvites?: { count: number };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TodayService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
|
||||
async getSummary(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
query: TodaySummaryQueryDto,
|
||||
) {
|
||||
const membership = await this.getActiveMembership(userId, organizationId);
|
||||
const permissionNames = this.resolvePermissionNames(membership);
|
||||
this.assertCanViewToday(membership.isOwner, permissionNames);
|
||||
|
||||
const orgType = membership.organization.type.name as OrganizationTypeName;
|
||||
const { from, to } = this.resolveDayRange(query);
|
||||
|
||||
const widgets: TodayWidgets = {};
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
if (orgType === 'CLINIC') {
|
||||
if (this.canViewAppointments(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadAppointmentsToday(organizationId, from, to, widgets),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.canViewPatients(membership.isOwner, permissionNames) ||
|
||||
this.canViewAppointments(membership.isOwner, permissionNames)
|
||||
) {
|
||||
tasks.push(
|
||||
this.loadPatientsToday(organizationId, from, to, widgets),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.canViewTreatment(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadTreatmentsToday(organizationId, from, to, widgets),
|
||||
);
|
||||
tasks.push(this.loadDraftTreatments(organizationId, widgets));
|
||||
tasks.push(this.loadLabCasesPendingSend(organizationId, widgets));
|
||||
}
|
||||
}
|
||||
|
||||
if (orgType === 'LAB') {
|
||||
if (this.canViewCases(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadCasesReceivedToday(organizationId, from, to, widgets),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.canViewTasks(membership.isOwner, permissionNames)) {
|
||||
tasks.push(this.loadTasksInProgress(organizationId, widgets));
|
||||
tasks.push(this.loadImportantTasks(organizationId, widgets));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.canManageOrganizations(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadPendingConnections(organizationId, widgets),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.canViewStaff(membership.isOwner, permissionNames)) {
|
||||
tasks.push(this.loadSeats(organizationId, membership.organization.plan, widgets));
|
||||
tasks.push(this.loadPendingStaffInvites(organizationId, widgets));
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
orgType,
|
||||
range: { from: from.toISOString(), to: to.toISOString() },
|
||||
widgets,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private resolveDayRange(query: TodaySummaryQueryDto): { from: Date; to: Date } {
|
||||
if (query.from && query.to) {
|
||||
const from = new Date(query.from);
|
||||
const to = new Date(query.to);
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||
throw new BadRequestException('Invalid date range');
|
||||
}
|
||||
if (to <= from) {
|
||||
throw new BadRequestException('Range "to" must be after "from"');
|
||||
}
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const from = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0),
|
||||
);
|
||||
const to = new Date(from.getTime() + 86_400_000);
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
private async loadAppointmentsToday(
|
||||
organizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const count = await this.prisma.appointment.count({
|
||||
where: {
|
||||
organizationId,
|
||||
startAt: { lt: to },
|
||||
endAt: { gt: from },
|
||||
},
|
||||
});
|
||||
widgets.appointmentsToday = { count };
|
||||
}
|
||||
|
||||
private async loadPatientsToday(
|
||||
organizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const rows = await this.prisma.appointment.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
startAt: { lt: to },
|
||||
endAt: { gt: from },
|
||||
},
|
||||
select: { patientId: true },
|
||||
distinct: ['patientId'],
|
||||
});
|
||||
widgets.patientsToday = { count: rows.length };
|
||||
}
|
||||
|
||||
private async loadTreatmentsToday(
|
||||
organizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const count = await this.prisma.treatment.count({
|
||||
where: {
|
||||
organizationId,
|
||||
treatmentAt: { gte: from, lt: to },
|
||||
},
|
||||
});
|
||||
widgets.treatmentsToday = { count };
|
||||
}
|
||||
|
||||
private async loadDraftTreatments(organizationId: string, widgets: TodayWidgets) {
|
||||
const count = await this.prisma.treatment.count({
|
||||
where: {
|
||||
organizationId,
|
||||
details: { none: {} },
|
||||
},
|
||||
});
|
||||
widgets.draftTreatments = { count };
|
||||
}
|
||||
|
||||
private async loadLabCasesPendingSend(organizationId: string, widgets: TodayWidgets) {
|
||||
const count = await this.prisma.labCase.count({
|
||||
where: {
|
||||
sentAt: null,
|
||||
destinationOrganizationId: { not: null },
|
||||
treatment: { organizationId },
|
||||
},
|
||||
});
|
||||
widgets.labCasesPendingSend = { count };
|
||||
}
|
||||
|
||||
private async loadCasesReceivedToday(
|
||||
labOrganizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const count = await this.prisma.labCase.count({
|
||||
where: {
|
||||
sentAt: { gte: from, lt: to },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
});
|
||||
widgets.casesReceivedToday = { count };
|
||||
}
|
||||
|
||||
private async loadTasksInProgress(labOrganizationId: string, widgets: TodayWidgets) {
|
||||
const count = await this.prisma.labCaseTask.count({
|
||||
where: {
|
||||
status: LabTaskStatus.IN_PROGRESS,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
widgets.tasksInProgress = { count };
|
||||
}
|
||||
|
||||
private async loadImportantTasks(labOrganizationId: string, widgets: TodayWidgets) {
|
||||
const count = await this.prisma.labCaseTask.count({
|
||||
where: {
|
||||
status: LabTaskStatus.IN_PROGRESS,
|
||||
labCase: {
|
||||
isImportant: true,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
widgets.importantTasks = { count };
|
||||
}
|
||||
|
||||
private async loadPendingConnections(organizationId: string, widgets: TodayWidgets) {
|
||||
const links = await this.prisma.organizationLink.findMany({
|
||||
where: {
|
||||
status: LinkStatus.PENDING,
|
||||
OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }],
|
||||
},
|
||||
select: { sharedDataTypes: true },
|
||||
});
|
||||
|
||||
const count = links.filter((link) => {
|
||||
const requesterOrgId = this.getRequesterOrganizationId(link.sharedDataTypes);
|
||||
return requesterOrgId !== null && requesterOrgId !== organizationId;
|
||||
}).length;
|
||||
|
||||
widgets.pendingConnections = { count };
|
||||
}
|
||||
|
||||
private async loadSeats(
|
||||
organizationId: string,
|
||||
plan: { maxUsers: number } | null,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const used = await this.prisma.membership.count({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
});
|
||||
|
||||
const maxUsers = plan?.maxUsers ?? 0;
|
||||
const unlimited = isUnlimitedSeats(maxUsers);
|
||||
|
||||
widgets.seats = {
|
||||
used,
|
||||
limit: unlimited ? null : maxUsers,
|
||||
unlimited,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadPendingStaffInvites(organizationId: string, widgets: TodayWidgets) {
|
||||
const members = await this.prisma.membership.findMany({
|
||||
where: { organizationId, isOwner: false, isActive: false },
|
||||
include: {
|
||||
invitations: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const count = members.filter((member) => {
|
||||
const invitation = member.invitations[0];
|
||||
if (!invitation || invitation.acceptedAt || invitation.revokedAt) {
|
||||
return false;
|
||||
}
|
||||
return invitation.expiresAt.getTime() > Date.now();
|
||||
}).length;
|
||||
|
||||
widgets.pendingStaffInvites = { count };
|
||||
}
|
||||
|
||||
private getRequesterOrganizationId(sharedDataTypes: unknown): string | null {
|
||||
if (!sharedDataTypes || typeof sharedDataTypes !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const requester = (sharedDataTypes as { requesterOrganizationId?: unknown })
|
||||
.requesterOrganizationId;
|
||||
return typeof requester === 'string' ? requester : null;
|
||||
}
|
||||
|
||||
private async getActiveMembership(userId: string, organizationId: string) {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
type: true,
|
||||
plan: true,
|
||||
},
|
||||
},
|
||||
permissions: { include: { permission: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
private resolvePermissionNames(membership: {
|
||||
isOwner: boolean;
|
||||
organization: {
|
||||
planId: string | null;
|
||||
type: { name: string };
|
||||
};
|
||||
permissions: { permission: { name: string } }[];
|
||||
}): string[] {
|
||||
if (membership.isOwner) {
|
||||
const orgType = (membership.organization.type.name === 'LAB'
|
||||
? 'LAB'
|
||||
: 'CLINIC') as OrganizationTypeName;
|
||||
return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.planId));
|
||||
}
|
||||
return normalizeTabPermissions(
|
||||
membership.permissions.map((p) => p.permission.name),
|
||||
);
|
||||
}
|
||||
|
||||
private assertCanViewToday(isOwner: boolean, permissionNames: string[]) {
|
||||
if (isOwner) return;
|
||||
if (!permissionNames.includes('TAB_TODAY_READ')) {
|
||||
throw new ForbiddenException('You do not have access to Today');
|
||||
}
|
||||
}
|
||||
|
||||
private canViewAppointments(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
[
|
||||
'TAB_APPOINTMENTS_READ',
|
||||
'TAB_APPOINTMENTS_EDIT',
|
||||
'TAB_TREATMENT_READ',
|
||||
'TAB_TREATMENT_EDIT',
|
||||
].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canViewPatients(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canViewTreatment(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canViewCases(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_CASES_READ', 'TAB_CASES_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canViewTasks(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_TASKS_READ', 'TAB_TASKS_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canManageOrganizations(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.includes('TAB_ORGANIZATIONS_EDIT');
|
||||
}
|
||||
|
||||
private canViewStaff(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_STAFF_READ', 'TAB_STAFF_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user