bugfix: appointment hours now use the client timezone on UTC servers.

Logical API errors throw stable codes so users see translated messages instead of a generic bad request.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 01:43:50 +03:30
parent d6958b2e48
commit 80167c622c
38 changed files with 833 additions and 392 deletions

View File

@@ -1,9 +1,8 @@
import {
BadRequestException,
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { createReadStream, existsSync } from 'fs';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
@@ -103,7 +102,7 @@ export class CasesService {
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
}
return user.organizationId;
}
@@ -300,7 +299,7 @@ export class CasesService {
});
if (!labCase) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
return {
@@ -327,7 +326,7 @@ export class CasesService {
});
if (!labCase) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
@@ -356,11 +355,11 @@ export class CasesService {
});
if (!link?.attachment) {
throw new NotFoundException('Attachment not found');
throw new AppException(ErrorCode.CASE_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (!existsSync(link.attachment.storagePath)) {
throw new NotFoundException('Attachment file is missing on disk');
throw new AppException(ErrorCode.CASE_ATTACHMENT_MISSING_FILE, HttpStatus.NOT_FOUND);
}
return {
@@ -389,7 +388,7 @@ export class CasesService {
});
if (!existing) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
await this.prisma.labCase.update({
@@ -440,7 +439,7 @@ export class CasesService {
});
if (!existing) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const externalCode =
@@ -510,7 +509,7 @@ export class CasesService {
});
if (!task) {
throw new NotFoundException('Task not found');
throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const assigneeUserId = dto.assigneeUserId ?? null;
@@ -530,7 +529,7 @@ export class CasesService {
const canReceive = memberships.some((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT'));
if (!canReceive) {
throw new BadRequestException('Selected user cannot be assigned tasks');
throw new AppException(ErrorCode.TASK_ASSIGNEE_INVALID, HttpStatus.BAD_REQUEST);
}
}
@@ -577,7 +576,7 @@ export class CasesService {
if (query.sentFrom) {
const from = new Date(query.sentFrom);
if (Number.isNaN(from.getTime())) {
throw new BadRequestException('Invalid sentFrom date');
throw new AppException(ErrorCode.INVALID_SENT_FROM, HttpStatus.BAD_REQUEST);
}
sentAtFilter.gte = from;
}
@@ -585,7 +584,7 @@ export class CasesService {
if (query.sentTo) {
const to = new Date(query.sentTo);
if (Number.isNaN(to.getTime())) {
throw new BadRequestException('Invalid sentTo date');
throw new AppException(ErrorCode.INVALID_SENT_TO, HttpStatus.BAD_REQUEST);
}
to.setHours(23, 59, 59, 999);
sentAtFilter.lte = to;
@@ -874,27 +873,27 @@ export class CasesService {
private async assertCanReadCases(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
}
if (m.isOwner) return;
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_CASES_READ') || names.includes('TAB_CASES_EDIT')) {
return;
}
throw new ForbiddenException('You do not have access to cases');
throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN);
}
private async assertCanEditCases(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
}
if (m.isOwner) return;
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_CASES_EDIT')) {
return;
}
throw new ForbiddenException('You cannot update cases');
throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN);
}
private async getMembership(userId: string, organizationId: string) {