Add roll-call API for admin work-day logs with tasks.

Lets owners/admins track start/stop sessions and task lists per business day.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-07 13:21:01 +03:30
co-authored by Cursor
parent d237d00f65
commit a6c2d4764e
7 changed files with 616 additions and 0 deletions
+2
View File
@@ -47,6 +47,7 @@ import { PaymentsModule } from './payments/payments.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SmsTemplatesModule } from './sms-templates/sms-templates.module';
import { TorobModule } from './torob/torob.module';
import { RollCallsModule } from './roll-calls/roll-calls.module';
@Module({
imports: [
@@ -98,6 +99,7 @@ import { TorobModule } from './torob/torob.module';
SitemapModule,
SmsTemplatesModule,
TorobModule,
RollCallsModule,
],
})
export class AppModule {}
+88
View File
@@ -0,0 +1,88 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsDateString,
IsInt,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
const TIME_HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
export class ListRollCallsDto {
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
}
export class RollCallTaskInputDto {
@IsString()
@MinLength(1)
@MaxLength(500)
title!: string;
}
export class CreateRollCallDto {
@IsDateString()
workDate!: string;
@IsString()
@Matches(TIME_HH_MM, { message: 'startTime must be HH:mm' })
startTime!: string;
@IsString()
@Matches(TIME_HH_MM, { message: 'endTime must be HH:mm' })
endTime!: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => RollCallTaskInputDto)
tasks?: RollCallTaskInputDto[];
}
export class UpdateRollCallDto {
@IsOptional()
@IsDateString()
workDate?: string;
@IsOptional()
@IsString()
@Matches(TIME_HH_MM, { message: 'startTime must be HH:mm' })
startTime?: string;
@IsOptional()
@IsString()
@Matches(TIME_HH_MM, { message: 'endTime must be HH:mm' })
endTime?: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => RollCallTaskInputDto)
tasks?: RollCallTaskInputDto[];
}
+79
View File
@@ -0,0 +1,79 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import {
CreateRollCallDto,
ListRollCallsDto,
UpdateRollCallDto,
} from './dto/roll-call.dto';
import { RollCallsService } from './roll-calls.service';
@Controller('businesses/:businessId/roll-calls')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class RollCallsController {
constructor(private readonly service: RollCallsService) {}
@Get()
@RequireBusinessPermission('roll_calls.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListRollCallsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.list(businessId, query, user);
}
@Get(':rollCallId')
@RequireBusinessPermission('roll_calls.read')
getOne(
@Param('businessId') businessId: string,
@Param('rollCallId') rollCallId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getOne(businessId, rollCallId, user);
}
@Post()
@RequireBusinessPermission('roll_calls.create')
create(
@Param('businessId') businessId: string,
@Body() dto: CreateRollCallDto,
@CurrentUser() user: AuthUser,
) {
return this.service.create(businessId, dto, user);
}
@Patch(':rollCallId')
@RequireBusinessPermission('roll_calls.update')
update(
@Param('businessId') businessId: string,
@Param('rollCallId') rollCallId: string,
@Body() dto: UpdateRollCallDto,
@CurrentUser() user: AuthUser,
) {
return this.service.update(businessId, rollCallId, dto, user);
}
@Delete(':rollCallId')
@RequireBusinessPermission('roll_calls.delete')
remove(
@Param('businessId') businessId: string,
@Param('rollCallId') rollCallId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.remove(businessId, rollCallId, user);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { RollCallsController } from './roll-calls.controller';
import { RollCallsService } from './roll-calls.service';
@Module({
imports: [AuthModule],
controllers: [RollCallsController],
providers: [RollCallsService],
})
export class RollCallsModule {}
+340
View File
@@ -0,0 +1,340 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma, RollCall, RollCallTask } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateRollCallDto,
ListRollCallsDto,
UpdateRollCallDto,
} from './dto/roll-call.dto';
type RollCallWithTasks = RollCall & { tasks: RollCallTask[] };
@Injectable()
export class RollCallsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
async list(businessIdRaw: string, query: ListRollCallsDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'roll_calls.read');
const page = query.page ?? 1;
const pageSize = Math.min(Math.max(query.pageSize ?? 24, 1), 100);
const skip = (page - 1) * pageSize;
const where = this.buildWhere(businessId, actor.id, query);
const [items, total, allForSummary] = await Promise.all([
this.prisma.rollCall.findMany({
where,
orderBy: [{ workDate: 'desc' }, { id: 'desc' }],
skip,
take: pageSize,
include: {
tasks: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
}),
this.prisma.rollCall.count({ where }),
this.prisma.rollCall.findMany({
where,
select: {
startTime: true,
endTime: true,
_count: { select: { tasks: true } },
},
}),
]);
const totalWorkingMinutes = allForSummary.reduce(
(sum, row) => sum + this.workingMinutes(row.startTime, row.endTime),
0,
);
const totalTasks = allForSummary.reduce(
(sum, row) => sum + row._count.tasks,
0,
);
return {
items: items.map((item) => this.serialize(item)),
total,
page,
pageSize,
summary: {
totalDays: total,
totalWorkingMinutes,
totalTasks,
},
};
}
async getOne(
businessIdRaw: string,
rollCallIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'roll_calls.read');
const rollCall = await this.findOwnedOrThrow(
businessId,
actor.id,
BigInt(rollCallIdRaw),
);
return { rollCall: this.serialize(rollCall) };
}
async create(
businessIdRaw: string,
dto: CreateRollCallDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'roll_calls.create');
const workDate = this.parseWorkDate(dto.workDate);
const tasks = this.normalizeTasks((dto.tasks ?? []).map((task) => task.title));
try {
const created = await this.prisma.rollCall.create({
data: {
businessId,
userId: actor.id,
workDate,
startTime: dto.startTime,
endTime: dto.endTime,
tasks: {
create: tasks.map((title, index) => ({
title,
sortOrder: index,
})),
},
},
include: {
tasks: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
});
return {
rollCall: this.serialize(created),
message: 'Roll call created successfully',
};
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
throw new ConflictException(
'A roll call already exists for this day',
);
}
throw err;
}
}
async update(
businessIdRaw: string,
rollCallIdRaw: string,
dto: UpdateRollCallDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'roll_calls.update');
const rollCallId = BigInt(rollCallIdRaw);
await this.findOwnedOrThrow(businessId, actor.id, rollCallId);
const workDate =
dto.workDate !== undefined ? this.parseWorkDate(dto.workDate) : undefined;
const tasks =
dto.tasks !== undefined
? this.normalizeTasks(dto.tasks.map((task) => task.title))
: undefined;
try {
const updated = await this.prisma.$transaction(async (tx) => {
await tx.rollCall.update({
where: { id: rollCallId },
data: {
...(workDate !== undefined ? { workDate } : {}),
...(dto.startTime !== undefined ? { startTime: dto.startTime } : {}),
...(dto.endTime !== undefined ? { endTime: dto.endTime } : {}),
},
});
if (tasks !== undefined) {
await tx.rollCallTask.deleteMany({ where: { rollCallId } });
if (tasks.length > 0) {
await tx.rollCallTask.createMany({
data: tasks.map((title, index) => ({
rollCallId,
title,
sortOrder: index,
})),
});
}
}
return tx.rollCall.findUniqueOrThrow({
where: { id: rollCallId },
include: {
tasks: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
});
});
return {
rollCall: this.serialize(updated),
message: 'Roll call updated successfully',
};
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
throw new ConflictException(
'A roll call already exists for this day',
);
}
throw err;
}
}
async remove(
businessIdRaw: string,
rollCallIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'roll_calls.delete');
const rollCallId = BigInt(rollCallIdRaw);
await this.findOwnedOrThrow(businessId, actor.id, rollCallId);
await this.prisma.rollCall.delete({ where: { id: rollCallId } });
return { message: 'Roll call deleted successfully' };
}
private buildWhere(
businessId: bigint,
userId: bigint,
query: ListRollCallsDto,
): Prisma.RollCallWhereInput {
const workDate: Prisma.DateTimeFilter = {};
if (query.dateFrom) {
workDate.gte = this.parseWorkDate(query.dateFrom);
}
if (query.dateTo) {
workDate.lte = this.parseWorkDate(query.dateTo);
}
return {
businessId,
userId,
...(Object.keys(workDate).length > 0 ? { workDate } : {}),
};
}
private async findOwnedOrThrow(
businessId: bigint,
userId: bigint,
rollCallId: bigint,
) {
const rollCall = await this.prisma.rollCall.findFirst({
where: { id: rollCallId, businessId, userId },
include: {
tasks: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
});
if (!rollCall) {
throw new NotFoundException('Roll call not found');
}
return rollCall;
}
private parseWorkDate(value: string): Date {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
if (!match) {
throw new BadRequestException('workDate must be YYYY-MM-DD');
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
throw new BadRequestException('Invalid workDate');
}
return date;
}
private normalizeTasks(titles: string[]): string[] {
return titles.map((title) => title.trim()).filter((title) => title.length > 0);
}
private workingMinutes(startTime: string, endTime: string): number {
const start = this.timeToMinutes(startTime);
const end = this.timeToMinutes(endTime);
if (end >= start) return end - start;
return 24 * 60 - start + end;
}
private timeToMinutes(value: string): number {
const [hours, minutes] = value.split(':').map(Number);
return hours * 60 + minutes;
}
private formatWorkDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
private serialize(item: RollCallWithTasks) {
const workingMinutes = this.workingMinutes(item.startTime, item.endTime);
return {
id: item.id.toString(),
businessId: item.businessId.toString(),
userId: item.userId.toString(),
workDate: this.formatWorkDate(item.workDate),
startTime: item.startTime,
endTime: item.endTime,
workingMinutes,
taskCount: item.tasks.length,
tasks: item.tasks.map((task) => ({
id: task.id.toString(),
title: task.title,
sortOrder: task.sortOrder,
})),
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
};
}
private async assertPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException(
`Missing permission: ${permission} for this business`,
);
}
}
}