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 { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
@@ -48,7 +47,7 @@ export class TasksService {
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;
}
@@ -118,7 +117,7 @@ export class TasksService {
});
if (!labCase) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
return this.listTasksForLabCaseId(labCaseId, localeInput);
@@ -176,7 +175,7 @@ export class TasksService {
});
if (!target?.labCase.sentAt) {
throw new NotFoundException('Task not found');
throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const where = await this.buildListWhere(labOrganizationId, actorUserId, listQuery);
@@ -235,11 +234,11 @@ export class TasksService {
});
if (!task) {
throw new NotFoundException('Task not found');
throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (task.assigneeUserId && task.assigneeUserId !== actorUserId) {
throw new ForbiddenException('This task is assigned to another staff member');
throw new AppException(ErrorCode.TASK_ASSIGNED_TO_OTHER, HttpStatus.FORBIDDEN);
}
const updated = await this.prisma.$transaction(async (tx) => {
@@ -419,14 +418,14 @@ export class TasksService {
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;
}
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;
@@ -540,7 +539,7 @@ export class TasksService {
const dir = query.sortDir ?? 'desc';
if (sortBy !== 'date') {
throw new BadRequestException('Task page location is only supported for date sort');
throw new AppException(ErrorCode.TASK_PAGE_DATE_SORT_ONLY, HttpStatus.BAD_REQUEST);
}
const sentAt = target.sentAt;
@@ -732,7 +731,7 @@ export class TasksService {
private async assertCanReadTasks(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 (
hasEffectivePermission(m, 'TAB_TASKS_READ') ||
@@ -740,18 +739,18 @@ export class TasksService {
) {
return;
}
throw new ForbiddenException('You do not have access to tasks');
throw new AppException(ErrorCode.PERMISSION_ACCESS_TASKS, HttpStatus.FORBIDDEN);
}
private async assertCanEditTasks(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 (hasEffectivePermission(m, 'TAB_TASKS_EDIT')) {
return;
}
throw new ForbiddenException('You cannot update tasks');
throw new AppException(ErrorCode.PERMISSION_EDIT_TASKS, HttpStatus.FORBIDDEN);
}
private async getMembership(userId: string, organizationId: string) {