Commit e83d0f8c authored by Administrator's avatar Administrator

Update 12 files via Son of Anton

parent ef9a81cb
......@@ -52,6 +52,9 @@ import { UnavailabilityModule } from './modules/unavailability/unavailability.mo
import { SchedulesModule } from './modules/schedules/schedules.module';
import { MeetingsModule } from './modules/meetings/meetings.module';
// ─── Phase 2D: Reports & Daily Operations ───────────────────
import { ReportsModule } from './modules/reports/reports.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
......@@ -103,6 +106,8 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
UnavailabilityModule,
SchedulesModule,
MeetingsModule,
// Phase 2D
ReportsModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
......
import {
IsString,
IsOptional,
IsArray,
ValidateNested,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ReportTaskEntryDto } from './create-report.dto';
export class CreateAmendmentDto {
@IsString()
reportId: string;
@IsString()
@MinLength(30, { message: 'Amendment reason must be at least 30 characters' })
reason: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => ReportTaskEntryDto)
amendedTaskEntries: ReportTaskEntryDto[];
@IsOptional()
@IsString()
amendedBlockers?: string;
@IsOptional()
@IsString()
amendedNotes?: string;
}
export class ReviewAmendmentDto {
@IsString()
decision: string; // APPROVED, REJECTED
@IsOptional()
@IsString()
notes?: string;
}
\ No newline at end of file
import {
IsString,
IsOptional,
IsArray,
IsInt,
IsDateString,
IsBoolean,
ValidateNested,
Min,
MinLength,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export class ReportTaskEntryDto {
@IsOptional()
@IsString()
cardId?: string;
@IsString()
@MinLength(50, { message: 'Work description must be at least 50 characters' })
workDescription: string;
@IsInt()
@Min(15, { message: 'Minimum time per task is 15 minutes' })
timeMinutes: number;
@IsString()
taskStatus: string; // IN_PROGRESS, COMPLETED, BLOCKED
}
export class CreateReportDto {
@IsDateString()
reportDate: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => ReportTaskEntryDto)
taskEntries: ReportTaskEntryDto[];
@IsOptional()
@IsString()
blockers?: string;
@IsOptional()
@IsString()
additionalNotes?: string;
@IsOptional()
@IsString()
mood?: string; // FRUSTRATED, NEUTRAL, GOOD, ON_FIRE
}
export class SaveDraftDto {
@IsDateString()
reportDate: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => DraftTaskEntryDto)
taskEntries?: DraftTaskEntryDto[];
@IsOptional()
@IsString()
blockers?: string;
@IsOptional()
@IsString()
additionalNotes?: string;
@IsOptional()
@IsString()
mood?: string;
}
export class DraftTaskEntryDto {
@IsOptional()
@IsString()
cardId?: string;
@IsOptional()
@IsString()
workDescription?: string;
@IsOptional()
@IsInt()
@Min(0)
timeMinutes?: number;
@IsOptional()
@IsString()
taskStatus?: string;
}
\ No newline at end of file
import { IsOptional, IsString, IsDateString, IsBoolean } from 'class-validator';
import { Type } from 'class-transformer';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class ReportFilterDto extends PaginationDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsString()
boardId?: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isLate?: boolean;
@IsOptional()
@IsString()
reviewedById?: string;
}
\ No newline at end of file
export class ReportResponseDto {
id: string;
userId: string;
reportDate: string;
status: string;
totalMinutes: number;
totalHours: number;
isLate: boolean;
submittedAt: string | null;
autoApproved: boolean;
blockers: string | null;
additionalNotes: string | null;
mood: string | null;
flagReason: string | null;
reviewedAt: string | null;
reviewNotes: string | null;
user?: {
id: string;
firstName: string;
lastName: string;
avatar: string | null;
};
reviewedBy?: {
id: string;
firstName: string;
lastName: string;
} | null;
taskEntries: TaskEntryResponseDto[];
amendmentCount: number;
createdAt: string;
updatedAt: string;
}
export class TaskEntryResponseDto {
id: string;
cardId: string | null;
cardNumber: string | null;
cardTitle: string | null;
workDescription: string;
timeMinutes: number;
taskStatus: string;
}
export class ReviewDashboardDto {
reportedToday: { userId: string; name: string; status: string }[];
notReportedToday: { userId: string; name: string }[];
pendingReview: number;
flaggedThisMonth: number;
}
\ No newline at end of file
import { IsString, IsOptional, IsArray, MinLength } from 'class-validator';
export class ReviewReportDto {
@IsString()
action: string; // APPROVE, FLAG_VAGUE, FLAG_INCONSISTENT, REQUEST_REVISION
@IsOptional()
@IsString()
@MinLength(10)
notes?: string;
}
export class BulkApproveDto {
@IsArray()
@IsString({ each: true })
reportIds: string[];
}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CreateAmendmentDto, ReviewAmendmentDto } from './dto/create-amendment.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class ReportAmendmentService {
private readonly logger = new Logger(ReportAmendmentService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
) {}
async create(dto: CreateAmendmentDto, currentUser: RequestUser): Promise<any> {
const report = await this.prisma.dailyReport.findUnique({
where: { id: dto.reportId },
include: { user: { select: { id: true, firstName: true, lastName: true } } },
});
if (!report) throw new NotFoundException('Report not found');
if (report.userId !== currentUser.id) {
throw new ForbiddenException('You can only submit amendments for your own reports');
}
// Can only amend submitted/approved/flagged reports (not drafts or unreported)
const amendableStatuses = [
'SUBMITTED', 'LATE', 'APPROVED', 'AUTO_APPROVED',
'FLAGGED_VAGUE', 'FLAGGED_INCONSISTENT', 'REVISION_REQUESTED',
];
if (!amendableStatuses.includes(report.status)) {
throw new BadRequestException(`Reports in "${report.status}" status cannot be amended`);
}
// Amendment window: 7 calendar days from report date
const reportDate = new Date(report.reportDate);
const windowEnd = new Date(reportDate);
windowEnd.setDate(windowEnd.getDate() + 7);
if (new Date() > windowEnd) {
throw new BadRequestException(
'Amendment window has expired. Reports can only be amended within 7 calendar days of the report date.',
);
}
// Validate amended task entries
if (!dto.amendedTaskEntries || dto.amendedTaskEntries.length === 0) {
throw new BadRequestException('At least one task entry is required in the amendment');
}
for (const entry of dto.amendedTaskEntries) {
if (!entry.workDescription || entry.workDescription.length < 50) {
throw new BadRequestException('Each task description must be at least 50 characters');
}
if (entry.timeMinutes < 15) {
throw new BadRequestException('Minimum time per task is 15 minutes');
}
}
// Store amended entries as JSON
const amendedContent = dto.amendedTaskEntries.map((entry, i) => ({
cardId: entry.cardId || null,
workDescription: entry.workDescription,
timeMinutes: entry.timeMinutes,
taskStatus: entry.taskStatus,
position: i,
}));
const amendment = await this.prisma.reportAmendment.create({
data: {
reportId: dto.reportId,
requestedById: currentUser.id,
reason: dto.reason,
amendedTaskEntries: amendedContent,
amendedBlockers: dto.amendedBlockers || null,
amendedNotes: dto.amendedNotes || null,
status: 'PENDING',
},
include: {
requestedBy: { select: { id: true, firstName: true, lastName: true } },
},
});
// Notify reviewers (PL + Admin)
const notifyUsers = await this.prisma.user.findMany({
where: {
OR: [
{ role: { in: ['SUPER_ADMIN', 'ADMIN'] }, status: 'ACTIVE', deletedAt: null },
{ id: report.user?.id ? undefined : 'none' }, // PL
],
},
select: { id: true },
});
// Also notify the assigned PL
const contractor = await this.prisma.user.findUnique({
where: { id: report.userId },
select: { assignedProjectLeaderId: true },
});
if (contractor?.assignedProjectLeaderId) {
notifyUsers.push({ id: contractor.assignedProjectLeaderId });
}
const uniqueNotifyIds = [...new Set(notifyUsers.map((u) => u.id))];
for (const userId of uniqueNotifyIds) {
try {
await this.notificationsService.create({
userId,
type: 'IMPORTANT',
category: 'REPORT',
title: 'Report Amendment Submitted',
message: `${report.user?.firstName} submitted an amendment for their ${report.reportDate.toISOString().split('T')[0]} report. Reason: ${dto.reason.substring(0, 100)}`,
actionUrl: '/reports/review',
entityType: 'reportAmendment',
entityId: amendment.id,
});
} catch { /* non-critical */ }
}
this.logger.log(`Amendment submitted for report ${dto.reportId} by ${currentUser.email}`);
return amendment;
}
async review(
amendmentId: string,
dto: ReviewAmendmentDto,
currentUser: RequestUser,
): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot review amendments');
}
if (!['APPROVED', 'REJECTED'].includes(dto.decision)) {
throw new BadRequestException('Decision must be APPROVED or REJECTED');
}
const amendment = await this.prisma.reportAmendment.findUnique({
where: { id: amendmentId },
include: {
report: {
include: {
user: { select: { id: true, firstName: true, lastName: true, assignedProjectLeaderId: true } },
},
},
},
});
if (!amendment) throw new NotFoundException('Amendment not found');
if (amendment.status !== 'PENDING') {
throw new BadRequestException('This amendment has already been reviewed');
}
// PL can only review their team
if (currentUser.role === 'TEAM_LEAD') {
if (amendment.report.user?.assignedProjectLeaderId !== currentUser.id) {
throw new ForbiddenException('You can only review amendments from your team');
}
}
const updated = await this.prisma.reportAmendment.update({
where: { id: amendmentId },
data: {
status: dto.decision,
reviewedById: currentUser.id,
reviewedAt: new Date(),
reviewNotes: dto.notes || null,
},
});
if (dto.decision === 'APPROVED') {
// Apply the amendment: update the report's task entries
const report = amendment.report;
const amendedEntries = amendment.amendedTaskEntries as any[];
// Delete old task entries
await this.prisma.reportTaskEntry.deleteMany({
where: { reportId: report.id },
});
// Create new entries from amendment
let totalMinutes = 0;
for (const entry of amendedEntries) {
let cardNumber: string | null = null;
let cardTitle: string | null = null;
if (entry.cardId) {
const card = await this.prisma.card.findUnique({
where: { id: entry.cardId },
select: { cardNumber: true, title: true },
});
if (card) {
cardNumber = card.cardNumber;
cardTitle = card.title;
}
}
await this.prisma.reportTaskEntry.create({
data: {
reportId: report.id,
cardId: entry.cardId || null,
cardNumber,
cardTitle,
workDescription: entry.workDescription,
timeMinutes: entry.timeMinutes,
taskStatus: entry.taskStatus,
position: entry.position || 0,
},
});
totalMinutes += entry.timeMinutes;
}
// Update report status and content
await this.prisma.dailyReport.update({
where: { id: report.id },
data: {
status: 'AMENDED',
totalMinutes,
blockers: amendment.amendedBlockers ?? report.blockers,
additionalNotes: amendment.amendedNotes ?? report.additionalNotes,
},
});
this.logger.log(`Amendment ${amendmentId} approved — report ${report.id} updated`);
}
// Notify the contractor
try {
await this.notificationsService.create({
userId: amendment.report.userId,
type: 'IMPORTANT',
category: 'REPORT',
title: `Amendment ${dto.decision === 'APPROVED' ? 'Approved' : 'Rejected'}`,
message: `Your amendment for the ${amendment.report.reportDate.toISOString().split('T')[0]} report was ${dto.decision.toLowerCase()}.${dto.notes ? ` Notes: ${dto.notes}` : ''}`,
actionUrl: '/reports',
entityType: 'reportAmendment',
entityId: amendmentId,
});
} catch { /* non-critical */ }
this.logger.log(`Amendment ${amendmentId} ${dto.decision} by ${currentUser.email}`);
return updated;
}
async findByReport(reportId: string, currentUser: RequestUser): Promise<any[]> {
const report = await this.prisma.dailyReport.findUnique({ where: { id: reportId } });
if (!report) throw new NotFoundException('Report not found');
if (currentUser.role === 'CONTRACTOR' && report.userId !== currentUser.id) {
throw new ForbiddenException('You can only view amendments for your own reports');
}
return this.prisma.reportAmendment.findMany({
where: { reportId },
orderBy: { createdAt: 'desc' },
include: {
requestedBy: { select: { id: true, firstName: true, lastName: true } },
reviewedBy: { select: { id: true, firstName: true, lastName: true } },
},
});
}
}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getScheduledDaysOfWeek } from '../../common/utils/date.util';
@Injectable()
export class ReportReviewService {
private readonly logger = new Logger(ReportReviewService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
) {}
async approve(reportId: string, notes: string | undefined, currentUser: RequestUser): Promise<any> {
const report = await this.getReviewableReport(reportId, currentUser);
const updated = await this.prisma.dailyReport.update({
where: { id: reportId },
data: {
status: 'APPROVED',
reviewedById: currentUser.id,
reviewedAt: new Date(),
reviewNotes: notes || null,
},
});
this.logger.log(`Report ${reportId} approved by ${currentUser.email}`);
return updated;
}
async flagVague(reportId: string, notes: string | undefined, currentUser: RequestUser): Promise<any> {
const report = await this.getReviewableReport(reportId, currentUser);
const updated = await this.prisma.dailyReport.update({
where: { id: reportId },
data: {
status: 'FLAGGED_VAGUE',
reviewedById: currentUser.id,
reviewedAt: new Date(),
reviewNotes: notes || null,
flagReason: 'Content lacks sufficient detail',
flagCount: { increment: 1 },
},
});
// Notify contractor
try {
await this.notificationsService.create({
userId: report.userId,
type: 'IMPORTANT',
category: 'REPORT',
title: `Report Flagged: ${report.reportDate.toISOString().split('T')[0]}`,
message: `Your daily report was flagged as vague. Please add more detail to your task descriptions.${notes ? ` Reviewer notes: ${notes}` : ''}`,
actionUrl: '/reports',
entityType: 'dailyReport',
entityId: reportId,
});
} catch { /* non-critical */ }
// Check if this is the 3rd+ vague flag this month — trigger B3 deduction
await this.checkVagueFlagThreshold(report.userId);
this.logger.log(`Report ${reportId} flagged as vague by ${currentUser.email}`);
return updated;
}
async flagInconsistent(reportId: string, notes: string | undefined, currentUser: RequestUser): Promise<any> {
const report = await this.getReviewableReport(reportId, currentUser);
const updated = await this.prisma.dailyReport.update({
where: { id: reportId },
data: {
status: 'FLAGGED_INCONSISTENT',
reviewedById: currentUser.id,
reviewedAt: new Date(),
reviewNotes: notes || null,
flagReason: 'Hours do not match reported output',
flagCount: { increment: 1 },
},
});
// Notify contractor
try {
await this.notificationsService.create({
userId: report.userId,
type: 'IMPORTANT',
category: 'REPORT',
title: `Report Flagged: ${report.reportDate.toISOString().split('T')[0]}`,
message: `Your daily report was flagged as inconsistent — reported hours don't match observed output.${notes ? ` Notes: ${notes}` : ''}`,
actionUrl: '/reports',
entityType: 'dailyReport',
entityId: reportId,
});
} catch { /* non-critical */ }
// Notify admins
const admins = await this.prisma.user.findMany({
where: { role: { in: ['SUPER_ADMIN', 'ADMIN'] }, status: 'ACTIVE', deletedAt: null },
select: { id: true },
});
for (const admin of admins) {
try {
await this.notificationsService.create({
userId: admin.id,
type: 'IMPORTANT',
category: 'REPORT',
title: 'Inconsistent Report Flagged',
message: `A report by ${report.user?.firstName || 'contractor'} for ${report.reportDate.toISOString().split('T')[0]} was flagged as inconsistent. Investigation may be required.`,
entityType: 'dailyReport',
entityId: reportId,
});
} catch { /* non-critical */ }
}
this.logger.log(`Report ${reportId} flagged as inconsistent by ${currentUser.email}`);
return updated;
}
async requestRevision(reportId: string, notes: string | undefined, currentUser: RequestUser): Promise<any> {
const report = await this.getReviewableReport(reportId, currentUser);
if (!notes || notes.length < 10) {
throw new BadRequestException('Revision request requires notes explaining what needs to change (min 10 characters)');
}
const updated = await this.prisma.dailyReport.update({
where: { id: reportId },
data: {
status: 'REVISION_REQUESTED',
reviewedById: currentUser.id,
reviewedAt: new Date(),
reviewNotes: notes,
},
});
// Notify contractor
try {
await this.notificationsService.create({
userId: report.userId,
type: 'IMPORTANT',
category: 'REPORT',
title: `Revision Requested: ${report.reportDate.toISOString().split('T')[0]}`,
message: `Please revise your daily report. Notes: ${notes}. You have 24 hours to submit an amendment.`,
actionUrl: '/reports',
entityType: 'dailyReport',
entityId: reportId,
});
} catch { /* non-critical */ }
this.logger.log(`Revision requested for report ${reportId} by ${currentUser.email}`);
return updated;
}
async bulkApprove(reportIds: string[], currentUser: RequestUser): Promise<{ approved: number; skipped: number }> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot review reports');
}
let approved = 0;
let skipped = 0;
for (const reportId of reportIds) {
try {
const report = await this.prisma.dailyReport.findUnique({
where: { id: reportId },
include: { user: { select: { assignedProjectLeaderId: true } } },
});
if (!report) { skipped++; continue; }
if (!['SUBMITTED', 'LATE'].includes(report.status)) {
skipped++;
continue;
}
// PL can only approve their team
if (currentUser.role === 'TEAM_LEAD' && report.user?.assignedProjectLeaderId !== currentUser.id) {
skipped++;
continue;
}
await this.prisma.dailyReport.update({
where: { id: reportId },
data: {
status: 'APPROVED',
reviewedById: currentUser.id,
reviewedAt: new Date(),
reviewNotes: 'Bulk approved',
},
});
approved++;
} catch (err) {
this.logger.warn(`Failed to bulk approve report ${reportId}: ${err.message}`);
skipped++;
}
}
this.logger.log(`Bulk approve: ${approved} approved, ${skipped} skipped by ${currentUser.email}`);
return { approved, skipped };
}
async getReviewDashboard(currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot access the review dashboard');
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
// Get contractors based on role
const contractorWhere: any = {
role: 'CONTRACTOR',
status: { in: ['ACTIVE', 'ON_PIP'] },
deletedAt: null,
};
if (currentUser.role === 'TEAM_LEAD') {
contractorWhere.assignedProjectLeaderId = currentUser.id;
}
const contractors = await this.prisma.user.findMany({
where: contractorWhere,
select: {
id: true,
firstName: true,
lastName: true,
avatar: true,
weeklySchedule: true,
},
});
// Who reported today
const todayReports = await this.prisma.dailyReport.findMany({
where: {
reportDate: today,
userId: { in: contractors.map((c) => c.id) },
status: { not: 'DRAFT' },
},
select: { userId: true, status: true },
});
const reportedUserIds = new Set(todayReports.map((r) => r.userId));
// Build today's status lists
const reportedToday: any[] = [];
const notReportedToday: any[] = [];
for (const contractor of contractors) {
const schedule = (contractor.weeklySchedule as Record<string, string>) || {};
const scheduledDays = getScheduledDaysOfWeek(schedule);
const isWorkingDay = scheduledDays.includes(today.getDay());
if (!isWorkingDay) continue;
if (reportedUserIds.has(contractor.id)) {
const report = todayReports.find((r) => r.userId === contractor.id);
reportedToday.push({
userId: contractor.id,
name: `${contractor.firstName} ${contractor.lastName}`,
avatar: contractor.avatar,
status: report?.status || 'SUBMITTED',
});
} else {
notReportedToday.push({
userId: contractor.id,
name: `${contractor.firstName} ${contractor.lastName}`,
avatar: contractor.avatar,
});
}
}
// Pending review count
const pendingWhere: any = {
status: { in: ['SUBMITTED', 'LATE'] },
userId: { in: contractors.map((c) => c.id) },
};
const pendingReview = await this.prisma.dailyReport.count({ where: pendingWhere });
// Flagged this month
const flaggedThisMonth = await this.prisma.dailyReport.count({
where: {
userId: { in: contractors.map((c) => c.id) },
reportDate: { gte: monthStart },
status: { in: ['FLAGGED_VAGUE', 'FLAGGED_INCONSISTENT'] },
},
});
return {
reportedToday,
notReportedToday,
pendingReview,
flaggedThisMonth,
totalContractors: contractors.length,
};
}
// ─── Helpers ──────────────────────────────────────────────
private async getReviewableReport(reportId: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot review reports');
}
const report = await this.prisma.dailyReport.findUnique({
where: { id: reportId },
include: {
user: { select: { id: true, firstName: true, lastName: true, assignedProjectLeaderId: true } },
taskEntries: true,
},
});
if (!report) throw new NotFoundException('Report not found');
const reviewableStatuses = ['SUBMITTED', 'LATE', 'FLAGGED_VAGUE', 'FLAGGED_INCONSISTENT'];
if (!reviewableStatuses.includes(report.status) && currentUser.role !== 'SUPER_ADMIN') {
throw new BadRequestException(`Report in status "${report.status}" cannot be reviewed`);
}
// PL can only review their team
if (currentUser.role === 'TEAM_LEAD') {
if (report.user?.assignedProjectLeaderId !== currentUser.id) {
throw new ForbiddenException('You can only review reports from your team');
}
}
return report;
}
private async checkVagueFlagThreshold(userId: string): Promise<void> {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const vagueCount = await this.prisma.dailyReport.count({
where: {
userId,
reportDate: { gte: monthStart },
status: 'FLAGGED_VAGUE',
},
});
if (vagueCount >= 3) {
// Check if B3 deduction already exists this month
const existingB3 = await this.prisma.deduction.findFirst({
where: {
userId,
subCategory: 'B3',
payrollMonth: now.getMonth() + 1,
payrollYear: now.getFullYear(),
},
});
if (!existingB3) {
try {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { actualSalaryPiasters: true, baseSalaryPiasters: true, firstName: true, lastName: true },
});
const salary = user?.actualSalaryPiasters || user?.baseSalaryPiasters || 0;
const amount = Math.round(salary * 0.05); // 5% of monthly salary
await this.prisma.deduction.create({
data: {
userId,
category: 'B',
subCategory: 'B3',
violationDate: new Date(),
description: `Automatic deduction: ${vagueCount} reports flagged as vague/useless in ${now.toLocaleString('en-US', { month: 'long', year: 'numeric' })}. Per the deduction policy, 3+ vague reports in a single month triggers a Category B3 deduction of 5% of monthly salary.`,
amountPiasters: amount,
originalAmountPiasters: amount,
calculationBasis: `Category B3 — 5% of monthly salary (${salary} piasters). ${vagueCount} vague flags this month.`,
status: 'PENDING_ACKNOWLEDGMENT',
initiatedById: null,
initiatedByRole: 'SYSTEM',
payrollMonth: now.getMonth() + 1,
payrollYear: now.getFullYear(),
},
});
// Notify contractor
try {
await this.notificationsService.create({
userId,
type: 'BLOCKING',
category: 'DEDUCTION',
title: 'Deduction: Vague Reports (B3)',
message: `You have ${vagueCount} reports flagged as vague this month. A Category B3 deduction has been initiated.`,
actionUrl: '/salary',
isBlocking: true,
entityType: 'deduction',
});
} catch { /* non-critical */ }
this.logger.warn(
`B3 deduction auto-created for ${user?.firstName} ${user?.lastName}: ${vagueCount} vague flags this month`,
);
} catch (err) {
this.logger.error(`Failed to create B3 deduction for ${userId}: ${err.message}`);
}
}
}
}
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ReportsService } from './reports.service';
import { ReportReviewService } from './report-review.service';
import { ReportAmendmentService } from './report-amendment.service';
import { CreateReportDto, SaveDraftDto } from './dto/create-report.dto';
import { ReviewReportDto, BulkApproveDto } from './dto/review-report.dto';
import { CreateAmendmentDto, ReviewAmendmentDto } from './dto/create-amendment.dto';
import { ReportFilterDto } from './dto/report-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('reports')
export class ReportsController {
constructor(
private readonly reportsService: ReportsService,
private readonly reviewService: ReportReviewService,
private readonly amendmentService: ReportAmendmentService,
) {}
// ─── SUBMISSION ──────────────────────────────────────
@Post('draft')
async saveDraft(@Body() dto: SaveDraftDto, @CurrentUser() user: RequestUser) {
return this.reportsService.saveDraft(dto, user);
}
@Post()
async createAndSubmit(@Body() dto: CreateReportDto, @CurrentUser() user: RequestUser) {
return this.reportsService.createAndSubmit(dto, user);
}
@Post(':id/submit')
@HttpCode(HttpStatus.OK)
async submit(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.reportsService.submit(id, user);
}
// ─── READING ─────────────────────────────────────────
@Get()
async findAll(@Query() filter: ReportFilterDto, @CurrentUser() user: RequestUser) {
return this.reportsService.findAll(filter, user);
}
@Get('today')
async getTodayStatus(@CurrentUser() user: RequestUser) {
return this.reportsService.getTodayStatus(user);
}
@Get('my-cards')
async getMyAssignedCards(@CurrentUser() user: RequestUser) {
return this.reportsService.getMyAssignedCards(user);
}
@Get('stats/:userId')
async getReportingStats(
@Param('userId') userId: string,
@Query('month') month: string,
@Query('year') year: string,
@CurrentUser() user: RequestUser,
) {
// Contractors can only view own stats
if (user.role === 'CONTRACTOR' && userId !== user.id) {
return { expected: 0, reported: 0, onTime: 0, late: 0, unreported: 0, flagged: 0, approved: 0 };
}
return this.reportsService.getReportingStats(
userId,
month ? parseInt(month, 10) : new Date().getMonth() + 1,
year ? parseInt(year, 10) : new Date().getFullYear(),
);
}
@Get('date/:date')
async getMyReportForDate(@Param('date') date: string, @CurrentUser() user: RequestUser) {
return this.reportsService.getMyReportForDate(date, user);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.reportsService.findById(id, user);
}
// ─── REVIEW ──────────────────────────────────────────
@Get('review/dashboard')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
async getReviewDashboard(@CurrentUser() user: RequestUser) {
return this.reviewService.getReviewDashboard(user);
}
@Put(':id/review')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
@HttpCode(HttpStatus.OK)
async reviewReport(
@Param('id') id: string,
@Body() dto: ReviewReportDto,
@CurrentUser() user: RequestUser,
) {
switch (dto.action) {
case 'APPROVE':
return this.reviewService.approve(id, dto.notes, user);
case 'FLAG_VAGUE':
return this.reviewService.flagVague(id, dto.notes, user);
case 'FLAG_INCONSISTENT':
return this.reviewService.flagInconsistent(id, dto.notes, user);
case 'REQUEST_REVISION':
return this.reviewService.requestRevision(id, dto.notes, user);
default:
throw new Error(`Unknown review action: ${dto.action}`);
}
}
@Post('review/bulk-approve')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
@HttpCode(HttpStatus.OK)
async bulkApprove(@Body() dto: BulkApproveDto, @CurrentUser() user: RequestUser) {
return this.reviewService.bulkApprove(dto.reportIds, user);
}
// ─── AMENDMENTS ──────────────────────────────────────
@Post('amendments')
async createAmendment(@Body() dto: CreateAmendmentDto, @CurrentUser() user: RequestUser) {
return this.amendmentService.create(dto, user);
}
@Put('amendments/:id/review')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
@HttpCode(HttpStatus.OK)
async reviewAmendment(
@Param('id') id: string,
@Body() dto: ReviewAmendmentDto,
@CurrentUser() user: RequestUser,
) {
return this.amendmentService.review(id, dto, user);
}
@Get(':id/amendments')
async getAmendments(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.amendmentService.findByReport(id, user);
}
// ─── ADMIN ACTIONS ───────────────────────────────────
@Put(':id')
@Roles('SUPER_ADMIN')
async updateReport(@Param('id') id: string, @Body() data: any, @CurrentUser() user: RequestUser) {
const report = await this.reportsService.findById(id, user);
if (!report) throw new Error('Report not found');
const updateData: any = {};
if (data.status !== undefined) updateData.status = data.status;
if (data.reviewNotes !== undefined) updateData.reviewNotes = data.reviewNotes;
const prisma = (this.reportsService as any).prisma;
return prisma.dailyReport.update({ where: { id }, data: updateData });
}
@Delete(':id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async deleteReport(@Param('id') id: string, @CurrentUser() user: RequestUser) {
const prisma = (this.reportsService as any).prisma;
const report = await prisma.dailyReport.findUnique({ where: { id } });
if (!report) throw new Error('Report not found');
// Delete task entries and amendments first
await prisma.reportTaskEntry.deleteMany({ where: { reportId: id } });
await prisma.reportAmendment.deleteMany({ where: { reportId: id } });
await prisma.dailyReport.delete({ where: { id } });
return { message: 'Report deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
import { ReportReviewService } from './report-review.service';
import { ReportAmendmentService } from './report-amendment.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [ReportsController],
providers: [ReportsService, ReportReviewService, ReportAmendmentService],
exports: [ReportsService, ReportReviewService],
})
export class ReportsModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
ConflictException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateReportDto, SaveDraftDto } from './dto/create-report.dto';
import { ReportFilterDto } from './dto/report-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
import { getScheduledDaysOfWeek } from '../../common/utils/date.util';
@Injectable()
export class ReportsService {
private readonly logger = new Logger(ReportsService.name);
constructor(private readonly prisma: PrismaService) {}
async saveDraft(dto: SaveDraftDto, currentUser: RequestUser): Promise<any> {
const reportDate = new Date(dto.reportDate);
reportDate.setHours(0, 0, 0, 0);
this.validateReportDate(reportDate);
const user = await this.prisma.user.findUnique({
where: { id: currentUser.id },
select: { weeklySchedule: true, status: true },
});
if (!user || !['ACTIVE', 'ON_PIP'].includes(user.status)) {
throw new ForbiddenException('Only active contractors can submit reports');
}
await this.validateWorkingDay(currentUser.id, reportDate, user.weeklySchedule as Record<string, string>);
// Check for existing report
let report = await this.prisma.dailyReport.findUnique({
where: { userId_reportDate: { userId: currentUser.id, reportDate } },
});
if (report && report.status !== 'DRAFT') {
throw new BadRequestException('A submitted report already exists for this date. Use the amendment system to make changes.');
}
const totalMinutes = (dto.taskEntries || []).reduce(
(sum, entry) => sum + (entry.timeMinutes || 0),
0,
);
if (report) {
// Update existing draft
// Delete old task entries
await this.prisma.reportTaskEntry.deleteMany({ where: { reportId: report.id } });
report = await this.prisma.dailyReport.update({
where: { id: report.id },
data: {
blockers: dto.blockers ?? report.blockers,
additionalNotes: dto.additionalNotes ?? report.additionalNotes,
mood: dto.mood ?? report.mood,
totalMinutes,
lastDraftSavedAt: new Date(),
},
});
} else {
report = await this.prisma.dailyReport.create({
data: {
userId: currentUser.id,
reportDate,
status: 'DRAFT',
blockers: dto.blockers || null,
additionalNotes: dto.additionalNotes || null,
mood: dto.mood || null,
totalMinutes,
lastDraftSavedAt: new Date(),
},
});
}
// Create task entries
if (dto.taskEntries && dto.taskEntries.length > 0) {
for (let i = 0; i < dto.taskEntries.length; i++) {
const entry = dto.taskEntries[i];
let cardNumber: string | null = null;
let cardTitle: string | null = null;
if (entry.cardId) {
const card = await this.prisma.card.findUnique({
where: { id: entry.cardId },
select: { cardNumber: true, title: true },
});
if (card) {
cardNumber = card.cardNumber;
cardTitle = card.title;
}
}
await this.prisma.reportTaskEntry.create({
data: {
reportId: report.id,
cardId: entry.cardId || null,
cardNumber,
cardTitle,
workDescription: entry.workDescription || '',
timeMinutes: entry.timeMinutes || 0,
taskStatus: entry.taskStatus || 'IN_PROGRESS',
position: i,
},
});
}
}
return this.findById(report.id, currentUser);
}
async submit(reportId: string, currentUser: RequestUser): Promise<any> {
const report = await this.prisma.dailyReport.findUnique({
where: { id: reportId },
include: { taskEntries: true },
});
if (!report) throw new NotFoundException('Report not found');
if (report.userId !== currentUser.id) {
throw new ForbiddenException('You can only submit your own reports');
}
if (report.status !== 'DRAFT') {
throw new BadRequestException('Only draft reports can be submitted');
}
// Validate required fields
if (!report.taskEntries || report.taskEntries.length === 0) {
throw new BadRequestException('At least one task entry is required');
}
for (const entry of report.taskEntries) {
if (!entry.workDescription || entry.workDescription.length < 50) {
throw new BadRequestException(
`Work description for "${entry.cardTitle || 'task'}" must be at least 50 characters`,
);
}
if (entry.timeMinutes < 15) {
throw new BadRequestException('Minimum time per task is 15 minutes');
}
if (!['IN_PROGRESS', 'COMPLETED', 'BLOCKED'].includes(entry.taskStatus)) {
throw new BadRequestException('Invalid task status');
}
}
// Check if any task is blocked — blockers field becomes required
const hasBlockedTask = report.taskEntries.some((e) => e.taskStatus === 'BLOCKED');
if (hasBlockedTask && (!report.blockers || report.blockers.length < 30)) {
throw new BadRequestException(
'Blockers field is required (min 30 characters) when any task is marked as Blocked',
);
}
// Total hours warning (not a block)
const totalHours = report.totalMinutes / 60;
if (totalHours > 12) {
this.logger.warn(`Report ${reportId} has ${totalHours.toFixed(1)} hours — unusually high`);
}
// Determine if late
const now = new Date();
const isLate = await this.isSubmissionLate(report.reportDate, now);
const status = isLate ? 'LATE' : 'SUBMITTED';
// Check auto-approval
const shouldAutoApprove = await this.checkAutoApproval(report, isLate);
const finalStatus = shouldAutoApprove ? 'AUTO_APPROVED' : status;
const updated = await this.prisma.dailyReport.update({
where: { id: reportId },
data: {
status: finalStatus,
submittedAt: now,
isLate,
autoApproved: shouldAutoApprove,
reviewedAt: shouldAutoApprove ? now : null,
reviewNotes: shouldAutoApprove ? 'Auto-approved: all criteria met' : null,
},
});
this.logger.log(
`Report ${reportId} submitted by ${currentUser.email} for ${report.reportDate.toISOString().split('T')[0]}${finalStatus}${isLate ? ' (LATE)' : ''}`,
);
return this.findById(reportId, currentUser);
}
async createAndSubmit(dto: CreateReportDto, currentUser: RequestUser): Promise<any> {
// Save as draft first
const draft = await this.saveDraft(
{
reportDate: dto.reportDate,
taskEntries: dto.taskEntries.map((e) => ({
cardId: e.cardId,
workDescription: e.workDescription,
timeMinutes: e.timeMinutes,
taskStatus: e.taskStatus,
})),
blockers: dto.blockers,
additionalNotes: dto.additionalNotes,
mood: dto.mood,
},
currentUser,
);
// Then submit
return this.submit(draft.id, currentUser);
}
async findAll(filter: ReportFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = {};
// Permission filtering
if (currentUser.role === 'CONTRACTOR') {
where.userId = currentUser.id;
} else if (currentUser.role === 'TEAM_LEAD') {
where.user = { assignedProjectLeaderId: currentUser.id };
}
if (filter.userId) where.userId = filter.userId;
if (filter.status) where.status = filter.status;
if (filter.isLate !== undefined) where.isLate = filter.isLate;
if (filter.reviewedById) where.reviewedById = filter.reviewedById;
if (filter.dateFrom || filter.dateTo) {
where.reportDate = {};
if (filter.dateFrom) where.reportDate.gte = new Date(filter.dateFrom);
if (filter.dateTo) where.reportDate.lte = new Date(filter.dateTo);
}
// Filter by board: find users who are members of the board
if (filter.boardId) {
const boardMembers = await this.prisma.boardMember.findMany({
where: { boardId: filter.boardId },
select: { userId: true },
});
const memberIds = boardMembers.map((m) => m.userId);
where.userId = where.userId ? { in: [where.userId].flat().filter((id: string) => memberIds.includes(id)) } : { in: memberIds };
}
const [data, total] = await Promise.all([
this.prisma.dailyReport.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { reportDate: filter.sortOrder || 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
reviewedBy: { select: { id: true, firstName: true, lastName: true } },
taskEntries: {
orderBy: { position: 'asc' },
select: {
id: true,
cardId: true,
cardNumber: true,
cardTitle: true,
workDescription: true,
timeMinutes: true,
taskStatus: true,
},
},
_count: { select: { amendments: true } },
},
}),
this.prisma.dailyReport.count({ where }),
]);
const formatted = data.map((r: any) => this.formatReport(r));
return buildPaginatedResponse(formatted, total, { page, limit, sortOrder: filter.sortOrder || 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const report = await this.prisma.dailyReport.findUnique({
where: { id },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
reviewedBy: { select: { id: true, firstName: true, lastName: true } },
taskEntries: {
orderBy: { position: 'asc' },
},
amendments: {
orderBy: { createdAt: 'desc' },
include: {
requestedBy: { select: { id: true, firstName: true, lastName: true } },
reviewedBy: { select: { id: true, firstName: true, lastName: true } },
},
},
_count: { select: { amendments: true } },
},
});
if (!report) throw new NotFoundException('Report not found');
// Permission check
if (currentUser.role === 'CONTRACTOR' && report.userId !== currentUser.id) {
throw new ForbiddenException('You can only view your own reports');
}
if (currentUser.role === 'TEAM_LEAD') {
const contractor = await this.prisma.user.findUnique({
where: { id: report.userId },
select: { assignedProjectLeaderId: true },
});
if (contractor?.assignedProjectLeaderId !== currentUser.id) {
throw new ForbiddenException('You can only view reports from your team');
}
}
return this.formatReport(report);
}
async getMyReportForDate(date: string, currentUser: RequestUser): Promise<any> {
const reportDate = new Date(date);
reportDate.setHours(0, 0, 0, 0);
const report = await this.prisma.dailyReport.findUnique({
where: { userId_reportDate: { userId: currentUser.id, reportDate } },
include: {
taskEntries: { orderBy: { position: 'asc' } },
_count: { select: { amendments: true } },
},
});
if (!report) return null;
return this.formatReport(report);
}
async getTodayStatus(currentUser: RequestUser): Promise<any> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const user = await this.prisma.user.findUnique({
where: { id: currentUser.id },
select: { weeklySchedule: true, status: true },
});
if (!user) return { isWorkingDay: false, report: null };
const schedule = (user.weeklySchedule as Record<string, string>) || {};
const scheduledDays = getScheduledDaysOfWeek(schedule);
const isWorkingDay = scheduledDays.includes(today.getDay());
// Check for holiday
let isHoliday = false;
try {
const holiday = await this.prisma.holiday.findFirst({
where: {
startDate: { lte: today },
endDate: { gte: today },
},
});
isHoliday = !!holiday;
} catch { /* table may not exist */ }
// Check for unavailability
let isUnavailable = false;
try {
const unavailability = await this.prisma.unavailability.findFirst({
where: {
userId: currentUser.id,
startDate: { lte: today },
endDate: { gte: today },
},
});
isUnavailable = !!unavailability;
} catch { /* table may not exist */ }
const report = await this.prisma.dailyReport.findUnique({
where: { userId_reportDate: { userId: currentUser.id, reportDate: today } },
include: { taskEntries: { orderBy: { position: 'asc' } } },
});
return {
date: today.toISOString().split('T')[0],
isWorkingDay: isWorkingDay && !isHoliday && !isUnavailable,
isHoliday,
isUnavailable,
report: report ? this.formatReport(report) : null,
needsReport: isWorkingDay && !isHoliday && !isUnavailable && (!report || report.status === 'DRAFT'),
};
}
async getMyAssignedCards(currentUser: RequestUser): Promise<any[]> {
const cards = await this.prisma.card.findMany({
where: {
assignees: { some: { id: currentUser.id } },
deletedAt: null,
isArchived: false,
completedAt: null,
},
select: {
id: true,
cardNumber: true,
title: true,
column: {
select: { name: true, type: true, board: { select: { name: true, key: true } } },
},
},
orderBy: [{ column: { board: { name: 'asc' } } }, { position: 'asc' }],
});
return cards.map((c: any) => ({
id: c.id,
cardNumber: c.cardNumber,
title: c.title,
boardName: c.column?.board?.name,
boardKey: c.column?.board?.key,
columnName: c.column?.name,
columnType: c.column?.type,
}));
}
async getReportingStats(
userId: string,
month: number,
year: number,
): Promise<{
expected: number;
reported: number;
onTime: number;
late: number;
unreported: number;
flagged: number;
approved: number;
}> {
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0, 23, 59, 59, 999);
const reports = await this.prisma.dailyReport.findMany({
where: {
userId,
reportDate: { gte: startDate, lte: endDate },
},
select: { status: true, isLate: true },
});
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { weeklySchedule: true },
});
let expected = 0;
if (user?.weeklySchedule) {
const schedule = user.weeklySchedule as Record<string, string>;
const scheduledDays = getScheduledDaysOfWeek(schedule);
const daysInMonth = new Date(year, month, 0).getDate();
const today = new Date();
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month - 1, d);
if (date > today) break; // Don't count future days
if (scheduledDays.includes(date.getDay())) {
expected++;
}
}
}
const statuses = reports.map((r) => r.status);
return {
expected,
reported: reports.filter((r) => r.status !== 'DRAFT' && r.status !== 'UNREPORTED').length,
onTime: reports.filter((r) => !r.isLate && r.status !== 'DRAFT' && r.status !== 'UNREPORTED').length,
late: reports.filter((r) => r.isLate).length,
unreported: reports.filter((r) => r.status === 'UNREPORTED').length,
flagged: reports.filter((r) =>
['FLAGGED_VAGUE', 'FLAGGED_INCONSISTENT'].includes(r.status),
).length,
approved: reports.filter((r) =>
['APPROVED', 'AUTO_APPROVED', 'AMENDED'].includes(r.status),
).length,
};
}
// ─── Private Helpers ──────────────────────────────────────
private validateReportDate(date: Date): void {
const now = new Date();
const today = new Date();
today.setHours(0, 0, 0, 0);
if (date > today) {
throw new BadRequestException('Cannot submit reports for future dates');
}
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date < yesterday) {
throw new BadRequestException(
'Can only submit reports for today or yesterday (within grace period). Older dates require Admin override.',
);
}
}
private async validateWorkingDay(
userId: string,
date: Date,
schedule: Record<string, string>,
): Promise<void> {
const scheduledDays = getScheduledDaysOfWeek(schedule);
if (!scheduledDays.includes(date.getDay())) {
throw new BadRequestException('This is not a scheduled working day for you');
}
// Check holiday
try {
const holiday = await this.prisma.holiday.findFirst({
where: {
startDate: { lte: date },
endDate: { gte: date },
},
});
if (holiday) {
throw new BadRequestException(`Cannot submit a report for ${date.toISOString().split('T')[0]} — it is a holiday: ${holiday.name}`);
}
} catch (e) {
if (e instanceof BadRequestException) throw e;
// Holiday table might not exist, continue
}
// Check unavailability
try {
const unavailability = await this.prisma.unavailability.findFirst({
where: {
userId,
startDate: { lte: date },
endDate: { gte: date },
},
});
if (unavailability) {
throw new BadRequestException(`Cannot submit a report for ${date.toISOString().split('T')[0]} — unavailability is logged`);
}
} catch (e) {
if (e instanceof BadRequestException) throw e;
}
}
private async isSubmissionLate(reportDate: Date, submittedAt: Date): Promise<boolean> {
// Get deadline time from settings (default 23:59)
let deadlineHour = 23;
let deadlineMinute = 59;
try {
const setting = await this.prisma.setting.findUnique({
where: { key: 'reportSubmissionDeadlineTime' },
});
if (setting && typeof setting.value === 'string') {
const parts = (setting.value as string).split(':');
deadlineHour = parseInt(parts[0], 10);
deadlineMinute = parseInt(parts[1], 10);
}
} catch { /* use defaults */ }
const deadline = new Date(reportDate);
deadline.setHours(deadlineHour, deadlineMinute, 0, 0);
return submittedAt > deadline;
}
private async checkAutoApproval(report: any, isLate: boolean): Promise<boolean> {
// Check if auto-approval is enabled
try {
const setting = await this.prisma.setting.findUnique({
where: { key: 'reportAutoApproval' },
});
if (!setting || setting.value !== true) return false;
} catch {
return false;
}
// Auto-approval criteria from spec:
// 1. At least one task entry
if (!report.taskEntries || report.taskEntries.length === 0) return false;
// 2. Every task description is at least 50 characters
for (const entry of report.taskEntries) {
if (!entry.workDescription || entry.workDescription.length < 50) return false;
}
// 3. At least one task is linked to an actual card
const hasLinkedCard = report.taskEntries.some(
(e: any) => e.cardId && e.cardId.length > 0,
);
if (!hasLinkedCard) return false;
// 4. Total hours between 1 and 12
const totalHours = report.totalMinutes / 60;
if (totalHours < 1 || totalHours > 12) return false;
// 5. Report was submitted on time
if (isLate) return false;
return true;
}
private formatReport(report: any): any {
return {
id: report.id,
userId: report.userId,
reportDate: report.reportDate,
status: report.status,
totalMinutes: report.totalMinutes,
totalHours: Math.round((report.totalMinutes / 60) * 100) / 100,
isLate: report.isLate,
submittedAt: report.submittedAt,
autoApproved: report.autoApproved,
blockers: report.blockers,
additionalNotes: report.additionalNotes,
mood: report.mood,
flagReason: report.flagReason,
reviewedAt: report.reviewedAt,
reviewNotes: report.reviewNotes,
user: report.user || undefined,
reviewedBy: report.reviewedBy || null,
taskEntries: (report.taskEntries || []).map((e: any) => ({
id: e.id,
cardId: e.cardId,
cardNumber: e.cardNumber,
cardTitle: e.cardTitle,
workDescription: e.workDescription,
timeMinutes: e.timeMinutes,
taskStatus: e.taskStatus,
})),
amendments: report.amendments || undefined,
amendmentCount: report._count?.amendments || 0,
lastDraftSavedAt: report.lastDraftSavedAt,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
};
}
}
\ No newline at end of file
// ─── Phase 2D: Daily Reports & Check-in ─────────────────────
// NOTE: After adding this file, you must add reverse relations
// to the User model in schema.prisma:
// reports DailyReport[] @relation("UserReports")
// reviewedReports DailyReport[] @relation("ReportReviewer")
// amendmentRequests ReportAmendment[] @relation("AmendmentRequester")
// amendmentReviews ReportAmendment[] @relation("AmendmentReviewer")
//
// And to the Card model in schema-boards.prisma:
// reportTaskEntries ReportTaskEntry[]
model DailyReport {
id String @id @default(uuid())
userId String
user User @relation("UserReports", fields: [userId], references: [id], onDelete: Cascade)
reportDate DateTime @db.Date
status String @default("DRAFT")
// DRAFT, SUBMITTED, LATE, APPROVED, AUTO_APPROVED,
// FLAGGED_VAGUE, FLAGGED_INCONSISTENT, REVISION_REQUESTED,
// AMENDED, UNREPORTED
// ─── Task Entries ──────────────────────────────────────
taskEntries ReportTaskEntry[]
// ─── Content Fields ────────────────────────────────────
blockers String?
additionalNotes String?
mood String? // FRUSTRATED, NEUTRAL, GOOD, ON_FIRE
// ─── Computed ──────────────────────────────────────────
totalMinutes Int @default(0)
// ─── Submission Timing ─────────────────────────────────
submittedAt DateTime?
isLate Boolean @default(false)
// ─── Review ────────────────────────────────────────────
reviewedById String?
reviewedBy User? @relation("ReportReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
reviewedAt DateTime?
reviewNotes String?
flagReason String?
flagCount Int @default(0) // How many times this report has been flagged
// ─── Auto-Approval ────────────────────────────────────
autoApproved Boolean @default(false)
// ─── Amendments ────────────────────────────────────────
amendments ReportAmendment[]
// ─── Draft ─────────────────────────────────────────────
lastDraftSavedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, reportDate])
@@index([userId])
@@index([status])
@@index([reportDate])
@@index([reviewedById])
@@index([submittedAt])
}
model ReportTaskEntry {
id String @id @default(uuid())
reportId String
report DailyReport @relation(fields: [reportId], references: [id], onDelete: Cascade)
cardId String?
card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull)
// Snapshot fields preserved even if card is deleted/renamed
cardNumber String?
cardTitle String?
workDescription String // Min 50 characters per spec
timeMinutes Int // Min 15 (0:15 in 15-min increments)
taskStatus String // IN_PROGRESS, COMPLETED, BLOCKED
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([reportId])
@@index([cardId])
}
model ReportAmendment {
id String @id @default(uuid())
reportId String
report DailyReport @relation(fields: [reportId], references: [id], onDelete: Cascade)
requestedById String
requestedBy User @relation("AmendmentRequester", fields: [requestedById], references: [id], onDelete: Cascade)
reason String // Min 30 chars
amendedTaskEntries Json // Array of amended task entry objects
amendedBlockers String?
amendedNotes String?
status String @default("PENDING") // PENDING, APPROVED, REJECTED
reviewedById String?
reviewedBy User? @relation("AmendmentReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
reviewedAt DateTime?
reviewNotes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([reportId])
@@index([requestedById])
@@index([status])
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment