Hermes 284313f908
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
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

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,
};
}