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:
co-authored by
Cursor
parent
d237d00f65
commit
a6c2d4764e
@@ -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 {}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user