Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
2869 lines
88 KiB
TypeScript
2869 lines
88 KiB
TypeScript
import { v4 as uuidv4 } from 'uuid';
|
|
import type { Knex } from 'knex';
|
|
import { computeWorkDateFields, resolveUserTimeZone } from '@alga-psa/db/workDate';
|
|
import { Temporal } from '@js-temporal/polyfill';
|
|
import { toISODate, toPlainDate } from '@alga-psa/core';
|
|
import { hasPermissionByUserId } from './shared';
|
|
|
|
export type WorkflowTimeDomainErrorCategory = 'ValidationError' | 'ActionError' | 'TransientError';
|
|
|
|
export class WorkflowTimeDomainError extends Error {
|
|
public readonly category: WorkflowTimeDomainErrorCategory;
|
|
public readonly code: string;
|
|
public readonly details: Record<string, unknown> | null;
|
|
|
|
constructor(params: {
|
|
category: WorkflowTimeDomainErrorCategory;
|
|
code: string;
|
|
message: string;
|
|
details?: Record<string, unknown>;
|
|
}) {
|
|
super(params.message);
|
|
this.name = 'WorkflowTimeDomainError';
|
|
this.category = params.category;
|
|
this.code = params.code;
|
|
this.details = params.details ?? null;
|
|
}
|
|
}
|
|
|
|
export type WorkflowTimeCreateEntryInput = {
|
|
user_id: string;
|
|
start: string;
|
|
end?: string;
|
|
duration_minutes?: number;
|
|
billable?: boolean;
|
|
billable_duration_minutes?: number;
|
|
link?: {
|
|
type: 'ticket' | 'project' | 'project_task' | 'interaction' | 'ad_hoc' | 'non_billable_category';
|
|
id: string;
|
|
};
|
|
service_id: string;
|
|
contract_line_id?: string | null;
|
|
tax_rate_id?: string | null;
|
|
notes?: string;
|
|
time_sheet_id?: string | null;
|
|
attach_to_timesheet?: boolean;
|
|
};
|
|
|
|
type ContractLineCandidate = {
|
|
client_contract_line_id: string;
|
|
bucket_overlay?: {
|
|
config_id: string;
|
|
} | null;
|
|
};
|
|
|
|
type BucketUsagePeriod = {
|
|
periodStart: string;
|
|
periodEnd: string;
|
|
};
|
|
|
|
export type WorkflowTimeCreatedEntrySummary = {
|
|
entry_id: string;
|
|
user_id: string;
|
|
work_item_id: string | null;
|
|
work_item_type: string | null;
|
|
service_id: string;
|
|
contract_line_id: string | null;
|
|
time_sheet_id: string | null;
|
|
start_time: string;
|
|
end_time: string;
|
|
total_minutes: number;
|
|
billable_minutes: number;
|
|
work_date: string;
|
|
work_timezone: string;
|
|
approval_status: string;
|
|
invoiced: boolean;
|
|
notes: string | null;
|
|
};
|
|
|
|
export type WorkflowTimeUpdateEntryInput = {
|
|
entry_id: string;
|
|
start?: string;
|
|
end?: string;
|
|
duration_minutes?: number;
|
|
billable?: boolean;
|
|
billable_duration_minutes?: number;
|
|
link?: {
|
|
type: 'ticket' | 'project' | 'project_task' | 'interaction' | 'ad_hoc' | 'non_billable_category';
|
|
id: string;
|
|
};
|
|
service_id?: string;
|
|
contract_line_id?: string | null;
|
|
tax_rate_id?: string | null;
|
|
notes?: string | null;
|
|
time_sheet_id?: string | null;
|
|
attach_to_timesheet?: boolean;
|
|
};
|
|
|
|
export type WorkflowTimeDeletedEntrySummary = {
|
|
entry_id: string;
|
|
user_id: string;
|
|
work_item_id: string | null;
|
|
work_item_type: string | null;
|
|
service_id: string;
|
|
contract_line_id: string | null;
|
|
billable_minutes: number;
|
|
deleted: true;
|
|
};
|
|
|
|
export type WorkflowTimeFindEntriesInput = {
|
|
user_id?: string;
|
|
work_item_id?: string;
|
|
work_item_type?: 'ticket' | 'project' | 'project_task' | 'interaction' | 'ad_hoc' | 'non_billable_category';
|
|
client_id?: string;
|
|
ticket_id?: string;
|
|
project_task_id?: string;
|
|
time_sheet_id?: string;
|
|
service_id?: string;
|
|
contract_line_id?: string;
|
|
approval_status?: 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'CHANGES_REQUESTED';
|
|
billable?: boolean;
|
|
work_date_from?: string;
|
|
work_date_to?: string;
|
|
start_from?: string;
|
|
start_to?: string;
|
|
invoiced?: boolean;
|
|
limit?: number;
|
|
};
|
|
|
|
export type WorkflowTimeFindEntriesResult = {
|
|
entries: WorkflowTimeCreatedEntrySummary[];
|
|
summary: {
|
|
total_count: number;
|
|
total_minutes: number;
|
|
billable_minutes: number;
|
|
};
|
|
};
|
|
|
|
export type WorkflowTimeApprovalStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'CHANGES_REQUESTED';
|
|
|
|
export type WorkflowTimeEntryApprovalResult = {
|
|
entry_id: string;
|
|
approval_status: WorkflowTimeApprovalStatus;
|
|
time_sheet_id: string | null;
|
|
change_request_id: string | null;
|
|
};
|
|
|
|
export type WorkflowTimeSheetSummary = {
|
|
time_sheet_id: string;
|
|
user_id: string;
|
|
period_id: string;
|
|
period_start_date: string;
|
|
period_end_date: string;
|
|
approval_status: string;
|
|
submitted_at: string | null;
|
|
approved_at: string | null;
|
|
approved_by: string | null;
|
|
entry_count: number;
|
|
total_minutes: number;
|
|
billable_minutes: number;
|
|
comment_count: number;
|
|
};
|
|
|
|
export type WorkflowTimeSheetCommentSummary = {
|
|
comment_id: string;
|
|
user_id: string;
|
|
comment: string;
|
|
is_approver: boolean;
|
|
created_at: string;
|
|
};
|
|
|
|
export type WorkflowTimeSheetMutationResult = {
|
|
time_sheet: WorkflowTimeSheetSummary;
|
|
};
|
|
|
|
export type WorkflowTimeSummaryGroupBy =
|
|
| 'user_id'
|
|
| 'client_id'
|
|
| 'work_item_type'
|
|
| 'work_item_id'
|
|
| 'service_id'
|
|
| 'contract_line_id'
|
|
| 'approval_status'
|
|
| 'billable'
|
|
| 'work_date';
|
|
|
|
function toIsoString(value: string | Date): string {
|
|
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
|
}
|
|
|
|
function toDateOnly(value: string | Date): string {
|
|
return value instanceof Date ? value.toISOString().slice(0, 10) : value.slice(0, 10);
|
|
}
|
|
|
|
function hasOwnProperty(obj: object, key: string): boolean {
|
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
}
|
|
|
|
function ensureValidDate(value: string, field: string): Date {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: `${field} must be a valid ISO timestamp`,
|
|
details: { field, value },
|
|
});
|
|
}
|
|
return date;
|
|
}
|
|
|
|
function assertPositiveOrZeroMinutes(value: number, field: string): void {
|
|
if (!Number.isFinite(value) || value < 0 || !Number.isInteger(value)) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: `${field} must be an integer greater than or equal to zero`,
|
|
details: { field, value },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function isManagerOfSubject(
|
|
trx: Knex.Transaction,
|
|
tenantId: string,
|
|
actorUserId: string,
|
|
subjectUserId: string
|
|
): Promise<boolean> {
|
|
const teamManagerRow = await trx('teams')
|
|
.join('team_members', function joinTeamMembers() {
|
|
this.on('teams.team_id', '=', 'team_members.team_id')
|
|
.andOn('teams.tenant', '=', 'team_members.tenant');
|
|
})
|
|
.where({
|
|
'teams.tenant': tenantId,
|
|
'teams.manager_id': actorUserId,
|
|
'team_members.user_id': subjectUserId,
|
|
})
|
|
.first('teams.team_id');
|
|
|
|
if (teamManagerRow) {
|
|
return true;
|
|
}
|
|
|
|
const reportsToRow = await trx('users')
|
|
.where({ tenant: tenantId, user_id: subjectUserId, reports_to: actorUserId })
|
|
.first('user_id');
|
|
|
|
return Boolean(reportsToRow);
|
|
}
|
|
|
|
async function hasAssignedNotSelfApproverBundleRuleForWorkflowTime(
|
|
trx: Knex.Transaction,
|
|
tenantId: string,
|
|
actorUserId: string
|
|
): Promise<boolean> {
|
|
const [roleRows, teamRows] = await Promise.all([
|
|
trx('user_roles')
|
|
.where({ tenant: tenantId, user_id: actorUserId })
|
|
.select<Array<{ role_id: string }>>('role_id'),
|
|
trx('team_members')
|
|
.where({ tenant: tenantId, user_id: actorUserId })
|
|
.select<Array<{ team_id: string }>>('team_id'),
|
|
]);
|
|
|
|
const roleIds = roleRows.map((row) => row.role_id).filter(Boolean);
|
|
const teamIds = teamRows.map((row) => row.team_id).filter(Boolean);
|
|
|
|
const rule = await trx('authorization_bundle_assignments as a')
|
|
.join('authorization_bundles as b', function joinBundles() {
|
|
this.on('b.tenant', '=', 'a.tenant').andOn('b.bundle_id', '=', 'a.bundle_id');
|
|
})
|
|
.join('authorization_bundle_rules as r', function joinRules() {
|
|
this.on('r.tenant', '=', 'b.tenant')
|
|
.andOn('r.bundle_id', '=', 'b.bundle_id')
|
|
.andOn('r.revision_id', '=', 'b.published_revision_id');
|
|
})
|
|
.where('a.tenant', tenantId)
|
|
.andWhere('a.status', 'active')
|
|
.andWhere('b.status', 'active')
|
|
.whereNotNull('b.published_revision_id')
|
|
.andWhere('r.resource_type', 'time_entry')
|
|
.andWhere('r.action', 'approve')
|
|
.andWhere('r.constraint_key', 'not_self_approver')
|
|
.andWhere((builder) => {
|
|
builder.orWhere((subBuilder) => {
|
|
subBuilder.where('a.target_type', 'user').where('a.target_id', actorUserId);
|
|
});
|
|
|
|
if (roleIds.length > 0) {
|
|
builder.orWhere((subBuilder) => {
|
|
subBuilder.where('a.target_type', 'role').whereIn('a.target_id', roleIds);
|
|
});
|
|
}
|
|
|
|
if (teamIds.length > 0) {
|
|
builder.orWhere((subBuilder) => {
|
|
subBuilder.where('a.target_type', 'team').whereIn('a.target_id', teamIds);
|
|
});
|
|
}
|
|
})
|
|
.first('r.rule_id');
|
|
|
|
return Boolean(rule);
|
|
}
|
|
|
|
async function assertCanActOnBehalfForWorkflowTime(
|
|
trx: Knex.Transaction,
|
|
tenantId: string,
|
|
actorUserId: string,
|
|
subjectUserId: string
|
|
): Promise<void> {
|
|
if (actorUserId === subjectUserId) {
|
|
return;
|
|
}
|
|
|
|
const canApprove = await hasPermissionByUserId(trx, tenantId, actorUserId, 'timesheet', 'approve');
|
|
if (!canApprove) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'PERMISSION_DENIED',
|
|
message: 'Permission denied: Cannot access other users time submissions',
|
|
details: { actor_user_id: actorUserId, subject_user_id: subjectUserId },
|
|
});
|
|
}
|
|
|
|
const canReadAll = await hasPermissionByUserId(trx, tenantId, actorUserId, 'timesheet', 'read_all');
|
|
if (canReadAll) {
|
|
return;
|
|
}
|
|
|
|
if (await isManagerOfSubject(trx, tenantId, actorUserId, subjectUserId)) {
|
|
return;
|
|
}
|
|
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'PERMISSION_DENIED',
|
|
message: 'Permission denied: Cannot access other users time submissions',
|
|
details: { actor_user_id: actorUserId, subject_user_id: subjectUserId },
|
|
});
|
|
}
|
|
|
|
async function assertCanApproveSubjectForWorkflowTime(
|
|
trx: Knex.Transaction,
|
|
tenantId: string,
|
|
actorUserId: string,
|
|
subjectUserId: string
|
|
): Promise<void> {
|
|
if (
|
|
actorUserId === subjectUserId &&
|
|
(await hasAssignedNotSelfApproverBundleRuleForWorkflowTime(trx, tenantId, actorUserId))
|
|
) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'PERMISSION_DENIED',
|
|
message: 'Permission denied: Cannot approve your own time submissions',
|
|
details: { actor_user_id: actorUserId, subject_user_id: subjectUserId },
|
|
});
|
|
}
|
|
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, subjectUserId);
|
|
}
|
|
|
|
async function scopeFindEntriesInputForActor<T extends WorkflowTimeFindEntriesInput>(
|
|
trx: Knex.Transaction,
|
|
tenantId: string,
|
|
actorUserId: string,
|
|
input: T
|
|
): Promise<T> {
|
|
const canReadAll = await hasPermissionByUserId(trx, tenantId, actorUserId, 'timesheet', 'read_all');
|
|
if (canReadAll) {
|
|
return input;
|
|
}
|
|
|
|
if (input.user_id) {
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, input.user_id);
|
|
return input;
|
|
}
|
|
|
|
const entryIds = (input as { entry_ids?: string[] }).entry_ids;
|
|
if (entryIds?.length) {
|
|
const rows = await trx('time_entries')
|
|
.where({ tenant: tenantId })
|
|
.whereIn('entry_id', entryIds)
|
|
.distinct<{ user_id: string }[]>('user_id');
|
|
for (const row of rows) {
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, String(row.user_id));
|
|
}
|
|
return input;
|
|
}
|
|
|
|
if (input.time_sheet_id) {
|
|
const sheet = await trx('time_sheets')
|
|
.where({ tenant: tenantId, id: input.time_sheet_id })
|
|
.first<{ user_id: string }>('user_id');
|
|
if (sheet?.user_id) {
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, String(sheet.user_id));
|
|
return input;
|
|
}
|
|
}
|
|
|
|
return { ...input, user_id: actorUserId };
|
|
}
|
|
|
|
async function scopeFindTimeSheetsInputForActor<T extends { user_ids?: string[] }>(
|
|
trx: Knex.Transaction,
|
|
tenantId: string,
|
|
actorUserId: string,
|
|
input: T
|
|
): Promise<T> {
|
|
const canReadAll = await hasPermissionByUserId(trx, tenantId, actorUserId, 'timesheet', 'read_all');
|
|
if (canReadAll) {
|
|
return input;
|
|
}
|
|
|
|
if (input.user_ids?.length) {
|
|
for (const userId of input.user_ids) {
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, userId);
|
|
}
|
|
return input;
|
|
}
|
|
|
|
return { ...input, user_ids: [actorUserId] };
|
|
}
|
|
|
|
async function getWorkItemClientContext(
|
|
trx: Knex.Transaction,
|
|
tenantId: string,
|
|
link: WorkflowTimeCreateEntryInput['link']
|
|
): Promise<{ clientId: string | null; ticketAssignedTo?: string | null; taskAssignedTo?: string | null }> {
|
|
if (!link) {
|
|
return { clientId: null };
|
|
}
|
|
|
|
switch (link.type) {
|
|
case 'ticket': {
|
|
const ticket = await trx('tickets')
|
|
.where({ tenant: tenantId, ticket_id: link.id })
|
|
.select('ticket_id', 'client_id', 'assigned_to')
|
|
.first();
|
|
|
|
if (!ticket) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Ticket not found',
|
|
details: { ticket_id: link.id },
|
|
});
|
|
}
|
|
|
|
return {
|
|
clientId: (ticket.client_id as string | null) ?? null,
|
|
ticketAssignedTo: (ticket.assigned_to as string | null) ?? null,
|
|
};
|
|
}
|
|
case 'project_task': {
|
|
const task = await trx('project_tasks as pt')
|
|
.join('project_phases as pp', function joinPhases() {
|
|
this.on('pt.phase_id', '=', 'pp.phase_id').andOn('pt.tenant', '=', 'pp.tenant');
|
|
})
|
|
.join('projects as p', function joinProjects() {
|
|
this.on('pp.project_id', '=', 'p.project_id').andOn('pp.tenant', '=', 'p.tenant');
|
|
})
|
|
.where({ 'pt.tenant': tenantId, 'pt.task_id': link.id })
|
|
.select('pt.task_id', 'pt.assigned_to', 'p.client_id')
|
|
.first();
|
|
|
|
if (!task) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Project task not found',
|
|
details: { task_id: link.id },
|
|
});
|
|
}
|
|
|
|
return {
|
|
clientId: (task.client_id as string | null) ?? null,
|
|
taskAssignedTo: (task.assigned_to as string | null) ?? null,
|
|
};
|
|
}
|
|
case 'project': {
|
|
const project = await trx('projects')
|
|
.where({ tenant: tenantId, project_id: link.id })
|
|
.select('project_id', 'client_id')
|
|
.first();
|
|
|
|
if (!project) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Project not found',
|
|
details: { project_id: link.id },
|
|
});
|
|
}
|
|
|
|
return { clientId: (project.client_id as string | null) ?? null };
|
|
}
|
|
case 'interaction': {
|
|
const interaction = await trx('interactions')
|
|
.where({ tenant: tenantId, interaction_id: link.id })
|
|
.select('interaction_id', 'client_id')
|
|
.first();
|
|
|
|
if (!interaction) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Interaction not found',
|
|
details: { interaction_id: link.id },
|
|
});
|
|
}
|
|
|
|
return { clientId: (interaction.client_id as string | null) ?? null };
|
|
}
|
|
case 'ad_hoc': {
|
|
const adHoc = await trx('schedule_entries')
|
|
.where({ tenant: tenantId, entry_id: link.id })
|
|
.select('entry_id')
|
|
.first();
|
|
|
|
if (!adHoc) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Schedule entry not found',
|
|
details: { entry_id: link.id },
|
|
});
|
|
}
|
|
|
|
return { clientId: null };
|
|
}
|
|
case 'non_billable_category':
|
|
return { clientId: null };
|
|
default:
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Unsupported work item type',
|
|
details: { work_item_type: (link as { type?: string }).type ?? null },
|
|
});
|
|
}
|
|
}
|
|
|
|
function resolveDeterministicContractLineSelection(
|
|
eligibleContractLines: ContractLineCandidate[]
|
|
): string | null {
|
|
if (eligibleContractLines.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
if (eligibleContractLines.length === 1) {
|
|
return eligibleContractLines[0].client_contract_line_id;
|
|
}
|
|
|
|
const overlayContractLines = eligibleContractLines.filter((contractLine) => contractLine.bucket_overlay?.config_id);
|
|
if (overlayContractLines.length === 1) {
|
|
return overlayContractLines[0].client_contract_line_id;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function determineDefaultContractLineForWorkflow(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
clientId: string;
|
|
serviceId: string;
|
|
effectiveDate: string;
|
|
}): Promise<string | null> {
|
|
const { trx, tenantId, clientId, serviceId, effectiveDate } = params;
|
|
|
|
const rangeStart = `${effectiveDate}T00:00:00.000Z`;
|
|
const rangeEnd = `${effectiveDate}T23:59:59.999Z`;
|
|
|
|
const rows = await trx('client_contracts')
|
|
.join('contracts', function joinContracts() {
|
|
this.on('client_contracts.contract_id', '=', 'contracts.contract_id')
|
|
.andOn('contracts.tenant', '=', 'client_contracts.tenant');
|
|
})
|
|
.join('contract_lines', function joinContractLines() {
|
|
this.on('contracts.contract_id', '=', 'contract_lines.contract_id')
|
|
.andOn('contract_lines.tenant', '=', 'contracts.tenant');
|
|
})
|
|
.join('contract_line_services', function joinContractLineServices() {
|
|
this.on('contract_lines.contract_line_id', '=', 'contract_line_services.contract_line_id')
|
|
.andOn('contract_line_services.tenant', '=', 'contract_lines.tenant');
|
|
})
|
|
.leftJoin('contract_line_service_configuration as bucket_config', function joinBucketConfig() {
|
|
this.on('bucket_config.contract_line_id', '=', 'contract_lines.contract_line_id')
|
|
.andOn('bucket_config.tenant', '=', 'contract_lines.tenant')
|
|
.andOn('bucket_config.service_id', '=', 'contract_line_services.service_id')
|
|
.andOnVal('bucket_config.configuration_type', 'Bucket');
|
|
})
|
|
.where({
|
|
'client_contracts.client_id': clientId,
|
|
'client_contracts.is_active': true,
|
|
'client_contracts.tenant': tenantId,
|
|
'contract_line_services.service_id': serviceId,
|
|
})
|
|
.where(function withinRange(this: Knex.QueryBuilder) {
|
|
this.where('client_contracts.start_date', '<=', rangeEnd);
|
|
})
|
|
.where(function notExpired(this: Knex.QueryBuilder) {
|
|
this.whereNull('client_contracts.end_date').orWhere('client_contracts.end_date', '>=', rangeStart);
|
|
})
|
|
.where(function notSystemManaged(this: Knex.QueryBuilder) {
|
|
this.whereNull('contracts.is_system_managed_default').orWhere('contracts.is_system_managed_default', false);
|
|
})
|
|
.select(
|
|
'contract_lines.contract_line_id as client_contract_line_id',
|
|
'bucket_config.config_id as bucket_config_id'
|
|
);
|
|
|
|
const candidates: ContractLineCandidate[] = rows.map((row) => ({
|
|
client_contract_line_id: String(row.client_contract_line_id),
|
|
bucket_overlay: row.bucket_config_id ? { config_id: String(row.bucket_config_id) } : null,
|
|
}));
|
|
|
|
return resolveDeterministicContractLineSelection(candidates);
|
|
}
|
|
|
|
function calculateAnchoredPeriod(
|
|
targetDate: Temporal.PlainDate,
|
|
anchorDate: Temporal.PlainDate,
|
|
frequency: string
|
|
): { periodStart: Temporal.PlainDate; periodEnd: Temporal.PlainDate } {
|
|
switch (frequency) {
|
|
case 'monthly': {
|
|
const monthsDiff = targetDate.since(anchorDate, { largestUnit: 'month' }).months;
|
|
const periodStart = anchorDate.add({ months: monthsDiff });
|
|
return {
|
|
periodStart,
|
|
periodEnd: periodStart.add({ months: 1 }).subtract({ days: 1 }),
|
|
};
|
|
}
|
|
case 'quarterly': {
|
|
const monthsDiff = targetDate.since(anchorDate, { largestUnit: 'month' }).months;
|
|
const quartersDiff = Math.floor(monthsDiff / 3);
|
|
const periodStart = anchorDate.add({ months: quartersDiff * 3 });
|
|
return {
|
|
periodStart,
|
|
periodEnd: periodStart.add({ months: 3 }).subtract({ days: 1 }),
|
|
};
|
|
}
|
|
case 'annually': {
|
|
const yearsDiff = targetDate.since(anchorDate, { largestUnit: 'year' }).years;
|
|
const periodStart = anchorDate.add({ years: yearsDiff });
|
|
return {
|
|
periodStart,
|
|
periodEnd: periodStart.add({ years: 1 }).subtract({ days: 1 }),
|
|
};
|
|
}
|
|
default:
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'BUCKET_USAGE_PERIOD_ERROR',
|
|
message: `Unsupported billing frequency for bucket usage: ${frequency}`,
|
|
details: { frequency },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function resolveBucketUsagePeriod(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
clientId: string;
|
|
contractLineId: string;
|
|
startTimeIso: string;
|
|
}): Promise<BucketUsagePeriod | null> {
|
|
const { trx, tenantId, clientId, contractLineId, startTimeIso } = params;
|
|
const targetDate = toPlainDate(startTimeIso);
|
|
const targetDateIso = toISODate(targetDate);
|
|
|
|
const matchingBillingCycle = await trx('client_billing_cycles')
|
|
.where({
|
|
tenant: tenantId,
|
|
client_id: clientId,
|
|
})
|
|
.whereNotNull('period_start_date')
|
|
.whereNotNull('period_end_date')
|
|
.andWhere('period_start_date', '<=', targetDateIso)
|
|
.andWhere('period_end_date', '>', targetDateIso)
|
|
.orderBy('period_start_date', 'desc')
|
|
.first<{ period_start_date: string; period_end_date: string }>('period_start_date', 'period_end_date');
|
|
|
|
if (matchingBillingCycle) {
|
|
return {
|
|
periodStart: toISODate(toPlainDate(matchingBillingCycle.period_start_date)),
|
|
periodEnd: toISODate(toPlainDate(matchingBillingCycle.period_end_date).subtract({ days: 1 })),
|
|
};
|
|
}
|
|
|
|
const contractAssignment = await trx('client_contract_lines as ccl')
|
|
.join('contract_lines as cl', function joinContractLines() {
|
|
this.on('ccl.contract_line_id', '=', 'cl.contract_line_id').andOn('ccl.tenant', '=', 'cl.tenant');
|
|
})
|
|
.where({
|
|
'ccl.tenant': tenantId,
|
|
'ccl.client_id': clientId,
|
|
'ccl.contract_line_id': contractLineId,
|
|
'ccl.is_active': true,
|
|
})
|
|
.andWhere('ccl.start_date', '<=', targetDateIso)
|
|
.andWhere((query) => {
|
|
query.whereNull('ccl.end_date').orWhere('ccl.end_date', '>=', targetDateIso);
|
|
})
|
|
.orderBy('ccl.start_date', 'desc')
|
|
.select('ccl.start_date', 'cl.billing_frequency')
|
|
.first<{ start_date: string; billing_frequency: string }>();
|
|
|
|
if (!contractAssignment) {
|
|
return null;
|
|
}
|
|
|
|
const anchorDate = toPlainDate(contractAssignment.start_date);
|
|
const { periodStart, periodEnd } = calculateAnchoredPeriod(
|
|
targetDate,
|
|
anchorDate,
|
|
contractAssignment.billing_frequency
|
|
);
|
|
|
|
return {
|
|
periodStart: toISODate(periodStart),
|
|
periodEnd: toISODate(periodEnd),
|
|
};
|
|
}
|
|
|
|
async function findOrCreateBucketUsageForEntry(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
clientId: string;
|
|
contractLineId: string;
|
|
serviceId: string;
|
|
startTimeIso: string;
|
|
}): Promise<string> {
|
|
const { trx, tenantId, clientId, contractLineId, serviceId, startTimeIso } = params;
|
|
|
|
const period = await resolveBucketUsagePeriod({
|
|
trx,
|
|
tenantId,
|
|
clientId,
|
|
contractLineId,
|
|
startTimeIso,
|
|
});
|
|
|
|
if (!period) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'BUCKET_USAGE_PERIOD_NOT_FOUND',
|
|
message: 'Unable to determine bucket usage period for time entry',
|
|
details: { client_id: clientId, contract_line_id: contractLineId, service_id: serviceId, start_time: startTimeIso },
|
|
});
|
|
}
|
|
|
|
const existing = await trx('bucket_usage')
|
|
.where({
|
|
tenant: tenantId,
|
|
client_id: clientId,
|
|
contract_line_id: contractLineId,
|
|
service_catalog_id: serviceId,
|
|
period_start: period.periodStart,
|
|
period_end: period.periodEnd,
|
|
})
|
|
.select('usage_id')
|
|
.first<{ usage_id: string }>();
|
|
|
|
if (existing?.usage_id) {
|
|
return existing.usage_id;
|
|
}
|
|
|
|
const [inserted] = await trx('bucket_usage')
|
|
.insert({
|
|
usage_id: uuidv4(),
|
|
tenant: tenantId,
|
|
client_id: clientId,
|
|
contract_line_id: contractLineId,
|
|
service_catalog_id: serviceId,
|
|
period_start: period.periodStart,
|
|
period_end: period.periodEnd,
|
|
minutes_used: 0,
|
|
overage_minutes: 0,
|
|
rolled_over_minutes: 0,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.returning('usage_id');
|
|
|
|
return String(inserted.usage_id);
|
|
}
|
|
|
|
async function applyBucketUsageDeltaForEntry(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
clientId: string | null;
|
|
contractLineId: string | null;
|
|
serviceId: string;
|
|
startTimeIso: string;
|
|
minutesDelta: number;
|
|
}): Promise<void> {
|
|
const { trx, tenantId, clientId, contractLineId, serviceId, startTimeIso, minutesDelta } = params;
|
|
|
|
if (!clientId || !contractLineId || minutesDelta === 0) {
|
|
return;
|
|
}
|
|
|
|
const overlayConfig = await trx('contract_line_service_configuration as cfg')
|
|
.join('contract_line_service_bucket_config as bucket_cfg', function joinBucketConfig() {
|
|
this.on('cfg.config_id', '=', 'bucket_cfg.config_id').andOn('cfg.tenant', '=', 'bucket_cfg.tenant');
|
|
})
|
|
.where({
|
|
'cfg.tenant': tenantId,
|
|
'cfg.contract_line_id': contractLineId,
|
|
'cfg.service_id': serviceId,
|
|
'cfg.configuration_type': 'Bucket',
|
|
})
|
|
.select('cfg.config_id', 'bucket_cfg.total_minutes')
|
|
.first<{ config_id: string; total_minutes: number }>();
|
|
|
|
if (!overlayConfig?.config_id) {
|
|
return;
|
|
}
|
|
|
|
const usageId = await findOrCreateBucketUsageForEntry({
|
|
trx,
|
|
tenantId,
|
|
clientId,
|
|
contractLineId,
|
|
serviceId,
|
|
startTimeIso,
|
|
});
|
|
|
|
const usageRecord = await trx('bucket_usage')
|
|
.where({ tenant: tenantId, usage_id: usageId })
|
|
.select('minutes_used', 'rolled_over_minutes')
|
|
.first<{ minutes_used: number; rolled_over_minutes: number }>();
|
|
|
|
if (!usageRecord) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'BUCKET_USAGE_NOT_FOUND',
|
|
message: 'Bucket usage record could not be loaded for update',
|
|
details: { usage_id: usageId, contract_line_id: contractLineId, service_id: serviceId },
|
|
});
|
|
}
|
|
|
|
const newMinutesUsed = Number(usageRecord.minutes_used ?? 0) + minutesDelta;
|
|
const totalAvailableMinutes = Number(overlayConfig.total_minutes ?? 0) + Number(usageRecord.rolled_over_minutes ?? 0);
|
|
const newOverageMinutes = Math.max(0, newMinutesUsed - totalAvailableMinutes);
|
|
|
|
await trx('bucket_usage')
|
|
.where({ tenant: tenantId, usage_id: usageId })
|
|
.update({
|
|
minutes_used: newMinutesUsed,
|
|
overage_minutes: newOverageMinutes,
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
async function resolveOrCreateTimeSheet(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
userId: string;
|
|
workDate: string;
|
|
startWorkDate: string;
|
|
endWorkDate: string;
|
|
providedTimeSheetId?: string | null;
|
|
attachToTimeSheet: boolean;
|
|
}): Promise<string | null> {
|
|
const {
|
|
trx,
|
|
tenantId,
|
|
userId,
|
|
workDate,
|
|
startWorkDate,
|
|
endWorkDate,
|
|
providedTimeSheetId,
|
|
attachToTimeSheet,
|
|
} = params;
|
|
|
|
if (providedTimeSheetId) {
|
|
const timeSheet = await trx('time_sheets as ts')
|
|
.join('time_periods as tp', function joinTimePeriods() {
|
|
this.on('ts.period_id', '=', 'tp.period_id').andOn('ts.tenant', '=', 'tp.tenant');
|
|
})
|
|
.where({ 'ts.tenant': tenantId, 'ts.id': providedTimeSheetId })
|
|
.select('ts.id', 'ts.user_id', 'tp.start_date', 'tp.end_date')
|
|
.first();
|
|
|
|
if (!timeSheet) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Time sheet not found',
|
|
details: { time_sheet_id: providedTimeSheetId },
|
|
});
|
|
}
|
|
|
|
if (timeSheet.user_id !== userId) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Time sheet owner must match time entry user',
|
|
details: { time_sheet_id: providedTimeSheetId, user_id: userId },
|
|
});
|
|
}
|
|
|
|
const periodStart = toDateOnly(timeSheet.start_date as string | Date);
|
|
const periodEnd = toDateOnly(timeSheet.end_date as string | Date);
|
|
|
|
if (startWorkDate < periodStart || startWorkDate >= periodEnd || endWorkDate < periodStart || endWorkDate >= periodEnd) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Time entry must fall within the provided time sheet period',
|
|
details: { time_sheet_id: providedTimeSheetId, period_start: periodStart, period_end: periodEnd },
|
|
});
|
|
}
|
|
|
|
return providedTimeSheetId;
|
|
}
|
|
|
|
if (!attachToTimeSheet) {
|
|
return null;
|
|
}
|
|
|
|
const period = await trx('time_periods')
|
|
.where({ tenant: tenantId, is_closed: false })
|
|
.andWhere('start_date', '<=', workDate)
|
|
.andWhere('end_date', '>', workDate)
|
|
.orderBy('start_date', 'desc')
|
|
.select('period_id')
|
|
.first();
|
|
|
|
if (!period?.period_id) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'No open time period found for work_date',
|
|
details: { work_date: workDate },
|
|
});
|
|
}
|
|
|
|
let timeSheet = await trx('time_sheets')
|
|
.where({ tenant: tenantId, user_id: userId, period_id: period.period_id })
|
|
.select('id')
|
|
.first();
|
|
|
|
if (!timeSheet?.id) {
|
|
const inserted = await trx('time_sheets')
|
|
.insert({
|
|
id: uuidv4(),
|
|
tenant: tenantId,
|
|
user_id: userId,
|
|
period_id: period.period_id,
|
|
approval_status: 'DRAFT',
|
|
})
|
|
.returning('id');
|
|
|
|
timeSheet = Array.isArray(inserted) ? inserted[0] : inserted;
|
|
}
|
|
|
|
return (timeSheet?.id as string | undefined) ?? null;
|
|
}
|
|
|
|
async function applyTicketAssignmentSideEffects(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
ticketId: string;
|
|
entryUserId: string;
|
|
}): Promise<void> {
|
|
const { trx, tenantId, actorUserId, ticketId, entryUserId } = params;
|
|
|
|
const existingResource = await trx('ticket_resources')
|
|
.where({ tenant: tenantId, ticket_id: ticketId })
|
|
.where((query) => {
|
|
query.where('assigned_to', entryUserId).orWhere('additional_user_id', entryUserId);
|
|
})
|
|
.first();
|
|
|
|
if (existingResource) {
|
|
return;
|
|
}
|
|
|
|
const ticket = await trx('tickets')
|
|
.where({ tenant: tenantId, ticket_id: ticketId })
|
|
.select('assigned_to')
|
|
.first();
|
|
|
|
if (!ticket) {
|
|
return;
|
|
}
|
|
|
|
if (ticket.assigned_to && ticket.assigned_to !== entryUserId) {
|
|
await trx('ticket_resources').insert({
|
|
tenant: tenantId,
|
|
ticket_id: ticketId,
|
|
assigned_to: ticket.assigned_to,
|
|
additional_user_id: entryUserId,
|
|
assigned_at: new Date().toISOString(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!ticket.assigned_to) {
|
|
await trx('tickets')
|
|
.where({ tenant: tenantId, ticket_id: ticketId })
|
|
.update({
|
|
assigned_to: entryUserId,
|
|
updated_at: new Date().toISOString(),
|
|
updated_by: actorUserId,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function recalculateProjectTaskActualMinutes(
|
|
trx: Knex.Transaction,
|
|
tenantId: string,
|
|
taskId: string
|
|
): Promise<void> {
|
|
const rows = await trx('time_entries')
|
|
.where({ tenant: tenantId, work_item_type: 'project_task', work_item_id: taskId })
|
|
.select('billable_duration');
|
|
|
|
const totalMinutes = rows.reduce((acc, row) => acc + Number(row.billable_duration ?? 0), 0);
|
|
|
|
await trx('project_tasks')
|
|
.where({ tenant: tenantId, task_id: taskId })
|
|
.update({
|
|
actual_hours: totalMinutes,
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
async function applyProjectTaskAssignmentSideEffects(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
taskId: string;
|
|
entryUserId: string;
|
|
}): Promise<void> {
|
|
const { trx, tenantId, taskId, entryUserId } = params;
|
|
|
|
const task = await trx('project_tasks')
|
|
.where({ tenant: tenantId, task_id: taskId })
|
|
.select('assigned_to')
|
|
.first();
|
|
|
|
if (!task) {
|
|
return;
|
|
}
|
|
|
|
const existingResource = await trx('task_resources')
|
|
.where({ tenant: tenantId, task_id: taskId })
|
|
.where((query) => {
|
|
query.where('assigned_to', entryUserId).orWhere('additional_user_id', entryUserId);
|
|
})
|
|
.first();
|
|
|
|
if (task.assigned_to && task.assigned_to !== entryUserId) {
|
|
if (!existingResource) {
|
|
await trx('task_resources').insert({
|
|
tenant: tenantId,
|
|
task_id: taskId,
|
|
assigned_to: task.assigned_to,
|
|
additional_user_id: entryUserId,
|
|
assigned_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!task.assigned_to) {
|
|
await trx('project_tasks')
|
|
.where({ tenant: tenantId, task_id: taskId })
|
|
.update({
|
|
assigned_to: entryUserId,
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function createWorkflowTimeEntry(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
input: WorkflowTimeCreateEntryInput;
|
|
}): Promise<WorkflowTimeCreatedEntrySummary> {
|
|
const { trx, tenantId, actorUserId, input } = params;
|
|
|
|
const user = await trx('users')
|
|
.where({ tenant: tenantId, user_id: input.user_id })
|
|
.select('user_id')
|
|
.first();
|
|
|
|
if (!user) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'User not found',
|
|
details: { user_id: input.user_id },
|
|
});
|
|
}
|
|
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, input.user_id);
|
|
|
|
const service = await trx('service_catalog')
|
|
.where({ tenant: tenantId, service_id: input.service_id })
|
|
.select('service_id')
|
|
.first();
|
|
|
|
if (!service) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Service is required and must exist',
|
|
details: { service_id: input.service_id },
|
|
});
|
|
}
|
|
|
|
const startDate = ensureValidDate(input.start, 'start');
|
|
const endDate = input.end ? ensureValidDate(input.end, 'end') : null;
|
|
|
|
if (!endDate && input.duration_minutes === undefined) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Provide end or duration_minutes',
|
|
details: undefined,
|
|
});
|
|
}
|
|
|
|
if (input.duration_minutes !== undefined) {
|
|
assertPositiveOrZeroMinutes(input.duration_minutes, 'duration_minutes');
|
|
}
|
|
|
|
const computedEndDate = endDate ?? new Date(startDate.getTime() + (input.duration_minutes as number) * 60 * 1000);
|
|
if (computedEndDate.getTime() < startDate.getTime()) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'end must be at or after start',
|
|
details: { start: input.start, end: input.end ?? computedEndDate.toISOString() },
|
|
});
|
|
}
|
|
|
|
const totalMinutes = Math.round((computedEndDate.getTime() - startDate.getTime()) / 60000);
|
|
|
|
const billableMinutesInput = input.billable_duration_minutes;
|
|
if (billableMinutesInput !== undefined) {
|
|
assertPositiveOrZeroMinutes(billableMinutesInput, 'billable_duration_minutes');
|
|
}
|
|
|
|
const billableMinutes = input.billable === false
|
|
? 0
|
|
: (billableMinutesInput ?? totalMinutes);
|
|
|
|
const userTimeZone = await resolveUserTimeZone(trx, tenantId, input.user_id);
|
|
const { work_date, work_timezone } = computeWorkDateFields(startDate.toISOString(), userTimeZone);
|
|
const { work_date: end_work_date } = computeWorkDateFields(computedEndDate.toISOString(), userTimeZone);
|
|
|
|
const workItem = await getWorkItemClientContext(trx, tenantId, input.link);
|
|
|
|
let contractLineId = input.contract_line_id ?? null;
|
|
if (!contractLineId && workItem.clientId) {
|
|
contractLineId = await determineDefaultContractLineForWorkflow({
|
|
trx,
|
|
tenantId,
|
|
clientId: workItem.clientId,
|
|
serviceId: input.service_id,
|
|
effectiveDate: work_date,
|
|
});
|
|
}
|
|
|
|
const timeSheetId = await resolveOrCreateTimeSheet({
|
|
trx,
|
|
tenantId,
|
|
userId: input.user_id,
|
|
workDate: work_date,
|
|
startWorkDate: work_date,
|
|
endWorkDate: end_work_date,
|
|
providedTimeSheetId: input.time_sheet_id,
|
|
attachToTimeSheet: input.attach_to_timesheet !== false,
|
|
});
|
|
|
|
const entryId = uuidv4();
|
|
const nowIso = new Date().toISOString();
|
|
const startIso = startDate.toISOString();
|
|
const endIso = computedEndDate.toISOString();
|
|
|
|
const inserted = await trx('time_entries')
|
|
.insert({
|
|
tenant: tenantId,
|
|
entry_id: entryId,
|
|
user_id: input.user_id,
|
|
work_item_id: input.link?.id ?? null,
|
|
work_item_type: input.link?.type ?? null,
|
|
service_id: input.service_id,
|
|
contract_line_id: contractLineId,
|
|
tax_rate_id: input.tax_rate_id ?? null,
|
|
start_time: startIso,
|
|
end_time: endIso,
|
|
work_date,
|
|
work_timezone,
|
|
billable_duration: billableMinutes,
|
|
notes: input.notes ?? null,
|
|
approval_status: 'DRAFT',
|
|
time_sheet_id: timeSheetId,
|
|
invoiced: false,
|
|
created_by: actorUserId,
|
|
updated_by: actorUserId,
|
|
created_at: nowIso,
|
|
updated_at: nowIso,
|
|
})
|
|
.returning([
|
|
'entry_id',
|
|
'user_id',
|
|
'work_item_id',
|
|
'work_item_type',
|
|
'service_id',
|
|
'contract_line_id',
|
|
'time_sheet_id',
|
|
'start_time',
|
|
'end_time',
|
|
'billable_duration',
|
|
'work_date',
|
|
'work_timezone',
|
|
'approval_status',
|
|
'invoiced',
|
|
'notes',
|
|
]);
|
|
|
|
const entry = Array.isArray(inserted) ? inserted[0] : inserted;
|
|
|
|
if (input.link?.type === 'ticket' && input.link.id) {
|
|
await applyTicketAssignmentSideEffects({
|
|
trx,
|
|
tenantId,
|
|
actorUserId,
|
|
ticketId: input.link.id,
|
|
entryUserId: input.user_id,
|
|
});
|
|
}
|
|
|
|
if (input.link?.type === 'project_task' && input.link.id) {
|
|
await recalculateProjectTaskActualMinutes(trx, tenantId, input.link.id);
|
|
await applyProjectTaskAssignmentSideEffects({
|
|
trx,
|
|
tenantId,
|
|
taskId: input.link.id,
|
|
entryUserId: input.user_id,
|
|
});
|
|
}
|
|
|
|
await applyBucketUsageDeltaForEntry({
|
|
trx,
|
|
tenantId,
|
|
clientId: workItem.clientId,
|
|
contractLineId: (entry.contract_line_id as string | null) ?? null,
|
|
serviceId: entry.service_id,
|
|
startTimeIso: toIsoString(entry.start_time as string | Date),
|
|
minutesDelta: Number(entry.billable_duration ?? 0),
|
|
});
|
|
|
|
return {
|
|
entry_id: entry.entry_id,
|
|
user_id: entry.user_id,
|
|
work_item_id: (entry.work_item_id as string | null) ?? null,
|
|
work_item_type: (entry.work_item_type as string | null) ?? null,
|
|
service_id: entry.service_id,
|
|
contract_line_id: (entry.contract_line_id as string | null) ?? null,
|
|
time_sheet_id: (entry.time_sheet_id as string | null) ?? null,
|
|
start_time: toIsoString(entry.start_time as string | Date),
|
|
end_time: toIsoString(entry.end_time as string | Date),
|
|
total_minutes: totalMinutes,
|
|
billable_minutes: Number(entry.billable_duration ?? 0),
|
|
work_date: toDateOnly(entry.work_date as string | Date),
|
|
work_timezone: String(entry.work_timezone ?? work_timezone),
|
|
approval_status: String(entry.approval_status ?? 'DRAFT'),
|
|
invoiced: Boolean(entry.invoiced),
|
|
notes: (entry.notes as string | null) ?? null,
|
|
};
|
|
}
|
|
|
|
function getLinkFromStoredEntry(entry: {
|
|
work_item_type: string | null;
|
|
work_item_id: string | null;
|
|
}): WorkflowTimeCreateEntryInput['link'] {
|
|
if (!entry.work_item_type || !entry.work_item_id) {
|
|
return undefined;
|
|
}
|
|
|
|
const validTypes = new Set(['ticket', 'project', 'project_task', 'interaction', 'ad_hoc', 'non_billable_category']);
|
|
if (!validTypes.has(entry.work_item_type)) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
type: entry.work_item_type as NonNullable<WorkflowTimeCreateEntryInput['link']>['type'],
|
|
id: entry.work_item_id,
|
|
};
|
|
}
|
|
|
|
function normalizeEntrySummary(
|
|
entry: {
|
|
entry_id: string;
|
|
user_id: string;
|
|
work_item_id: string | null;
|
|
work_item_type: string | null;
|
|
service_id: string;
|
|
contract_line_id: string | null;
|
|
time_sheet_id: string | null;
|
|
start_time: string | Date;
|
|
end_time: string | Date;
|
|
billable_duration: number | null;
|
|
work_date: string | Date;
|
|
work_timezone: string | null;
|
|
approval_status: string | null;
|
|
invoiced: boolean | null;
|
|
notes: string | null;
|
|
},
|
|
fallbackWorkTimezone?: string
|
|
): WorkflowTimeCreatedEntrySummary {
|
|
const startIso = toIsoString(entry.start_time);
|
|
const endIso = toIsoString(entry.end_time);
|
|
|
|
return {
|
|
entry_id: entry.entry_id,
|
|
user_id: entry.user_id,
|
|
work_item_id: entry.work_item_id ?? null,
|
|
work_item_type: entry.work_item_type ?? null,
|
|
service_id: entry.service_id,
|
|
contract_line_id: entry.contract_line_id ?? null,
|
|
time_sheet_id: entry.time_sheet_id ?? null,
|
|
start_time: startIso,
|
|
end_time: endIso,
|
|
total_minutes: Math.round((new Date(endIso).getTime() - new Date(startIso).getTime()) / 60000),
|
|
billable_minutes: Number(entry.billable_duration ?? 0),
|
|
work_date: toDateOnly(entry.work_date),
|
|
work_timezone: String(entry.work_timezone ?? fallbackWorkTimezone ?? 'UTC'),
|
|
approval_status: String(entry.approval_status ?? 'DRAFT'),
|
|
invoiced: Boolean(entry.invoiced),
|
|
notes: entry.notes ?? null,
|
|
};
|
|
}
|
|
|
|
export async function updateWorkflowTimeEntry(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
input: WorkflowTimeUpdateEntryInput;
|
|
}): Promise<WorkflowTimeCreatedEntrySummary> {
|
|
const { trx, tenantId, actorUserId, input } = params;
|
|
|
|
const existing = await trx('time_entries')
|
|
.where({ tenant: tenantId, entry_id: input.entry_id })
|
|
.select(
|
|
'entry_id',
|
|
'user_id',
|
|
'work_item_id',
|
|
'work_item_type',
|
|
'service_id',
|
|
'contract_line_id',
|
|
'time_sheet_id',
|
|
'start_time',
|
|
'end_time',
|
|
'billable_duration',
|
|
'work_date',
|
|
'work_timezone',
|
|
'approval_status',
|
|
'invoiced',
|
|
'notes',
|
|
'tax_rate_id'
|
|
)
|
|
.first();
|
|
|
|
if (!existing) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Time entry not found',
|
|
details: { entry_id: input.entry_id },
|
|
});
|
|
}
|
|
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, String(existing.user_id));
|
|
|
|
if (existing.invoiced) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'This time entry has already been invoiced and cannot be modified',
|
|
details: { entry_id: input.entry_id },
|
|
});
|
|
}
|
|
|
|
const oldLink = getLinkFromStoredEntry({
|
|
work_item_type: (existing.work_item_type as string | null) ?? null,
|
|
work_item_id: (existing.work_item_id as string | null) ?? null,
|
|
});
|
|
const oldWorkItem = await getWorkItemClientContext(trx, tenantId, oldLink);
|
|
|
|
const effectiveLink = input.link ?? oldLink;
|
|
const resolvedWorkItem = await getWorkItemClientContext(trx, tenantId, effectiveLink);
|
|
const resolvedServiceId = input.service_id ?? String(existing.service_id);
|
|
|
|
const service = await trx('service_catalog')
|
|
.where({ tenant: tenantId, service_id: resolvedServiceId })
|
|
.select('service_id')
|
|
.first();
|
|
|
|
if (!service) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Service is required and must exist',
|
|
details: { service_id: resolvedServiceId },
|
|
});
|
|
}
|
|
|
|
const existingStartIso = toIsoString(existing.start_time as string | Date);
|
|
const existingEndIso = toIsoString(existing.end_time as string | Date);
|
|
|
|
const startDate = input.start
|
|
? ensureValidDate(input.start, 'start')
|
|
: new Date(existingStartIso);
|
|
|
|
const explicitEndDate = input.end
|
|
? ensureValidDate(input.end, 'end')
|
|
: null;
|
|
|
|
if (input.duration_minutes !== undefined) {
|
|
assertPositiveOrZeroMinutes(input.duration_minutes, 'duration_minutes');
|
|
}
|
|
if (input.billable_duration_minutes !== undefined) {
|
|
assertPositiveOrZeroMinutes(input.billable_duration_minutes, 'billable_duration_minutes');
|
|
}
|
|
|
|
const computedEndDate = explicitEndDate
|
|
?? (input.duration_minutes !== undefined
|
|
? new Date(startDate.getTime() + input.duration_minutes * 60 * 1000)
|
|
: new Date(existingEndIso));
|
|
|
|
if (computedEndDate.getTime() < startDate.getTime()) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'end must be at or after start',
|
|
details: { entry_id: input.entry_id },
|
|
});
|
|
}
|
|
|
|
const totalMinutes = Math.round((computedEndDate.getTime() - startDate.getTime()) / 60000);
|
|
const existingBillableMinutes = Number(existing.billable_duration ?? 0);
|
|
const billableMinutes = input.billable === false
|
|
? 0
|
|
: input.billable_duration_minutes ?? (input.billable === true ? totalMinutes : (existingBillableMinutes === 0 ? 0 : totalMinutes));
|
|
|
|
const userTimeZone = await resolveUserTimeZone(trx, tenantId, String(existing.user_id));
|
|
const { work_date, work_timezone } = computeWorkDateFields(startDate.toISOString(), userTimeZone);
|
|
const { work_date: endWorkDate } = computeWorkDateFields(computedEndDate.toISOString(), userTimeZone);
|
|
|
|
let resolvedContractLineId: string | null;
|
|
if (hasOwnProperty(input, 'contract_line_id')) {
|
|
resolvedContractLineId = input.contract_line_id ?? null;
|
|
} else {
|
|
resolvedContractLineId = (existing.contract_line_id as string | null) ?? null;
|
|
}
|
|
|
|
if (!resolvedContractLineId && resolvedWorkItem.clientId) {
|
|
resolvedContractLineId = await determineDefaultContractLineForWorkflow({
|
|
trx,
|
|
tenantId,
|
|
clientId: resolvedWorkItem.clientId,
|
|
serviceId: resolvedServiceId,
|
|
effectiveDate: work_date,
|
|
});
|
|
}
|
|
|
|
const existingTimeSheetId = (existing.time_sheet_id as string | null) ?? null;
|
|
const attachToTimeSheet = hasOwnProperty(input, 'attach_to_timesheet')
|
|
? Boolean(input.attach_to_timesheet)
|
|
: true;
|
|
|
|
const providedTimeSheetId = !attachToTimeSheet
|
|
? null
|
|
: hasOwnProperty(input, 'time_sheet_id')
|
|
? (input.time_sheet_id ?? null)
|
|
: existingTimeSheetId;
|
|
|
|
const timeSheetId = await resolveOrCreateTimeSheet({
|
|
trx,
|
|
tenantId,
|
|
userId: String(existing.user_id),
|
|
workDate: work_date,
|
|
startWorkDate: work_date,
|
|
endWorkDate,
|
|
providedTimeSheetId,
|
|
attachToTimeSheet,
|
|
});
|
|
|
|
const updatedRows = await trx('time_entries')
|
|
.where({ tenant: tenantId, entry_id: input.entry_id })
|
|
.update({
|
|
work_item_id: effectiveLink?.id ?? null,
|
|
work_item_type: effectiveLink?.type ?? null,
|
|
service_id: resolvedServiceId,
|
|
contract_line_id: resolvedContractLineId,
|
|
tax_rate_id: hasOwnProperty(input, 'tax_rate_id') ? (input.tax_rate_id ?? null) : existing.tax_rate_id,
|
|
start_time: startDate.toISOString(),
|
|
end_time: computedEndDate.toISOString(),
|
|
work_date,
|
|
work_timezone,
|
|
billable_duration: billableMinutes,
|
|
notes: hasOwnProperty(input, 'notes') ? (input.notes ?? null) : existing.notes,
|
|
time_sheet_id: timeSheetId,
|
|
updated_by: actorUserId,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.returning([
|
|
'entry_id',
|
|
'user_id',
|
|
'work_item_id',
|
|
'work_item_type',
|
|
'service_id',
|
|
'contract_line_id',
|
|
'time_sheet_id',
|
|
'start_time',
|
|
'end_time',
|
|
'billable_duration',
|
|
'work_date',
|
|
'work_timezone',
|
|
'approval_status',
|
|
'invoiced',
|
|
'notes',
|
|
]);
|
|
|
|
const updated = Array.isArray(updatedRows) ? updatedRows[0] : updatedRows;
|
|
|
|
await applyBucketUsageDeltaForEntry({
|
|
trx,
|
|
tenantId,
|
|
clientId: oldWorkItem.clientId,
|
|
contractLineId: (existing.contract_line_id as string | null) ?? null,
|
|
serviceId: String(existing.service_id),
|
|
startTimeIso: existingStartIso,
|
|
minutesDelta: -existingBillableMinutes,
|
|
});
|
|
|
|
await applyBucketUsageDeltaForEntry({
|
|
trx,
|
|
tenantId,
|
|
clientId: resolvedWorkItem.clientId,
|
|
contractLineId: (updated.contract_line_id as string | null) ?? null,
|
|
serviceId: String(updated.service_id),
|
|
startTimeIso: toIsoString(updated.start_time as string | Date),
|
|
minutesDelta: Number(updated.billable_duration ?? 0),
|
|
});
|
|
|
|
const oldTaskId = oldLink?.type === 'project_task' ? oldLink.id : null;
|
|
const newTaskId = effectiveLink?.type === 'project_task' ? effectiveLink.id : null;
|
|
if (oldTaskId) {
|
|
await recalculateProjectTaskActualMinutes(trx, tenantId, oldTaskId);
|
|
}
|
|
if (newTaskId && newTaskId !== oldTaskId) {
|
|
await recalculateProjectTaskActualMinutes(trx, tenantId, newTaskId);
|
|
}
|
|
|
|
if (effectiveLink?.type === 'ticket') {
|
|
await applyTicketAssignmentSideEffects({
|
|
trx,
|
|
tenantId,
|
|
actorUserId,
|
|
ticketId: effectiveLink.id,
|
|
entryUserId: String(existing.user_id),
|
|
});
|
|
}
|
|
|
|
if (effectiveLink?.type === 'project_task') {
|
|
await applyProjectTaskAssignmentSideEffects({
|
|
trx,
|
|
tenantId,
|
|
taskId: effectiveLink.id,
|
|
entryUserId: String(existing.user_id),
|
|
});
|
|
}
|
|
|
|
return normalizeEntrySummary(
|
|
{
|
|
entry_id: String(updated.entry_id),
|
|
user_id: String(updated.user_id),
|
|
work_item_id: (updated.work_item_id as string | null) ?? null,
|
|
work_item_type: (updated.work_item_type as string | null) ?? null,
|
|
service_id: String(updated.service_id),
|
|
contract_line_id: (updated.contract_line_id as string | null) ?? null,
|
|
time_sheet_id: (updated.time_sheet_id as string | null) ?? null,
|
|
start_time: updated.start_time as string | Date,
|
|
end_time: updated.end_time as string | Date,
|
|
billable_duration: Number(updated.billable_duration ?? 0),
|
|
work_date: updated.work_date as string | Date,
|
|
work_timezone: (updated.work_timezone as string | null) ?? null,
|
|
approval_status: (updated.approval_status as string | null) ?? null,
|
|
invoiced: Boolean(updated.invoiced),
|
|
notes: (updated.notes as string | null) ?? null,
|
|
},
|
|
userTimeZone
|
|
);
|
|
}
|
|
|
|
export async function deleteWorkflowTimeEntry(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
entryId: string;
|
|
}): Promise<WorkflowTimeDeletedEntrySummary> {
|
|
const { trx, tenantId, actorUserId, entryId } = params;
|
|
|
|
const existing = await trx('time_entries')
|
|
.where({ tenant: tenantId, entry_id: entryId })
|
|
.select(
|
|
'entry_id',
|
|
'user_id',
|
|
'work_item_id',
|
|
'work_item_type',
|
|
'service_id',
|
|
'contract_line_id',
|
|
'billable_duration',
|
|
'start_time',
|
|
'invoiced'
|
|
)
|
|
.first();
|
|
|
|
if (!existing) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Time entry not found',
|
|
details: { entry_id: entryId },
|
|
});
|
|
}
|
|
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, String(existing.user_id));
|
|
|
|
if (existing.invoiced) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'This time entry has already been invoiced and cannot be deleted',
|
|
details: { entry_id: entryId },
|
|
});
|
|
}
|
|
|
|
const oldLink = getLinkFromStoredEntry({
|
|
work_item_type: (existing.work_item_type as string | null) ?? null,
|
|
work_item_id: (existing.work_item_id as string | null) ?? null,
|
|
});
|
|
const oldWorkItem = await getWorkItemClientContext(trx, tenantId, oldLink);
|
|
|
|
await applyBucketUsageDeltaForEntry({
|
|
trx,
|
|
tenantId,
|
|
clientId: oldWorkItem.clientId,
|
|
contractLineId: (existing.contract_line_id as string | null) ?? null,
|
|
serviceId: String(existing.service_id),
|
|
startTimeIso: toIsoString(existing.start_time as string | Date),
|
|
minutesDelta: -Number(existing.billable_duration ?? 0),
|
|
});
|
|
|
|
await trx('time_entries')
|
|
.where({ tenant: tenantId, entry_id: entryId })
|
|
.delete();
|
|
|
|
if (oldLink?.type === 'project_task') {
|
|
await recalculateProjectTaskActualMinutes(trx, tenantId, oldLink.id);
|
|
}
|
|
|
|
return {
|
|
entry_id: String(existing.entry_id),
|
|
user_id: String(existing.user_id),
|
|
work_item_id: (existing.work_item_id as string | null) ?? null,
|
|
work_item_type: (existing.work_item_type as string | null) ?? null,
|
|
service_id: String(existing.service_id),
|
|
contract_line_id: (existing.contract_line_id as string | null) ?? null,
|
|
billable_minutes: Number(existing.billable_duration ?? 0),
|
|
deleted: true,
|
|
};
|
|
}
|
|
|
|
export async function getWorkflowTimeEntry(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
entryId: string;
|
|
}): Promise<WorkflowTimeCreatedEntrySummary> {
|
|
const { trx, tenantId, actorUserId, entryId } = params;
|
|
|
|
const entry = await trx('time_entries')
|
|
.where({ tenant: tenantId, entry_id: entryId })
|
|
.select(
|
|
'entry_id',
|
|
'user_id',
|
|
'work_item_id',
|
|
'work_item_type',
|
|
'service_id',
|
|
'contract_line_id',
|
|
'time_sheet_id',
|
|
'start_time',
|
|
'end_time',
|
|
'billable_duration',
|
|
'work_date',
|
|
'work_timezone',
|
|
'approval_status',
|
|
'invoiced',
|
|
'notes'
|
|
)
|
|
.first();
|
|
|
|
if (!entry) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Time entry not found',
|
|
details: { entry_id: entryId },
|
|
});
|
|
}
|
|
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, String(entry.user_id));
|
|
|
|
return normalizeEntrySummary({
|
|
entry_id: String(entry.entry_id),
|
|
user_id: String(entry.user_id),
|
|
work_item_id: (entry.work_item_id as string | null) ?? null,
|
|
work_item_type: (entry.work_item_type as string | null) ?? null,
|
|
service_id: String(entry.service_id),
|
|
contract_line_id: (entry.contract_line_id as string | null) ?? null,
|
|
time_sheet_id: (entry.time_sheet_id as string | null) ?? null,
|
|
start_time: entry.start_time as string | Date,
|
|
end_time: entry.end_time as string | Date,
|
|
billable_duration: Number(entry.billable_duration ?? 0),
|
|
work_date: entry.work_date as string | Date,
|
|
work_timezone: (entry.work_timezone as string | null) ?? null,
|
|
approval_status: (entry.approval_status as string | null) ?? null,
|
|
invoiced: Boolean(entry.invoiced),
|
|
notes: (entry.notes as string | null) ?? null,
|
|
});
|
|
}
|
|
|
|
function applyFindEntriesFilters(
|
|
query: Knex.QueryBuilder,
|
|
tenantId: string,
|
|
filters: WorkflowTimeFindEntriesInput
|
|
): void {
|
|
query.where('te.tenant', tenantId);
|
|
|
|
if (filters.user_id) {
|
|
query.andWhere('te.user_id', filters.user_id);
|
|
}
|
|
if (filters.work_item_id) {
|
|
query.andWhere('te.work_item_id', filters.work_item_id);
|
|
}
|
|
if (filters.work_item_type) {
|
|
query.andWhere('te.work_item_type', filters.work_item_type);
|
|
}
|
|
if (filters.ticket_id) {
|
|
query.andWhere('te.work_item_type', 'ticket').andWhere('te.work_item_id', filters.ticket_id);
|
|
}
|
|
if (filters.project_task_id) {
|
|
query.andWhere('te.work_item_type', 'project_task').andWhere('te.work_item_id', filters.project_task_id);
|
|
}
|
|
if (filters.time_sheet_id) {
|
|
query.andWhere('te.time_sheet_id', filters.time_sheet_id);
|
|
}
|
|
if (filters.service_id) {
|
|
query.andWhere('te.service_id', filters.service_id);
|
|
}
|
|
if (filters.contract_line_id) {
|
|
query.andWhere('te.contract_line_id', filters.contract_line_id);
|
|
}
|
|
if (filters.approval_status) {
|
|
query.andWhere('te.approval_status', filters.approval_status);
|
|
}
|
|
if (filters.billable === true) {
|
|
query.andWhere('te.billable_duration', '>', 0);
|
|
}
|
|
if (filters.billable === false) {
|
|
query.andWhere('te.billable_duration', '=', 0);
|
|
}
|
|
if (filters.work_date_from) {
|
|
query.andWhere('te.work_date', '>=', filters.work_date_from);
|
|
}
|
|
if (filters.work_date_to) {
|
|
query.andWhere('te.work_date', '<=', filters.work_date_to);
|
|
}
|
|
if (filters.start_from) {
|
|
query.andWhere('te.start_time', '>=', filters.start_from);
|
|
}
|
|
if (filters.start_to) {
|
|
query.andWhere('te.start_time', '<=', filters.start_to);
|
|
}
|
|
if (filters.invoiced !== undefined) {
|
|
query.andWhere('te.invoiced', filters.invoiced);
|
|
}
|
|
|
|
if (filters.client_id) {
|
|
query.andWhere((builder) => {
|
|
builder
|
|
.whereExists(function ticketClientFilter() {
|
|
this.select(1)
|
|
.from('tickets as t')
|
|
.whereRaw('t.tenant = te.tenant')
|
|
.whereRaw('t.ticket_id = te.work_item_id')
|
|
.where('te.work_item_type', 'ticket')
|
|
.andWhere('t.client_id', filters.client_id as string);
|
|
})
|
|
.orWhereExists(function taskClientFilter() {
|
|
this.select(1)
|
|
.from('project_tasks as pt')
|
|
.join('project_phases as pp', function joinPhases() {
|
|
this.on('pt.phase_id', '=', 'pp.phase_id').andOn('pt.tenant', '=', 'pp.tenant');
|
|
})
|
|
.join('projects as p', function joinProjects() {
|
|
this.on('pp.project_id', '=', 'p.project_id').andOn('pp.tenant', '=', 'p.tenant');
|
|
})
|
|
.whereRaw('pt.tenant = te.tenant')
|
|
.whereRaw('pt.task_id = te.work_item_id')
|
|
.where('te.work_item_type', 'project_task')
|
|
.andWhere('p.client_id', filters.client_id as string);
|
|
})
|
|
.orWhereExists(function projectClientFilter() {
|
|
this.select(1)
|
|
.from('projects as p')
|
|
.whereRaw('p.tenant = te.tenant')
|
|
.whereRaw('p.project_id = te.work_item_id')
|
|
.where('te.work_item_type', 'project')
|
|
.andWhere('p.client_id', filters.client_id as string);
|
|
})
|
|
.orWhereExists(function interactionClientFilter() {
|
|
this.select(1)
|
|
.from('interactions as i')
|
|
.whereRaw('i.tenant = te.tenant')
|
|
.whereRaw('i.interaction_id = te.work_item_id')
|
|
.where('te.work_item_type', 'interaction')
|
|
.andWhere('i.client_id', filters.client_id as string);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function findWorkflowTimeEntries(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
input: WorkflowTimeFindEntriesInput;
|
|
}): Promise<WorkflowTimeFindEntriesResult> {
|
|
const { trx, tenantId, actorUserId } = params;
|
|
const input = await scopeFindEntriesInputForActor(trx, tenantId, actorUserId, params.input);
|
|
|
|
const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
|
|
|
|
const listQuery = trx('time_entries as te')
|
|
.select(
|
|
'te.entry_id',
|
|
'te.user_id',
|
|
'te.work_item_id',
|
|
'te.work_item_type',
|
|
'te.service_id',
|
|
'te.contract_line_id',
|
|
'te.time_sheet_id',
|
|
'te.start_time',
|
|
'te.end_time',
|
|
'te.billable_duration',
|
|
'te.work_date',
|
|
'te.work_timezone',
|
|
'te.approval_status',
|
|
'te.invoiced',
|
|
'te.notes'
|
|
)
|
|
.orderBy('te.start_time', 'desc')
|
|
.limit(limit);
|
|
|
|
applyFindEntriesFilters(listQuery, tenantId, input);
|
|
|
|
const aggregateQuery = trx('time_entries as te')
|
|
.count<{ count: string }[]>('* as count')
|
|
.sum<{ total_minutes: string | null }[]>({
|
|
total_minutes: trx.raw('EXTRACT(EPOCH FROM (te.end_time - te.start_time)) / 60'),
|
|
})
|
|
.sum<{ billable_minutes: string | null }[]>('te.billable_duration as billable_minutes');
|
|
|
|
applyFindEntriesFilters(aggregateQuery, tenantId, input);
|
|
|
|
const [rows, aggregateRows] = await Promise.all([listQuery, aggregateQuery]);
|
|
const aggregate = (Array.isArray(aggregateRows) ? aggregateRows[0] : aggregateRows) as unknown as {
|
|
count?: string | number;
|
|
total_minutes?: string | number | null;
|
|
billable_minutes?: string | number | null;
|
|
} | undefined;
|
|
|
|
const entries = rows.map((row) => normalizeEntrySummary({
|
|
entry_id: String(row.entry_id),
|
|
user_id: String(row.user_id),
|
|
work_item_id: (row.work_item_id as string | null) ?? null,
|
|
work_item_type: (row.work_item_type as string | null) ?? null,
|
|
service_id: String(row.service_id),
|
|
contract_line_id: (row.contract_line_id as string | null) ?? null,
|
|
time_sheet_id: (row.time_sheet_id as string | null) ?? null,
|
|
start_time: row.start_time as string | Date,
|
|
end_time: row.end_time as string | Date,
|
|
billable_duration: Number(row.billable_duration ?? 0),
|
|
work_date: row.work_date as string | Date,
|
|
work_timezone: (row.work_timezone as string | null) ?? null,
|
|
approval_status: (row.approval_status as string | null) ?? null,
|
|
invoiced: Boolean(row.invoiced),
|
|
notes: (row.notes as string | null) ?? null,
|
|
}));
|
|
|
|
return {
|
|
entries,
|
|
summary: {
|
|
total_count: Number(aggregate?.count ?? 0),
|
|
total_minutes: Number(aggregate?.total_minutes ?? 0),
|
|
billable_minutes: Number(aggregate?.billable_minutes ?? 0),
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function setWorkflowTimeEntryApprovalStatus(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
entryId: string;
|
|
approvalStatus: WorkflowTimeApprovalStatus;
|
|
changeRequestComment?: string;
|
|
}): Promise<WorkflowTimeEntryApprovalResult> {
|
|
const { trx, tenantId, actorUserId, entryId, approvalStatus, changeRequestComment } = params;
|
|
|
|
const existing = await trx('time_entries')
|
|
.where({ tenant: tenantId, entry_id: entryId })
|
|
.select('entry_id', 'user_id', 'invoiced', 'time_sheet_id')
|
|
.first();
|
|
|
|
if (!existing) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Time entry not found',
|
|
details: { entry_id: entryId },
|
|
});
|
|
}
|
|
|
|
if (existing.invoiced) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'This time entry has already been invoiced and cannot be modified',
|
|
details: { entry_id: entryId },
|
|
});
|
|
}
|
|
|
|
if (approvalStatus === 'APPROVED') {
|
|
await assertCanApproveSubjectForWorkflowTime(trx, tenantId, actorUserId, String(existing.user_id));
|
|
} else {
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, String(existing.user_id));
|
|
}
|
|
|
|
await trx('time_entries')
|
|
.where({ tenant: tenantId, entry_id: entryId })
|
|
.update({
|
|
approval_status: approvalStatus,
|
|
updated_at: new Date().toISOString(),
|
|
updated_by: actorUserId,
|
|
});
|
|
|
|
if (approvalStatus === 'CHANGES_REQUESTED' && existing.time_sheet_id) {
|
|
await trx('time_sheets')
|
|
.where({ tenant: tenantId, id: existing.time_sheet_id })
|
|
.update({
|
|
approval_status: 'CHANGES_REQUESTED',
|
|
approved_at: null,
|
|
approved_by: null,
|
|
});
|
|
}
|
|
|
|
let changeRequestId: string | null = null;
|
|
if (approvalStatus === 'CHANGES_REQUESTED' && changeRequestComment && existing.time_sheet_id) {
|
|
const inserted = await trx('time_entry_change_requests')
|
|
.insert({
|
|
tenant: tenantId,
|
|
change_request_id: trx.raw('gen_random_uuid()'),
|
|
time_sheet_id: existing.time_sheet_id,
|
|
time_entry_id: entryId,
|
|
created_by: actorUserId,
|
|
comment: changeRequestComment,
|
|
created_at: trx.fn.now(),
|
|
})
|
|
.returning('change_request_id');
|
|
const created = Array.isArray(inserted) ? inserted[0] : inserted;
|
|
changeRequestId = String(created.change_request_id);
|
|
}
|
|
|
|
return {
|
|
entry_id: entryId,
|
|
approval_status: approvalStatus,
|
|
time_sheet_id: (existing.time_sheet_id as string | null) ?? null,
|
|
change_request_id: changeRequestId,
|
|
};
|
|
}
|
|
|
|
export async function requestWorkflowTimeEntryChanges(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
entryIds: string[];
|
|
comment: string;
|
|
}): Promise<{
|
|
entries: WorkflowTimeEntryApprovalResult[];
|
|
}> {
|
|
const { trx, tenantId, actorUserId, entryIds, comment } = params;
|
|
if (entryIds.length === 0) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Provide at least one entry id',
|
|
});
|
|
}
|
|
|
|
const results: WorkflowTimeEntryApprovalResult[] = [];
|
|
for (const entryId of entryIds) {
|
|
const result = await setWorkflowTimeEntryApprovalStatus({
|
|
trx,
|
|
tenantId,
|
|
actorUserId,
|
|
entryId,
|
|
approvalStatus: 'CHANGES_REQUESTED',
|
|
changeRequestComment: comment,
|
|
});
|
|
results.push(result);
|
|
}
|
|
|
|
return { entries: results };
|
|
}
|
|
|
|
async function summarizeTimeSheet(
|
|
trx: Knex.Transaction,
|
|
tenantId: string,
|
|
timeSheetId: string
|
|
): Promise<WorkflowTimeSheetSummary> {
|
|
const row = await trx('time_sheets as ts')
|
|
.join('time_periods as tp', function joinPeriods() {
|
|
this.on('ts.period_id', '=', 'tp.period_id').andOn('ts.tenant', '=', 'tp.tenant');
|
|
})
|
|
.where({
|
|
'ts.tenant': tenantId,
|
|
'ts.id': timeSheetId,
|
|
})
|
|
.select(
|
|
'ts.id',
|
|
'ts.user_id',
|
|
'ts.period_id',
|
|
'ts.approval_status',
|
|
'ts.submitted_at',
|
|
'ts.approved_at',
|
|
'ts.approved_by',
|
|
'tp.start_date',
|
|
'tp.end_date'
|
|
)
|
|
.first();
|
|
|
|
if (!row) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Time sheet not found',
|
|
details: { time_sheet_id: timeSheetId },
|
|
});
|
|
}
|
|
|
|
const [entryAggregate, commentAggregate] = await Promise.all([
|
|
trx('time_entries')
|
|
.where({ tenant: tenantId, time_sheet_id: timeSheetId })
|
|
.countDistinct<{ entry_count: string | number }>('entry_id as entry_count')
|
|
.sum<{ total_minutes: string | number | null }>({
|
|
total_minutes: trx.raw('EXTRACT(EPOCH FROM (end_time - start_time)) / 60'),
|
|
})
|
|
.sum<{ billable_minutes: string | number | null }>('billable_duration as billable_minutes')
|
|
.first(),
|
|
trx('time_sheet_comments')
|
|
.where({ tenant: tenantId, time_sheet_id: timeSheetId })
|
|
.count<{ comment_count: string | number }>('comment_id as comment_count')
|
|
.first(),
|
|
]);
|
|
|
|
const entryStats = entryAggregate as unknown as {
|
|
entry_count?: string | number;
|
|
total_minutes?: string | number | null;
|
|
billable_minutes?: string | number | null;
|
|
} | undefined;
|
|
|
|
return {
|
|
time_sheet_id: String(row.id),
|
|
user_id: String(row.user_id),
|
|
period_id: String(row.period_id),
|
|
period_start_date: toDateOnly(row.start_date as string | Date),
|
|
period_end_date: toDateOnly(row.end_date as string | Date),
|
|
approval_status: String(row.approval_status ?? 'DRAFT'),
|
|
submitted_at: row.submitted_at ? toIsoString(row.submitted_at as string | Date) : null,
|
|
approved_at: row.approved_at ? toIsoString(row.approved_at as string | Date) : null,
|
|
approved_by: (row.approved_by as string | null) ?? null,
|
|
entry_count: Number(entryStats?.entry_count ?? 0),
|
|
total_minutes: Number(entryStats?.total_minutes ?? 0),
|
|
billable_minutes: Number(entryStats?.billable_minutes ?? 0),
|
|
comment_count: Number(commentAggregate?.comment_count ?? 0),
|
|
};
|
|
}
|
|
|
|
export async function findOrCreateWorkflowTimeSheet(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
userId: string;
|
|
periodId?: string;
|
|
workDate?: string;
|
|
}): Promise<WorkflowTimeSheetSummary> {
|
|
const { trx, tenantId, actorUserId, userId, periodId, workDate } = params;
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, userId);
|
|
|
|
let resolvedPeriodId = periodId;
|
|
if (!resolvedPeriodId) {
|
|
if (!workDate) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Provide period_id or work_date',
|
|
});
|
|
}
|
|
const period = await trx('time_periods')
|
|
.where({ tenant: tenantId, is_closed: false })
|
|
.andWhere('start_date', '<=', workDate)
|
|
.andWhere('end_date', '>', workDate)
|
|
.orderBy('start_date', 'desc')
|
|
.first('period_id');
|
|
if (!period?.period_id) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'No open time period found for work_date',
|
|
details: { work_date: workDate },
|
|
});
|
|
}
|
|
resolvedPeriodId = String(period.period_id);
|
|
}
|
|
|
|
const existing = await trx('time_sheets')
|
|
.where({
|
|
tenant: tenantId,
|
|
user_id: userId,
|
|
period_id: resolvedPeriodId,
|
|
})
|
|
.first('id');
|
|
|
|
const timeSheetId = existing?.id
|
|
? String(existing.id)
|
|
: String((await trx('time_sheets')
|
|
.insert({
|
|
tenant: tenantId,
|
|
id: uuidv4(),
|
|
user_id: userId,
|
|
period_id: resolvedPeriodId,
|
|
approval_status: 'DRAFT',
|
|
})
|
|
.returning('id'))[0].id);
|
|
|
|
return summarizeTimeSheet(trx, tenantId, timeSheetId);
|
|
}
|
|
|
|
export async function getWorkflowTimeSheet(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
timeSheetId: string;
|
|
}): Promise<{
|
|
time_sheet: WorkflowTimeSheetSummary;
|
|
comments: WorkflowTimeSheetCommentSummary[];
|
|
}> {
|
|
const { trx, tenantId, actorUserId, timeSheetId } = params;
|
|
const summary = await summarizeTimeSheet(trx, tenantId, timeSheetId);
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, summary.user_id);
|
|
|
|
const comments = await trx('time_sheet_comments')
|
|
.where({
|
|
tenant: tenantId,
|
|
time_sheet_id: timeSheetId,
|
|
})
|
|
.orderBy('created_at', 'desc')
|
|
.select('comment_id', 'user_id', 'comment', 'is_approver', 'created_at');
|
|
|
|
return {
|
|
time_sheet: summary,
|
|
comments: comments.map((comment) => ({
|
|
comment_id: String(comment.comment_id),
|
|
user_id: String(comment.user_id),
|
|
comment: String(comment.comment),
|
|
is_approver: Boolean(comment.is_approver),
|
|
created_at: toIsoString(comment.created_at as string | Date),
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function findWorkflowTimeSheets(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
input: {
|
|
user_ids?: string[];
|
|
approval_status?: 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'CHANGES_REQUESTED';
|
|
period_start_from?: string;
|
|
period_end_to?: string;
|
|
work_date?: string;
|
|
limit?: number;
|
|
};
|
|
}): Promise<{
|
|
time_sheets: WorkflowTimeSheetSummary[];
|
|
summary: {
|
|
total_count: number;
|
|
};
|
|
}> {
|
|
const { trx, tenantId, actorUserId } = params;
|
|
const input = await scopeFindTimeSheetsInputForActor(trx, tenantId, actorUserId, params.input);
|
|
const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
|
|
|
|
const base = trx('time_sheets as ts')
|
|
.join('time_periods as tp', function joinPeriods() {
|
|
this.on('ts.period_id', '=', 'tp.period_id').andOn('ts.tenant', '=', 'tp.tenant');
|
|
})
|
|
.where('ts.tenant', tenantId);
|
|
|
|
if (input.user_ids?.length) {
|
|
base.whereIn('ts.user_id', input.user_ids);
|
|
}
|
|
if (input.approval_status) {
|
|
base.andWhere('ts.approval_status', input.approval_status);
|
|
}
|
|
if (input.period_start_from) {
|
|
base.andWhere('tp.start_date', '>=', input.period_start_from);
|
|
}
|
|
if (input.period_end_to) {
|
|
base.andWhere('tp.end_date', '<=', input.period_end_to);
|
|
}
|
|
if (input.work_date) {
|
|
base.andWhere('tp.start_date', '<=', input.work_date).andWhere('tp.end_date', '>', input.work_date);
|
|
}
|
|
|
|
const rows = await base
|
|
.clone()
|
|
.orderBy('tp.start_date', 'desc')
|
|
.limit(limit)
|
|
.select('ts.id');
|
|
|
|
const ids = rows.map((row) => String(row.id));
|
|
const summaries = await Promise.all(ids.map((id) => summarizeTimeSheet(trx, tenantId, id)));
|
|
|
|
const countRow = await base.clone().count<{ count: string }[]>('* as count').first();
|
|
|
|
return {
|
|
time_sheets: summaries,
|
|
summary: {
|
|
total_count: Number(countRow?.count ?? 0),
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function submitWorkflowTimeSheet(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
timeSheetId: string;
|
|
}): Promise<WorkflowTimeSheetMutationResult> {
|
|
const { trx, tenantId, actorUserId, timeSheetId } = params;
|
|
|
|
const timeSheet = await trx('time_sheets')
|
|
.where({ tenant: tenantId, id: timeSheetId })
|
|
.select('id', 'user_id', 'approval_status')
|
|
.first();
|
|
|
|
if (!timeSheet) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Time sheet not found',
|
|
details: { time_sheet_id: timeSheetId },
|
|
});
|
|
}
|
|
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, String(timeSheet.user_id));
|
|
|
|
const status = String(timeSheet.approval_status ?? 'DRAFT');
|
|
if (status !== 'DRAFT' && status !== 'CHANGES_REQUESTED') {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Time sheet must be DRAFT or CHANGES_REQUESTED to submit',
|
|
details: { time_sheet_id: timeSheetId, approval_status: status },
|
|
});
|
|
}
|
|
|
|
await trx('time_sheets')
|
|
.where({ tenant: tenantId, id: timeSheetId })
|
|
.update({
|
|
approval_status: 'SUBMITTED',
|
|
submitted_at: trx.fn.now(),
|
|
});
|
|
|
|
await trx('time_entries')
|
|
.where({ tenant: tenantId, time_sheet_id: timeSheetId })
|
|
.update({
|
|
approval_status: 'SUBMITTED',
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
|
|
return {
|
|
time_sheet: await summarizeTimeSheet(trx, tenantId, timeSheetId),
|
|
};
|
|
}
|
|
|
|
export async function approveWorkflowTimeSheet(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
timeSheetId: string;
|
|
comment?: string;
|
|
}): Promise<WorkflowTimeSheetMutationResult> {
|
|
const { trx, tenantId, actorUserId, timeSheetId, comment } = params;
|
|
|
|
const timeSheet = await trx('time_sheets')
|
|
.where({ tenant: tenantId, id: timeSheetId })
|
|
.select('id', 'user_id', 'approval_status')
|
|
.first();
|
|
|
|
if (!timeSheet) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Time sheet not found',
|
|
details: { time_sheet_id: timeSheetId },
|
|
});
|
|
}
|
|
|
|
await assertCanApproveSubjectForWorkflowTime(trx, tenantId, actorUserId, String(timeSheet.user_id));
|
|
|
|
if (String(timeSheet.approval_status ?? 'DRAFT') !== 'SUBMITTED') {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Time sheet must be SUBMITTED to approve',
|
|
details: { time_sheet_id: timeSheetId, approval_status: timeSheet.approval_status },
|
|
});
|
|
}
|
|
|
|
await trx('time_sheets')
|
|
.where({ tenant: tenantId, id: timeSheetId })
|
|
.update({
|
|
approval_status: 'APPROVED',
|
|
approved_at: trx.fn.now(),
|
|
approved_by: actorUserId,
|
|
});
|
|
|
|
await trx('time_entries')
|
|
.where({ tenant: tenantId, time_sheet_id: timeSheetId })
|
|
.update({
|
|
approval_status: 'APPROVED',
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
|
|
await trx('time_sheet_comments').insert({
|
|
tenant: tenantId,
|
|
comment_id: uuidv4(),
|
|
time_sheet_id: timeSheetId,
|
|
user_id: actorUserId,
|
|
comment: comment?.trim() || 'Time sheet approved',
|
|
is_approver: true,
|
|
created_at: trx.fn.now(),
|
|
});
|
|
|
|
return {
|
|
time_sheet: await summarizeTimeSheet(trx, tenantId, timeSheetId),
|
|
};
|
|
}
|
|
|
|
export async function requestWorkflowTimeSheetChanges(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
timeSheetId: string;
|
|
comment: string;
|
|
}): Promise<WorkflowTimeSheetMutationResult> {
|
|
const { trx, tenantId, actorUserId, timeSheetId, comment } = params;
|
|
|
|
const timeSheet = await trx('time_sheets')
|
|
.where({ tenant: tenantId, id: timeSheetId })
|
|
.select('id', 'user_id')
|
|
.first();
|
|
|
|
if (!timeSheet) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Time sheet not found',
|
|
details: { time_sheet_id: timeSheetId },
|
|
});
|
|
}
|
|
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, String(timeSheet.user_id));
|
|
|
|
await trx('time_sheets')
|
|
.where({ tenant: tenantId, id: timeSheetId })
|
|
.update({
|
|
approval_status: 'CHANGES_REQUESTED',
|
|
approved_at: null,
|
|
approved_by: null,
|
|
});
|
|
|
|
await trx('time_entries')
|
|
.where({ tenant: tenantId, time_sheet_id: timeSheetId })
|
|
.update({
|
|
approval_status: 'CHANGES_REQUESTED',
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
|
|
await trx('time_sheet_comments').insert({
|
|
tenant: tenantId,
|
|
comment_id: uuidv4(),
|
|
time_sheet_id: timeSheetId,
|
|
user_id: actorUserId,
|
|
comment,
|
|
is_approver: true,
|
|
created_at: trx.fn.now(),
|
|
});
|
|
|
|
return {
|
|
time_sheet: await summarizeTimeSheet(trx, tenantId, timeSheetId),
|
|
};
|
|
}
|
|
|
|
export async function reverseWorkflowTimeSheetApproval(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
timeSheetId: string;
|
|
reason: string;
|
|
}): Promise<WorkflowTimeSheetMutationResult> {
|
|
const { trx, tenantId, actorUserId, timeSheetId, reason } = params;
|
|
|
|
const timeSheet = await trx('time_sheets')
|
|
.where({ tenant: tenantId, id: timeSheetId })
|
|
.select('id', 'user_id', 'approval_status')
|
|
.first();
|
|
|
|
if (!timeSheet) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Time sheet not found',
|
|
details: { time_sheet_id: timeSheetId },
|
|
});
|
|
}
|
|
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, String(timeSheet.user_id));
|
|
|
|
if (String(timeSheet.approval_status ?? 'DRAFT') !== 'APPROVED') {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Time sheet is not in an approved state',
|
|
details: { time_sheet_id: timeSheetId, approval_status: timeSheet.approval_status },
|
|
});
|
|
}
|
|
|
|
const invoicedEntry = await trx('time_entries')
|
|
.where({
|
|
tenant: tenantId,
|
|
time_sheet_id: timeSheetId,
|
|
invoiced: true,
|
|
})
|
|
.first('entry_id');
|
|
|
|
if (invoicedEntry) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'This time sheet contains invoiced time and cannot be reopened',
|
|
details: { time_sheet_id: timeSheetId },
|
|
});
|
|
}
|
|
|
|
await trx('time_sheets')
|
|
.where({ tenant: tenantId, id: timeSheetId })
|
|
.update({
|
|
approval_status: 'CHANGES_REQUESTED',
|
|
approved_at: null,
|
|
approved_by: null,
|
|
});
|
|
|
|
await trx('time_entries')
|
|
.where({ tenant: tenantId, time_sheet_id: timeSheetId })
|
|
.update({
|
|
approval_status: 'CHANGES_REQUESTED',
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
|
|
await trx('time_sheet_comments').insert({
|
|
tenant: tenantId,
|
|
comment_id: uuidv4(),
|
|
time_sheet_id: timeSheetId,
|
|
user_id: actorUserId,
|
|
comment: `Approval reversed: ${reason}`,
|
|
is_approver: true,
|
|
created_at: trx.fn.now(),
|
|
});
|
|
|
|
return {
|
|
time_sheet: await summarizeTimeSheet(trx, tenantId, timeSheetId),
|
|
};
|
|
}
|
|
|
|
export async function addWorkflowTimeSheetComment(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
timeSheetId: string;
|
|
comment: string;
|
|
isApprover: boolean;
|
|
}): Promise<{
|
|
comment: WorkflowTimeSheetCommentSummary;
|
|
time_sheet: WorkflowTimeSheetSummary;
|
|
}> {
|
|
const { trx, tenantId, actorUserId, timeSheetId, comment, isApprover } = params;
|
|
const existing = await trx('time_sheets')
|
|
.where({ tenant: tenantId, id: timeSheetId })
|
|
.first('id', 'user_id');
|
|
if (!existing) {
|
|
throw new WorkflowTimeDomainError({
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Time sheet not found',
|
|
details: { time_sheet_id: timeSheetId },
|
|
});
|
|
}
|
|
|
|
await assertCanActOnBehalfForWorkflowTime(trx, tenantId, actorUserId, String(existing.user_id));
|
|
|
|
const [inserted] = await trx('time_sheet_comments')
|
|
.insert({
|
|
tenant: tenantId,
|
|
comment_id: uuidv4(),
|
|
time_sheet_id: timeSheetId,
|
|
user_id: actorUserId,
|
|
comment,
|
|
is_approver: isApprover,
|
|
created_at: trx.fn.now(),
|
|
})
|
|
.returning(['comment_id', 'user_id', 'comment', 'is_approver', 'created_at']);
|
|
|
|
return {
|
|
comment: {
|
|
comment_id: String(inserted.comment_id),
|
|
user_id: String(inserted.user_id),
|
|
comment: String(inserted.comment),
|
|
is_approver: Boolean(inserted.is_approver),
|
|
created_at: toIsoString(inserted.created_at as string | Date),
|
|
},
|
|
time_sheet: await summarizeTimeSheet(trx, tenantId, timeSheetId),
|
|
};
|
|
}
|
|
|
|
async function resolveClientIdForEntryRow(
|
|
trx: Knex.Transaction,
|
|
tenantId: string,
|
|
row: { work_item_type: string | null; work_item_id: string | null }
|
|
): Promise<string | null> {
|
|
const link = getLinkFromStoredEntry({
|
|
work_item_type: row.work_item_type,
|
|
work_item_id: row.work_item_id,
|
|
});
|
|
const context = await getWorkItemClientContext(trx, tenantId, link);
|
|
return context.clientId;
|
|
}
|
|
|
|
export async function summarizeWorkflowTimeEntries(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
input: WorkflowTimeFindEntriesInput & {
|
|
group_by?: WorkflowTimeSummaryGroupBy[];
|
|
};
|
|
}): Promise<{
|
|
totals: {
|
|
entry_count: number;
|
|
total_minutes: number;
|
|
billable_minutes: number;
|
|
non_billable_minutes: number;
|
|
approved_count: number;
|
|
submitted_count: number;
|
|
draft_count: number;
|
|
changes_requested_count: number;
|
|
invoiced_count: number;
|
|
};
|
|
groups: Array<{
|
|
key: Record<string, string | boolean | null>;
|
|
entry_count: number;
|
|
total_minutes: number;
|
|
billable_minutes: number;
|
|
}>;
|
|
}> {
|
|
const { trx, tenantId, actorUserId } = params;
|
|
const input = await scopeFindEntriesInputForActor(trx, tenantId, actorUserId, params.input);
|
|
const limit = Math.min(Math.max(input.limit ?? 200, 1), 500);
|
|
const groupBy = input.group_by ?? [];
|
|
|
|
const query = trx('time_entries as te')
|
|
.select(
|
|
'te.entry_id',
|
|
'te.user_id',
|
|
'te.work_item_id',
|
|
'te.work_item_type',
|
|
'te.service_id',
|
|
'te.contract_line_id',
|
|
'te.approval_status',
|
|
'te.invoiced',
|
|
'te.billable_duration',
|
|
'te.work_date',
|
|
'te.start_time',
|
|
'te.end_time'
|
|
)
|
|
.orderBy('te.start_time', 'desc')
|
|
.limit(limit);
|
|
|
|
applyFindEntriesFilters(query, tenantId, input);
|
|
|
|
const rows = await query;
|
|
const cache = new Map<string, string | null>();
|
|
let totalMinutes = 0;
|
|
let billableMinutes = 0;
|
|
let approvedCount = 0;
|
|
let submittedCount = 0;
|
|
let draftCount = 0;
|
|
let changesRequestedCount = 0;
|
|
let invoicedCount = 0;
|
|
|
|
const groups = new Map<string, {
|
|
key: Record<string, string | boolean | null>;
|
|
entry_count: number;
|
|
total_minutes: number;
|
|
billable_minutes: number;
|
|
}>();
|
|
|
|
for (const row of rows) {
|
|
const startIso = toIsoString(row.start_time as string | Date);
|
|
const endIso = toIsoString(row.end_time as string | Date);
|
|
const entryMinutes = Math.round((new Date(endIso).getTime() - new Date(startIso).getTime()) / 60000);
|
|
const entryBillable = Number(row.billable_duration ?? 0);
|
|
|
|
totalMinutes += entryMinutes;
|
|
billableMinutes += entryBillable;
|
|
|
|
const approval = String(row.approval_status ?? 'DRAFT');
|
|
if (approval === 'APPROVED') approvedCount += 1;
|
|
if (approval === 'SUBMITTED') submittedCount += 1;
|
|
if (approval === 'DRAFT') draftCount += 1;
|
|
if (approval === 'CHANGES_REQUESTED') changesRequestedCount += 1;
|
|
if (row.invoiced) invoicedCount += 1;
|
|
|
|
if (groupBy.length > 0) {
|
|
const key: Record<string, string | boolean | null> = {};
|
|
for (const field of groupBy) {
|
|
if (field === 'billable') {
|
|
key[field] = entryBillable > 0;
|
|
continue;
|
|
}
|
|
if (field === 'client_id') {
|
|
const cacheKey = `${row.work_item_type ?? 'null'}:${row.work_item_id ?? 'null'}`;
|
|
if (!cache.has(cacheKey)) {
|
|
cache.set(cacheKey, await resolveClientIdForEntryRow(trx, tenantId, {
|
|
work_item_type: (row.work_item_type as string | null) ?? null,
|
|
work_item_id: (row.work_item_id as string | null) ?? null,
|
|
}));
|
|
}
|
|
key[field] = cache.get(cacheKey) ?? null;
|
|
continue;
|
|
}
|
|
key[field] = (row as Record<string, unknown>)[field] as string | boolean | null;
|
|
}
|
|
const serialized = JSON.stringify(key);
|
|
const existing = groups.get(serialized) ?? {
|
|
key,
|
|
entry_count: 0,
|
|
total_minutes: 0,
|
|
billable_minutes: 0,
|
|
};
|
|
existing.entry_count += 1;
|
|
existing.total_minutes += entryMinutes;
|
|
existing.billable_minutes += entryBillable;
|
|
groups.set(serialized, existing);
|
|
}
|
|
}
|
|
|
|
return {
|
|
totals: {
|
|
entry_count: rows.length,
|
|
total_minutes: totalMinutes,
|
|
billable_minutes: billableMinutes,
|
|
non_billable_minutes: Math.max(0, totalMinutes - billableMinutes),
|
|
approved_count: approvedCount,
|
|
submitted_count: submittedCount,
|
|
draft_count: draftCount,
|
|
changes_requested_count: changesRequestedCount,
|
|
invoiced_count: invoicedCount,
|
|
},
|
|
groups: Array.from(groups.values()),
|
|
};
|
|
}
|
|
|
|
type WorkflowBillingBlocker = {
|
|
category: string;
|
|
count: number;
|
|
entry_ids: string[];
|
|
explanation: string;
|
|
};
|
|
|
|
async function findBillingBlockersInternal(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
filters: WorkflowTimeFindEntriesInput;
|
|
entryIds?: string[];
|
|
requireTimesheet?: boolean;
|
|
limit: number;
|
|
}): Promise<WorkflowBillingBlocker[]> {
|
|
const { trx, tenantId, filters, entryIds, requireTimesheet, limit } = params;
|
|
|
|
const query = trx('time_entries as te')
|
|
.select(
|
|
'te.entry_id',
|
|
'te.approval_status',
|
|
'te.service_id',
|
|
'te.contract_line_id',
|
|
'te.billable_duration',
|
|
'te.work_item_id',
|
|
'te.work_item_type',
|
|
'te.time_sheet_id',
|
|
'te.start_time',
|
|
'te.end_time'
|
|
)
|
|
.limit(limit);
|
|
|
|
applyFindEntriesFilters(query, tenantId, filters);
|
|
if (entryIds?.length) {
|
|
query.whereIn('te.entry_id', entryIds);
|
|
}
|
|
|
|
const rows = await query;
|
|
const buckets = new Map<string, Set<string>>();
|
|
|
|
const push = (category: string, entryId: string) => {
|
|
const set = buckets.get(category) ?? new Set<string>();
|
|
set.add(entryId);
|
|
buckets.set(category, set);
|
|
};
|
|
|
|
for (const row of rows) {
|
|
const entryId = String(row.entry_id);
|
|
const approval = String(row.approval_status ?? 'DRAFT');
|
|
if (approval !== 'APPROVED') {
|
|
push(`status_${approval.toLowerCase()}`, entryId);
|
|
}
|
|
if (!row.service_id) {
|
|
push('missing_service', entryId);
|
|
}
|
|
if (Number(row.billable_duration ?? 0) > 0 && !row.contract_line_id) {
|
|
push('missing_contract_line', entryId);
|
|
}
|
|
const durationMinutes = Math.round(
|
|
(new Date(toIsoString(row.end_time as string | Date)).getTime() - new Date(toIsoString(row.start_time as string | Date)).getTime()) / 60000
|
|
);
|
|
if (durationMinutes <= 0 || Number(row.billable_duration ?? 0) < 0) {
|
|
push('invalid_duration', entryId);
|
|
}
|
|
if (Number(row.billable_duration ?? 0) > 0 && (!row.work_item_id || !row.work_item_type)) {
|
|
push('missing_work_item', entryId);
|
|
}
|
|
if (requireTimesheet && !row.time_sheet_id) {
|
|
push('missing_timesheet', entryId);
|
|
}
|
|
}
|
|
|
|
const explanations: Record<string, string> = {
|
|
status_draft: 'Entries are still in DRAFT and require submission/approval.',
|
|
status_submitted: 'Entries are SUBMITTED and still pending approval.',
|
|
status_changes_requested: 'Entries are marked CHANGES_REQUESTED and require technician updates.',
|
|
missing_service: 'Entries do not have a service assignment.',
|
|
missing_contract_line: 'Billable entries are missing a contract line.',
|
|
invalid_duration: 'Entries have invalid or zero duration values.',
|
|
missing_work_item: 'Billable entries are missing required linked work items.',
|
|
missing_timesheet: 'Entries are not attached to an expected time sheet.',
|
|
};
|
|
|
|
return Array.from(buckets.entries()).map(([category, ids]) => ({
|
|
category,
|
|
count: ids.size,
|
|
entry_ids: Array.from(ids),
|
|
explanation: explanations[category] ?? 'Entries match this blocker category.',
|
|
}));
|
|
}
|
|
|
|
export async function findWorkflowTimeBillingBlockers(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
input: WorkflowTimeFindEntriesInput & {
|
|
entry_ids?: string[];
|
|
require_timesheet?: boolean;
|
|
limit?: number;
|
|
};
|
|
}): Promise<{
|
|
blockers: WorkflowBillingBlocker[];
|
|
}> {
|
|
const { trx, tenantId, actorUserId } = params;
|
|
const input = await scopeFindEntriesInputForActor(trx, tenantId, actorUserId, params.input);
|
|
return {
|
|
blockers: await findBillingBlockersInternal({
|
|
trx,
|
|
tenantId,
|
|
filters: input,
|
|
entryIds: input.entry_ids,
|
|
requireTimesheet: input.require_timesheet,
|
|
limit: Math.min(Math.max(input.limit ?? 200, 1), 500),
|
|
}),
|
|
};
|
|
}
|
|
|
|
export async function validateWorkflowTimeEntries(params: {
|
|
trx: Knex.Transaction;
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
input: WorkflowTimeFindEntriesInput & {
|
|
entry_ids?: string[];
|
|
require_timesheet?: boolean;
|
|
limit?: number;
|
|
};
|
|
}): Promise<{
|
|
valid: boolean;
|
|
blocker_count: number;
|
|
blockers: WorkflowBillingBlocker[];
|
|
}> {
|
|
const input = await scopeFindEntriesInputForActor(params.trx, params.tenantId, params.actorUserId, params.input);
|
|
const blockers = await findBillingBlockersInternal({
|
|
trx: params.trx,
|
|
tenantId: params.tenantId,
|
|
filters: input,
|
|
entryIds: input.entry_ids,
|
|
requireTimesheet: input.require_timesheet,
|
|
limit: Math.min(Math.max(input.limit ?? 200, 1), 500),
|
|
});
|
|
|
|
return {
|
|
valid: blockers.length === 0,
|
|
blocker_count: blockers.reduce((sum, blocker) => sum + blocker.count, 0),
|
|
blockers,
|
|
};
|
|
}
|