Add reservations module and rename teams visibility label to file-access level.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-27 09:38:17 +03:30
co-authored by Cursor
parent db65b55660
commit d133c8ca34
15 changed files with 1646 additions and 15 deletions
@@ -0,0 +1,66 @@
-- CreateTable
CREATE TABLE "ReservationService" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"durationMinutes" INTEGER NOT NULL DEFAULT 30,
"workDays" INTEGER[] DEFAULT ARRAY[0, 1, 2, 3, 4, 5, 6]::INTEGER[],
"dayStartHour" INTEGER NOT NULL DEFAULT 9,
"dayEndHour" INTEGER NOT NULL DEFAULT 17,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReservationService_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReservationAppointment" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"assigneeId" TEXT,
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReservationAppointment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ReservationService_organizationId_isActive_idx" ON "ReservationService"("organizationId", "isActive");
-- CreateIndex
CREATE INDEX "ReservationAppointment_organizationId_startsAt_idx" ON "ReservationAppointment"("organizationId", "startsAt");
-- CreateIndex
CREATE INDEX "ReservationAppointment_organizationId_status_idx" ON "ReservationAppointment"("organizationId", "status");
-- CreateIndex
CREATE INDEX "ReservationAppointment_serviceId_startsAt_idx" ON "ReservationAppointment"("serviceId", "startsAt");
-- CreateIndex
CREATE INDEX "ReservationAppointment_personId_idx" ON "ReservationAppointment"("personId");
-- CreateIndex
CREATE INDEX "ReservationAppointment_assigneeId_startsAt_idx" ON "ReservationAppointment"("assigneeId", "startsAt");
-- AddForeignKey
ALTER TABLE "ReservationService" ADD CONSTRAINT "ReservationService_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "ReservationService"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+49
View File
@@ -200,6 +200,8 @@ model Organization {
smsCampaigns SmsCampaign[]
wooCommerceConnections WooCommerceConnection[]
voipConnections VoipConnection[]
reservationServices ReservationService[]
reservationAppointments ReservationAppointment[]
}
model User {
@@ -245,6 +247,7 @@ model User {
savedViews SavedView[] @relation("SavedViewOwner")
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
teamMemberships TeamMember[]
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -425,6 +428,7 @@ model Person {
smartNotes Note[]
quotes Quote[]
invoices Invoice[]
reservationAppointments ReservationAppointment[]
ownerId String?
owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull)
tickets Ticket[]
@@ -1830,3 +1834,48 @@ model VoipCallLog {
@@index([personId])
@@index([activityId])
}
/// خدمت قابل رزرو (مثلاً مشاوره ۳۰ دقیقه‌ای)
model ReservationService {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
description String @default("")
durationMinutes Int @default(30)
/// روزهای کار ۰=یکشنبه … ۶=شنبه
workDays Int[] @default([0, 1, 2, 3, 4, 5, 6])
dayStartHour Int @default(9)
dayEndHour Int @default(17)
isActive Boolean @default(true)
appointments ReservationAppointment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, isActive])
}
model ReservationAppointment {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
serviceId String
service ReservationService @relation(fields: [serviceId], references: [id], onDelete: Restrict)
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
assigneeId String?
assignee User? @relation("ReservationAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
startsAt DateTime
endsAt DateTime
/// pending | confirmed | canceled | done
status String @default("pending")
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, startsAt])
@@index([organizationId, status])
@@index([serviceId, startsAt])
@@index([personId])
@@index([assigneeId, startsAt])
}
+2
View File
@@ -41,6 +41,7 @@ import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
import { TeamsModule } from './teams/teams.module';
import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module';
import { VoipModule } from './integrations/voip/voip.module';
import { ReservationsModule } from './reservations/reservations.module';
@Module({
imports: [
@@ -89,6 +90,7 @@ import { VoipModule } from './integrations/voip/voip.module';
TeamsModule,
WooCommerceModule,
VoipModule,
ReservationsModule,
],
})
export class AppModule {}
+205
View File
@@ -0,0 +1,205 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
const STATUSES = ['pending', 'confirmed', 'canceled', 'done'] as const;
export class CreateReservationServiceDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@Type(() => Number)
@IsInt()
@Min(5)
@Max(480)
durationMinutes!: number;
@IsOptional()
@IsArray()
@ArrayMaxSize(7)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(6, { each: true })
workDays?: number[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
dayStartHour?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(24)
dayEndHour?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateReservationServiceDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(5)
@Max(480)
durationMinutes?: number;
@IsOptional()
@IsArray()
@ArrayMaxSize(7)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(6, { each: true })
workDays?: number[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
dayStartHour?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(24)
dayEndHour?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateReservationAppointmentDto {
@IsString()
serviceId!: string;
@IsString()
personId!: string;
@IsOptional()
@IsString()
assigneeId?: string | null;
@IsDateString()
startsAt!: string;
@IsOptional()
@IsIn(STATUSES)
status?: (typeof STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string | null;
}
export class UpdateReservationAppointmentDto {
@IsOptional()
@IsString()
serviceId?: string;
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@IsString()
assigneeId?: string | null;
@IsOptional()
@IsDateString()
startsAt?: string;
@IsOptional()
@IsIn(STATUSES)
status?: (typeof STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string | null;
}
export class ListReservationsQueryDto {
@IsOptional()
@IsIn(STATUSES)
status?: (typeof STATUSES)[number];
@IsOptional()
@IsDateString()
from?: string;
@IsOptional()
@IsDateString()
to?: string;
@IsOptional()
@IsString()
serviceId?: string;
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 30;
}
export class SlotsQueryDto {
@IsString()
serviceId!: string;
@IsDateString()
date!: string;
@IsOptional()
@IsString()
assigneeId?: string;
}
@@ -0,0 +1,118 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import type { Actor } from '../common/record-scope';
import {
CreateReservationAppointmentDto,
CreateReservationServiceDto,
ListReservationsQueryDto,
SlotsQueryDto,
UpdateReservationAppointmentDto,
UpdateReservationServiceDto,
} from './dto';
import { ReservationsService } from './reservations.service';
@Controller('reservations')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('reservations')
export class ReservationsController {
constructor(private readonly reservations: ReservationsService) {}
@Get('services')
listServices(@CurrentUser() user: Actor) {
return this.reservations.listServices(user.organizationId);
}
@Post('services')
@Roles('owner', 'admin')
createService(
@CurrentUser() user: Actor,
@Body() dto: CreateReservationServiceDto,
) {
return this.reservations.createService(user.organizationId, dto);
}
@Patch('services/:id')
@Roles('owner', 'admin')
updateService(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateReservationServiceDto,
) {
return this.reservations.updateService(user.organizationId, id, dto);
}
@Delete('services/:id')
@Roles('owner', 'admin')
async removeService(
@CurrentUser() user: Actor,
@Param('id') id: string,
) {
await this.reservations.removeService(user.organizationId, id);
return { ok: true };
}
@Get('slots')
slots(@CurrentUser() user: Actor, @Query() query: SlotsQueryDto) {
return this.reservations.listSlots(
user.organizationId,
query.serviceId,
query.date,
query.assigneeId,
);
}
@Get()
list(
@CurrentUser() user: Actor,
@Query() query: ListReservationsQueryDto,
) {
return this.reservations.listAppointments(user.organizationId, user, query);
}
@Post()
create(
@CurrentUser() user: Actor,
@Body() dto: CreateReservationAppointmentDto,
) {
return this.reservations.createAppointment(
user.organizationId,
user,
dto,
);
}
@Patch(':id')
update(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateReservationAppointmentDto,
) {
return this.reservations.updateAppointment(
user.organizationId,
user,
id,
dto,
);
}
@Delete(':id')
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.reservations.removeAppointment(user.organizationId, user, id);
return { ok: true };
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { ReservationsController } from './reservations.controller';
import { ReservationsService } from './reservations.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [ReservationsController],
providers: [ReservationsService],
exports: [ReservationsService],
})
export class ReservationsModule {}
@@ -0,0 +1,509 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateReservationAppointmentRequest,
CreateReservationServiceRequest,
ReservationAppointmentDto,
ReservationListResponse,
ReservationServiceDto,
ReservationSlotDto,
ReservationStatus,
UpdateReservationAppointmentRequest,
UpdateReservationServiceRequest,
} from '@mento/shared';
import { RESERVATION_STATUSES } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { type Actor, elevated } from '../common/record-scope';
@Injectable()
export class ReservationsService {
constructor(private readonly prisma: PrismaService) {}
/* ——— services ——— */
async listServices(organizationId: string): Promise<ReservationServiceDto[]> {
const rows = await this.prisma.reservationService.findMany({
where: { organizationId },
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
});
return rows.map((r) => this.toServiceDto(r));
}
async createService(
organizationId: string,
dto: CreateReservationServiceRequest,
): Promise<ReservationServiceDto> {
const name = dto.name?.trim();
if (!name) throw new BadRequestException('نام خدمت الزامی است');
const duration = Number(dto.durationMinutes);
if (!Number.isFinite(duration) || duration < 5) {
throw new BadRequestException('مدت خدمت نامعتبر است');
}
const dayStart = dto.dayStartHour ?? 9;
const dayEnd = dto.dayEndHour ?? 17;
if (dayEnd <= dayStart) {
throw new BadRequestException('ساعت پایان باید بعد از شروع باشد');
}
const row = await this.prisma.reservationService.create({
data: {
organizationId,
name,
description: dto.description?.trim() ?? '',
durationMinutes: duration,
workDays: dto.workDays?.length ? dto.workDays : [0, 1, 2, 3, 4, 5, 6],
dayStartHour: dayStart,
dayEndHour: dayEnd,
isActive: dto.isActive ?? true,
},
});
return this.toServiceDto(row);
}
async updateService(
organizationId: string,
id: string,
dto: UpdateReservationServiceRequest,
): Promise<ReservationServiceDto> {
const existing = await this.getServiceOrThrow(organizationId, id);
const data: Prisma.ReservationServiceUpdateInput = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام خدمت الزامی است');
data.name = name;
}
if (dto.description !== undefined) data.description = dto.description.trim();
if (dto.durationMinutes !== undefined) {
if (dto.durationMinutes < 5) throw new BadRequestException('مدت نامعتبر است');
data.durationMinutes = dto.durationMinutes;
}
if (dto.workDays !== undefined) data.workDays = dto.workDays;
if (dto.dayStartHour !== undefined) data.dayStartHour = dto.dayStartHour;
if (dto.dayEndHour !== undefined) data.dayEndHour = dto.dayEndHour;
if (dto.isActive !== undefined) data.isActive = dto.isActive;
const nextStart =
dto.dayStartHour !== undefined ? dto.dayStartHour : existing.dayStartHour;
const nextEnd =
dto.dayEndHour !== undefined ? dto.dayEndHour : existing.dayEndHour;
if (nextEnd <= nextStart) {
throw new BadRequestException('ساعت پایان باید بعد از شروع باشد');
}
const row = await this.prisma.reservationService.update({
where: { id },
data,
});
return this.toServiceDto(row);
}
async removeService(organizationId: string, id: string): Promise<void> {
await this.getServiceOrThrow(organizationId, id);
const count = await this.prisma.reservationAppointment.count({
where: {
serviceId: id,
status: { in: ['pending', 'confirmed'] },
},
});
if (count) {
throw new BadRequestException(
'خدمت دارای نوبت فعال است؛ ابتدا نوبت‌ها را لغو کنید یا خدمت را غیرفعال کنید',
);
}
await this.prisma.reservationService.delete({ where: { id } });
}
/* ——— slots ——— */
async listSlots(
organizationId: string,
serviceId: string,
dateIso: string,
assigneeId?: string,
): Promise<ReservationSlotDto[]> {
const service = await this.getServiceOrThrow(organizationId, serviceId);
if (!service.isActive) return [];
const dayStart = this.parseDayStart(dateIso);
const weekday = dayStart.getDay();
if (!service.workDays.includes(weekday)) return [];
const windowStart = new Date(dayStart);
windowStart.setHours(service.dayStartHour, 0, 0, 0);
const windowEnd = new Date(dayStart);
windowEnd.setHours(service.dayEndHour, 0, 0, 0);
const busy = await this.prisma.reservationAppointment.findMany({
where: {
organizationId,
status: { in: ['pending', 'confirmed'] },
startsAt: { lt: windowEnd },
endsAt: { gt: windowStart },
...(assigneeId ? { assigneeId } : { serviceId }),
},
select: { startsAt: true, endsAt: true },
});
const slots: ReservationSlotDto[] = [];
const stepMs = service.durationMinutes * 60_000;
for (
let t = windowStart.getTime();
t + stepMs <= windowEnd.getTime();
t += stepMs
) {
const startsAt = new Date(t);
const endsAt = new Date(t + stepMs);
const overlap = busy.some(
(b) => b.startsAt < endsAt && b.endsAt > startsAt,
);
if (!overlap) {
slots.push({
startsAt: startsAt.toISOString(),
endsAt: endsAt.toISOString(),
});
}
}
return slots;
}
/* ——— appointments ——— */
async listAppointments(
organizationId: string,
actor: Actor,
query: {
status?: string;
from?: string;
to?: string;
serviceId?: string;
personId?: string;
page?: number;
limit?: number;
},
): Promise<ReservationListResponse> {
const page = query.page ?? 1;
const limit = query.limit ?? 30;
const where: Prisma.ReservationAppointmentWhereInput = {
organizationId,
...(query.status ? { status: query.status } : {}),
...(query.serviceId ? { serviceId: query.serviceId } : {}),
...(query.personId ? { personId: query.personId } : {}),
...(query.from || query.to
? {
startsAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
...(!elevated(actor.role) ? { assigneeId: actor.userId } : {}),
};
const [total, rows] = await Promise.all([
this.prisma.reservationAppointment.count({ where }),
this.prisma.reservationAppointment.findMany({
where,
include: this.appointmentInclude,
orderBy: { startsAt: 'asc' },
skip: (page - 1) * limit,
take: limit,
}),
]);
return {
total,
items: rows.map((r) => this.toAppointmentDto(r)),
};
}
async createAppointment(
organizationId: string,
actor: Actor,
dto: CreateReservationAppointmentRequest,
): Promise<ReservationAppointmentDto> {
const service = await this.getServiceOrThrow(organizationId, dto.serviceId);
if (!service.isActive) throw new BadRequestException('این خدمت فعال نیست');
await this.assertPerson(organizationId, dto.personId);
const assigneeId = await this.resolveAssignee(
organizationId,
actor,
dto.assigneeId,
);
const startsAt = new Date(dto.startsAt);
if (Number.isNaN(startsAt.getTime())) {
throw new BadRequestException('زمان شروع نامعتبر است');
}
const endsAt = new Date(
startsAt.getTime() + service.durationMinutes * 60_000,
);
await this.assertNoConflict(
organizationId,
service.id,
startsAt,
endsAt,
assigneeId,
);
const status = this.normalizeStatus(dto.status ?? 'pending');
const row = await this.prisma.reservationAppointment.create({
data: {
organizationId,
serviceId: service.id,
personId: dto.personId,
assigneeId,
startsAt,
endsAt,
status,
notes: dto.notes?.trim() || null,
},
include: this.appointmentInclude,
});
return this.toAppointmentDto(row);
}
async updateAppointment(
organizationId: string,
actor: Actor,
id: string,
dto: UpdateReservationAppointmentRequest,
): Promise<ReservationAppointmentDto> {
const existing = await this.prisma.reservationAppointment.findFirst({
where: {
id,
organizationId,
...(!elevated(actor.role) ? { assigneeId: actor.userId } : {}),
},
});
if (!existing) throw new NotFoundException('نوبت پیدا نشد');
let serviceId = existing.serviceId;
let startsAt = existing.startsAt;
let endsAt = existing.endsAt;
let assigneeId = existing.assigneeId;
let personId = existing.personId;
if (dto.serviceId) {
const service = await this.getServiceOrThrow(organizationId, dto.serviceId);
serviceId = service.id;
endsAt = new Date(
startsAt.getTime() + service.durationMinutes * 60_000,
);
}
if (dto.startsAt) {
startsAt = new Date(dto.startsAt);
if (Number.isNaN(startsAt.getTime())) {
throw new BadRequestException('زمان شروع نامعتبر است');
}
const service = await this.getServiceOrThrow(organizationId, serviceId);
endsAt = new Date(
startsAt.getTime() + service.durationMinutes * 60_000,
);
}
if (dto.personId) {
await this.assertPerson(organizationId, dto.personId);
personId = dto.personId;
}
if (dto.assigneeId !== undefined) {
assigneeId = await this.resolveAssignee(
organizationId,
actor,
dto.assigneeId,
);
}
if (dto.startsAt || dto.serviceId || dto.assigneeId !== undefined) {
await this.assertNoConflict(
organizationId,
serviceId,
startsAt,
endsAt,
assigneeId,
id,
);
}
const row = await this.prisma.reservationAppointment.update({
where: { id },
data: {
serviceId,
personId,
assigneeId,
startsAt,
endsAt,
...(dto.status !== undefined
? { status: this.normalizeStatus(dto.status) }
: {}),
...(dto.notes !== undefined
? { notes: dto.notes?.trim() || null }
: {}),
},
include: this.appointmentInclude,
});
return this.toAppointmentDto(row);
}
async removeAppointment(
organizationId: string,
actor: Actor,
id: string,
): Promise<void> {
const existing = await this.prisma.reservationAppointment.findFirst({
where: {
id,
organizationId,
...(!elevated(actor.role) ? { assigneeId: actor.userId } : {}),
},
select: { id: true },
});
if (!existing) throw new NotFoundException('نوبت پیدا نشد');
await this.prisma.reservationAppointment.delete({ where: { id } });
}
/* ——— helpers ——— */
private appointmentInclude = {
service: { select: { id: true, name: true } },
person: { select: { id: true, firstName: true, lastName: true } },
assignee: { select: { id: true, fullName: true } },
} satisfies Prisma.ReservationAppointmentInclude;
private async getServiceOrThrow(organizationId: string, id: string) {
const row = await this.prisma.reservationService.findFirst({
where: { id, organizationId },
});
if (!row) throw new NotFoundException('خدمت پیدا نشد');
return row;
}
private async assertPerson(organizationId: string, personId: string) {
const p = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
select: { id: true },
});
if (!p) throw new BadRequestException('مشتری معتبر نیست');
}
private async resolveAssignee(
organizationId: string,
actor: Actor,
requested?: string | null,
): Promise<string | null> {
if (!elevated(actor.role)) return actor.userId;
if (!requested?.trim()) return null;
const m = await this.prisma.membership.findFirst({
where: {
organizationId,
userId: requested.trim(),
isActive: true,
},
select: { userId: true },
});
if (!m) throw new BadRequestException('کارشناس معتبر نیست');
return m.userId;
}
private async assertNoConflict(
organizationId: string,
serviceId: string,
startsAt: Date,
endsAt: Date,
assigneeId: string | null,
exceptId?: string,
) {
const conflict = await this.prisma.reservationAppointment.findFirst({
where: {
organizationId,
status: { in: ['pending', 'confirmed'] },
id: exceptId ? { not: exceptId } : undefined,
startsAt: { lt: endsAt },
endsAt: { gt: startsAt },
OR: [
{ serviceId },
...(assigneeId ? [{ assigneeId }] : []),
],
},
select: { id: true },
});
if (conflict) {
throw new BadRequestException('این بازه زمانی قبلاً رزرو شده است');
}
}
private normalizeStatus(value: string): ReservationStatus {
if (RESERVATION_STATUSES.includes(value as ReservationStatus)) {
return value as ReservationStatus;
}
throw new BadRequestException('وضعیت نامعتبر است');
}
private parseDayStart(dateIso: string): Date {
const d = new Date(dateIso);
if (Number.isNaN(d.getTime())) {
throw new BadRequestException('تاریخ نامعتبر است');
}
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
private toServiceDto(row: {
id: string;
name: string;
description: string;
durationMinutes: number;
workDays: number[];
dayStartHour: number;
dayEndHour: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}): ReservationServiceDto {
return {
id: row.id,
name: row.name,
description: row.description,
durationMinutes: row.durationMinutes,
workDays: row.workDays,
dayStartHour: row.dayStartHour,
dayEndHour: row.dayEndHour,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private toAppointmentDto(row: {
id: string;
serviceId: string;
personId: string;
assigneeId: string | null;
startsAt: Date;
endsAt: Date;
status: string;
notes: string | null;
createdAt: Date;
updatedAt: Date;
service: { name: string };
person: { firstName: string; lastName: string };
assignee: { fullName: string } | null;
}): ReservationAppointmentDto {
return {
id: row.id,
serviceId: row.serviceId,
serviceName: row.service.name,
personId: row.personId,
personName: `${row.person.firstName} ${row.person.lastName}`.trim(),
assigneeId: row.assigneeId,
assigneeName: row.assignee?.fullName ?? null,
startsAt: row.startsAt.toISOString(),
endsAt: row.endsAt.toISOString(),
status: row.status as ReservationStatus,
notes: row.notes,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+2 -2
View File
@@ -169,10 +169,10 @@ export class UsersService {
dataVisibility: DataVisibility,
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('اجازه تغییر سطح دید را ندارید');
throw new ForbiddenException('اجازه تغییر سطح دسترسی به پرونده‌ها را ندارید');
}
if (!DATA_VISIBILITIES.includes(dataVisibility)) {
throw new BadRequestException('سطح دید نامعتبر است');
throw new BadRequestException('سطح دسترسی به پرونده‌ها نامعتبر است');
}
const target = await this.memberships.findUserInOrgById(
organizationId,