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
+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,