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
@@ -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;
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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