Add reservations module and rename teams visibility label to file-access level.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user