From a6c2d4764eb2b744f203c676fd678661e689e05c Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 7 Oct 2026 13:21:01 +0330 Subject: [PATCH] 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 --- database/migrations/103_roll_calls.sql | 62 +++++ prisma/schema.prisma | 34 +++ src/app.module.ts | 2 + src/roll-calls/dto/roll-call.dto.ts | 88 ++++++ src/roll-calls/roll-calls.controller.ts | 79 ++++++ src/roll-calls/roll-calls.module.ts | 11 + src/roll-calls/roll-calls.service.ts | 340 ++++++++++++++++++++++++ 7 files changed, 616 insertions(+) create mode 100644 database/migrations/103_roll_calls.sql create mode 100644 src/roll-calls/dto/roll-call.dto.ts create mode 100644 src/roll-calls/roll-calls.controller.ts create mode 100644 src/roll-calls/roll-calls.module.ts create mode 100644 src/roll-calls/roll-calls.service.ts diff --git a/database/migrations/103_roll_calls.sql b/database/migrations/103_roll_calls.sql new file mode 100644 index 0000000..ebe5c1e --- /dev/null +++ b/database/migrations/103_roll_calls.sql @@ -0,0 +1,62 @@ +-- Roll call / daily work log for business admins (customer dashboard) + +CREATE TABLE roll_calls ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + work_date DATE NOT NULL, + start_time VARCHAR(5) NOT NULL, + end_time VARCHAR(5) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT roll_calls_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT roll_calls_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT roll_calls_business_user_date_unique + UNIQUE (business_id, user_id, work_date), + CONSTRAINT roll_calls_start_time_format + CHECK (start_time ~ '^\d{2}:\d{2}$'), + CONSTRAINT roll_calls_end_time_format + CHECK (end_time ~ '^\d{2}:\d{2}$') +); + +CREATE INDEX idx_roll_calls_business_id ON roll_calls (business_id); +CREATE INDEX idx_roll_calls_user_id ON roll_calls (user_id); +CREATE INDEX idx_roll_calls_business_user_date + ON roll_calls (business_id, user_id, work_date DESC); + +CREATE TRIGGER roll_calls_set_updated_at + BEFORE UPDATE ON roll_calls + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE roll_call_tasks ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + roll_call_id BIGINT NOT NULL, + title VARCHAR(500) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT roll_call_tasks_roll_call_id_fkey + FOREIGN KEY (roll_call_id) REFERENCES roll_calls (id) ON DELETE CASCADE, + CONSTRAINT roll_call_tasks_title_nonempty + CHECK (char_length(trim(title)) > 0) +); + +CREATE INDEX idx_roll_call_tasks_roll_call_id ON roll_call_tasks (roll_call_id); + +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View roll calls', 'roll_calls.read', 'roll_calls', 'View roll call / work day logs'), + ('Create roll calls', 'roll_calls.create', 'roll_calls', 'Create roll call / work day logs'), + ('Update roll calls', 'roll_calls.update', 'roll_calls', 'Update roll call / work day logs'), + ('Delete roll calls', 'roll_calls.delete', 'roll_calls', 'Delete roll call / work day logs') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug LIKE 'roll_calls.%' +WHERE r.slug IN ('business_owner', 'owner', 'admin') +ON CONFLICT DO NOTHING; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 670fb8f..3540800 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,6 +51,7 @@ model User { userProducts UserProduct[] userRoles UserRole[] smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier") + rollCalls RollCall[] @@index([cellNumber], map: "idx_users_cell_number") @@map("users") @@ -172,6 +173,7 @@ model Business { website_sliders website_sliders[] websitePageViews WebsitePageView[] websitePageViewCounters WebsitePageViewCounter[] + rollCalls RollCall[] @@map("businesses") } @@ -1962,3 +1964,35 @@ enum TranslationStatus { @@map("translation_status") } + +model RollCall { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + userId BigInt @map("user_id") + workDate DateTime @map("work_date") @db.Date + startTime String @map("start_time") @db.VarChar(5) + endTime String @map("end_time") @db.VarChar(5) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction) + tasks RollCallTask[] + + @@unique([businessId, userId, workDate], map: "roll_calls_business_user_date_unique") + @@index([businessId], map: "idx_roll_calls_business_id") + @@index([userId], map: "idx_roll_calls_user_id") + @@index([businessId, userId, workDate(sort: Desc)], map: "idx_roll_calls_business_user_date") + @@map("roll_calls") +} + +model RollCallTask { + id BigInt @id @default(autoincrement()) + rollCallId BigInt @map("roll_call_id") + title String @db.VarChar(500) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + rollCall RollCall @relation(fields: [rollCallId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([rollCallId], map: "idx_roll_call_tasks_roll_call_id") + @@map("roll_call_tasks") +} diff --git a/src/app.module.ts b/src/app.module.ts index a81a9e5..fd7033b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/roll-calls/dto/roll-call.dto.ts b/src/roll-calls/dto/roll-call.dto.ts new file mode 100644 index 0000000..c778733 --- /dev/null +++ b/src/roll-calls/dto/roll-call.dto.ts @@ -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[]; +} diff --git a/src/roll-calls/roll-calls.controller.ts b/src/roll-calls/roll-calls.controller.ts new file mode 100644 index 0000000..3b622e1 --- /dev/null +++ b/src/roll-calls/roll-calls.controller.ts @@ -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); + } +} diff --git a/src/roll-calls/roll-calls.module.ts b/src/roll-calls/roll-calls.module.ts new file mode 100644 index 0000000..0f734f1 --- /dev/null +++ b/src/roll-calls/roll-calls.module.ts @@ -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 {} diff --git a/src/roll-calls/roll-calls.service.ts b/src/roll-calls/roll-calls.service.ts new file mode 100644 index 0000000..8f9fe7c --- /dev/null +++ b/src/roll-calls/roll-calls.service.ts @@ -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`, + ); + } + } +}