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,10 +1,8 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { Prisma, UserNotificationType } from '@prisma/client';
@@ -28,7 +26,7 @@ export class StaffService {
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;
}
@@ -36,7 +34,7 @@ export class StaffService {
async list(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canViewStaff(actor)) {
throw new ForbiddenException('You do not have access to staff management');
throw new AppException(ErrorCode.PERMISSION_ACCESS_STAFF, HttpStatus.FORBIDDEN);
}
const org = await this.prisma.organization.findUnique({
@@ -44,7 +42,7 @@ export class StaffService {
include: { plan: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const [members, seatsUsed] = await Promise.all([
@@ -100,7 +98,7 @@ export class StaffService {
async invite(userId: string, organizationId: string, dto: InviteStaffDto) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot invite or manage staff');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const email = dto.email.trim().toLowerCase();
@@ -114,7 +112,7 @@ export class StaffService {
if (permissionRows.length !== normalizedPerms.length) {
const ok = new Set(permissionRows.map((p) => p.name));
const missing = normalizedPerms.filter((n) => !ok.has(n));
throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`);
throw new AppException(ErrorCode.STAFF_UNKNOWN_PERMISSIONS, HttpStatus.BAD_REQUEST);
}
const plainToken = this.generateInviteToken();
@@ -126,13 +124,11 @@ export class StaffService {
include: { plan: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (!org.plan) {
throw new BadRequestException(
'This organization has no active subscription. Please choose a plan before inviting staff.',
);
throw new AppException(ErrorCode.STAFF_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST);
}
const maxUsers = org.plan.maxUsers;
@@ -143,9 +139,7 @@ export class StaffService {
},
});
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
throw new BadRequestException(
`Your plan allows ${maxUsers} team members. Remove a member or upgrade to add more.`,
);
throw new AppException(ErrorCode.STAFF_SEAT_LIMIT, HttpStatus.BAD_REQUEST);
}
const existingUser = await tx.user.findUnique({ where: { email } });
@@ -153,7 +147,7 @@ export class StaffService {
if (existingUser) {
if (existingUser.id === org.ownerId) {
throw new BadRequestException('Organization owner is already a member');
throw new AppException(ErrorCode.STAFF_INVITE_OWNER_EMAIL, HttpStatus.BAD_REQUEST);
}
const dup = await tx.membership.findUnique({
where: {
@@ -164,7 +158,7 @@ export class StaffService {
},
});
if (dup) {
throw new ConflictException('This user is already a member of this organization');
throw new AppException(ErrorCode.STAFF_ALREADY_MEMBER, HttpStatus.CONFLICT);
}
targetUserId = existingUser.id;
} else {
@@ -250,7 +244,7 @@ export class StaffService {
async getInvitationLink(userId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot invite or manage staff');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const membership = await this.prisma.membership.findFirst({
@@ -262,24 +256,24 @@ export class StaffService {
});
if (!membership) {
throw new NotFoundException('Member not found');
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (membership.isOwner) {
throw new BadRequestException('Owner does not use an invitation link');
throw new AppException(ErrorCode.STAFF_OWNER_NO_INVITE_LINK, HttpStatus.BAD_REQUEST);
}
if (membership.isActive) {
throw new BadRequestException('This member has already accepted their invitation');
throw new AppException(ErrorCode.STAFF_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
}
const invitation = membership.invitations[0];
if (!invitation) {
throw new BadRequestException('No invitation found for this member');
throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.BAD_REQUEST);
}
if (invitation.acceptedAt) {
throw new BadRequestException('This invitation has already been accepted');
throw new AppException(ErrorCode.STAFF_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
}
if (invitation.revokedAt) {
throw new BadRequestException('This invitation is no longer valid');
throw new AppException(ErrorCode.STAFF_INVITE_INVALID, HttpStatus.BAD_REQUEST);
}
const plainToken = this.generateInviteToken();
@@ -324,7 +318,7 @@ export class StaffService {
const invitation = await this.findValidInvitation(dto.token);
if (invitation.acceptedAt) {
throw new BadRequestException('This invitation has already been accepted');
throw new AppException(ErrorCode.STAFF_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
}
const passwordHash = await bcrypt.hash(dto.password, 10);
@@ -367,7 +361,7 @@ export class StaffService {
) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot edit staff');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const target = await this.prisma.membership.findFirst({
@@ -379,10 +373,10 @@ export class StaffService {
});
if (!target) {
throw new NotFoundException('Member not found');
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (target.isOwner) {
throw new ForbiddenException('Owner membership cannot be edited here');
throw new AppException(ErrorCode.STAFF_CANNOT_EDIT_OWNER, HttpStatus.FORBIDDEN);
}
if (dto.name !== undefined) {
@@ -402,7 +396,7 @@ export class StaffService {
if (permissionRows.length !== normalizedPerms.length) {
const ok = new Set(permissionRows.map((p) => p.name));
const missing = normalizedPerms.filter((n) => !ok.has(n));
throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`);
throw new AppException(ErrorCode.STAFF_UNKNOWN_PERMISSIONS, HttpStatus.BAD_REQUEST);
}
await this.prisma.$transaction([
@@ -426,7 +420,7 @@ export class StaffService {
async enableMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot manage staff');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const target = await this.prisma.membership.findFirst({
@@ -437,20 +431,18 @@ export class StaffService {
});
if (!target) {
throw new NotFoundException('Member not found');
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (target.isOwner) {
throw new ForbiddenException('Cannot enable the organization owner');
throw new AppException(ErrorCode.STAFF_CANNOT_ENABLE_OWNER, HttpStatus.FORBIDDEN);
}
if (target.isActive) {
throw new BadRequestException('This member is already active');
throw new AppException(ErrorCode.STAFF_ALREADY_ACTIVE, HttpStatus.BAD_REQUEST);
}
const invitation = target.invitations[0];
if (invitation && !invitation.acceptedAt) {
throw new BadRequestException(
'This member has not completed their invitation yet. Share the invite link instead.',
);
throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.BAD_REQUEST);
}
await this.prisma.$transaction(async (tx) => {
@@ -470,7 +462,7 @@ export class StaffService {
async disableMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot manage staff');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const target = await this.prisma.membership.findFirst({
@@ -478,16 +470,16 @@ export class StaffService {
});
if (!target) {
throw new NotFoundException('Member not found');
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (target.isOwner) {
throw new ForbiddenException('Cannot disable the organization owner');
throw new AppException(ErrorCode.STAFF_CANNOT_DISABLE_OWNER, HttpStatus.FORBIDDEN);
}
if (actorUserId === target.userId) {
throw new BadRequestException('You cannot disable your own access');
throw new AppException(ErrorCode.STAFF_CANNOT_DISABLE_SELF, HttpStatus.BAD_REQUEST);
}
if (!target.isActive) {
throw new BadRequestException('This member is already disabled or pending activation');
throw new AppException(ErrorCode.STAFF_ALREADY_DISABLED, HttpStatus.BAD_REQUEST);
}
await this.prisma.membership.update({
@@ -508,7 +500,7 @@ export class StaffService {
async removeMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot remove staff');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const target = await this.prisma.membership.findFirst({
@@ -516,10 +508,10 @@ export class StaffService {
});
if (!target) {
throw new NotFoundException('Member not found');
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (target.isOwner) {
throw new ForbiddenException('Cannot remove the organization owner');
throw new AppException(ErrorCode.STAFF_CANNOT_REMOVE_OWNER, HttpStatus.FORBIDDEN);
}
await this.prisma.membership.delete({ where: { id: membershipId } });
@@ -536,12 +528,10 @@ export class StaffService {
include: { plan: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (!org.plan) {
throw new BadRequestException(
'This organization has no active subscription. Please choose a plan before adding staff.',
);
throw new AppException(ErrorCode.STAFF_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST);
}
const maxUsers = org.plan.maxUsers;
@@ -552,9 +542,7 @@ export class StaffService {
},
});
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
throw new BadRequestException(
`Your plan allows ${maxUsers} team members. Free a seat by disabling another member or upgrade your plan.`,
);
throw new AppException(ErrorCode.STAFF_SEAT_LIMIT, HttpStatus.BAD_REQUEST);
}
}
@@ -615,13 +603,13 @@ export class StaffService {
});
if (!invitation) {
throw new NotFoundException('Invitation not found');
throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.NOT_FOUND);
}
if (invitation.revokedAt) {
throw new BadRequestException('Invitation has been revoked');
throw new AppException(ErrorCode.STAFF_INVITE_REVOKED, HttpStatus.BAD_REQUEST);
}
if (invitation.expiresAt.getTime() <= Date.now()) {
throw new BadRequestException('Invitation has expired');
throw new AppException(ErrorCode.STAFF_INVITE_EXPIRED, HttpStatus.BAD_REQUEST);
}
return invitation;
}