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
+62
View File
@@ -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;
+34
View File
@@ -51,6 +51,7 @@ model User {
userProducts UserProduct[] userProducts UserProduct[]
userRoles UserRole[] userRoles UserRole[]
smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier") smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier")
rollCalls RollCall[]
@@index([cellNumber], map: "idx_users_cell_number") @@index([cellNumber], map: "idx_users_cell_number")
@@map("users") @@map("users")
@@ -172,6 +173,7 @@ model Business {
website_sliders website_sliders[] website_sliders website_sliders[]
websitePageViews WebsitePageView[] websitePageViews WebsitePageView[]
websitePageViewCounters WebsitePageViewCounter[] websitePageViewCounters WebsitePageViewCounter[]
rollCalls RollCall[]
@@map("businesses") @@map("businesses")
} }
@@ -1962,3 +1964,35 @@ enum TranslationStatus {
@@map("translation_status") @@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")
}
+2
View File
@@ -47,6 +47,7 @@ import { PaymentsModule } from './payments/payments.module';
import { SitemapModule } from './sitemap/sitemap.module'; import { SitemapModule } from './sitemap/sitemap.module';
import { SmsTemplatesModule } from './sms-templates/sms-templates.module'; import { SmsTemplatesModule } from './sms-templates/sms-templates.module';
import { TorobModule } from './torob/torob.module'; import { TorobModule } from './torob/torob.module';
import { RollCallsModule } from './roll-calls/roll-calls.module';
@Module({ @Module({
imports: [ imports: [
@@ -98,6 +99,7 @@ import { TorobModule } from './torob/torob.module';
SitemapModule, SitemapModule,
SmsTemplatesModule, SmsTemplatesModule,
TorobModule, TorobModule,
RollCallsModule,
], ],
}) })
export class AppModule {} 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`,
);
}
}
}