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
1079 lines
38 KiB
TypeScript
1079 lines
38 KiB
TypeScript
/**
|
|
* Schedule Entry Model
|
|
*
|
|
* Data access layer for schedule entry entities.
|
|
* Migrated from server/src/lib/models/scheduleEntry.ts
|
|
*
|
|
* Key changes from original:
|
|
* - Tenant is an explicit parameter (not from getCurrentTenantId)
|
|
* - This decouples the model from Next.js runtime
|
|
* - Class converted to object with methods for consistency
|
|
* - Event publishing removed (should be handled by calling code)
|
|
*/
|
|
|
|
import type { Knex } from 'knex';
|
|
import type {
|
|
IScheduleEntry,
|
|
IRecurrencePattern,
|
|
IEditScope,
|
|
IHoliday,
|
|
CreateScheduleEntryOptions,
|
|
} from '@alga-psa/types';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { generateOccurrences } from '../utils/recurrenceUtils';
|
|
|
|
/**
|
|
* Schedule Entry model with tenant-explicit methods.
|
|
* All methods require an explicit tenant parameter for multi-tenant safety.
|
|
*/
|
|
const ScheduleEntry = {
|
|
/**
|
|
* Helper method to fetch assigned user IDs for schedule entries.
|
|
*/
|
|
getAssignedUserIds: async (
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
entryIds: (string | undefined)[]
|
|
): Promise<Record<string, string[]>> => {
|
|
if (!tenant) {
|
|
throw new Error('Tenant context is required for getting schedule entry assignees');
|
|
}
|
|
|
|
const validEntryIds = entryIds.filter((id): id is string => id !== undefined);
|
|
|
|
if (validEntryIds.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
// Verify entries exist in the correct tenant
|
|
const validEntries = await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.whereIn('entry_id', validEntryIds)
|
|
.select('entry_id');
|
|
|
|
const validEntrySet = new Set(validEntries.map(e => e.entry_id));
|
|
const invalidEntryIds = validEntryIds.filter(id => !validEntrySet.has(id));
|
|
|
|
if (invalidEntryIds.length > 0) {
|
|
throw new Error(`Schedule entries ${invalidEntryIds.join(', ')} not found in tenant ${tenant}`);
|
|
}
|
|
|
|
const assignments = await knexOrTrx('schedule_entry_assignees')
|
|
.where('schedule_entry_assignees.tenant', tenant)
|
|
.whereIn('entry_id', validEntryIds)
|
|
.join('users', function () {
|
|
this.on('schedule_entry_assignees.user_id', '=', 'users.user_id')
|
|
.andOn('schedule_entry_assignees.tenant', '=', 'users.tenant');
|
|
})
|
|
.select('entry_id', 'schedule_entry_assignees.user_id');
|
|
|
|
// Group by entry_id
|
|
return assignments.reduce(
|
|
(acc: Record<string, string[]>, curr: { entry_id: string; user_id: string }) => {
|
|
if (!acc[curr.entry_id]) {
|
|
acc[curr.entry_id] = [];
|
|
}
|
|
acc[curr.entry_id].push(curr.user_id);
|
|
return acc;
|
|
},
|
|
{}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Helper method to update assignee records for a schedule entry.
|
|
*/
|
|
updateAssignees: async (
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
entry_id: string,
|
|
userIds: string[]
|
|
): Promise<void> => {
|
|
if (!tenant) {
|
|
throw new Error('Tenant context is required for updating schedule entry assignees');
|
|
}
|
|
|
|
// Verify entry exists in the correct tenant
|
|
const entryExists = await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', entry_id)
|
|
.first();
|
|
|
|
if (!entryExists) {
|
|
throw new Error(`Schedule entry ${entry_id} not found in tenant ${tenant}`);
|
|
}
|
|
|
|
// Delete existing assignments
|
|
await knexOrTrx('schedule_entry_assignees')
|
|
.where('schedule_entry_assignees.tenant', tenant)
|
|
.andWhere('entry_id', entry_id)
|
|
.del();
|
|
|
|
// Insert new assignments
|
|
if (userIds.length > 0) {
|
|
// Verify all users exist in the correct tenant
|
|
const validUsers = await knexOrTrx('users')
|
|
.where('users.tenant', tenant)
|
|
.whereIn('user_id', userIds)
|
|
.select('user_id');
|
|
|
|
const validUserIds = validUsers.map(u => u.user_id);
|
|
const invalidUserIds = userIds.filter(id => !validUserIds.includes(id));
|
|
|
|
if (invalidUserIds.length > 0) {
|
|
throw new Error(`Users ${invalidUserIds.join(', ')} not found in tenant ${tenant}`);
|
|
}
|
|
|
|
const assignments = userIds.map(
|
|
(user_id): { tenant: string; entry_id: string; user_id: string } => ({
|
|
tenant,
|
|
entry_id,
|
|
user_id,
|
|
})
|
|
);
|
|
await knexOrTrx('schedule_entry_assignees').insert(assignments);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Gets recurring entries within a date range by calculating virtual instances
|
|
* from master recurring entries and their recurrence patterns.
|
|
*/
|
|
getRecurringEntriesInRange: async (
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
start: Date,
|
|
end: Date
|
|
): Promise<IScheduleEntry[]> => {
|
|
if (!tenant) {
|
|
throw new Error('Tenant context is required for getting recurring entries');
|
|
}
|
|
|
|
// Load holidays for the tenant (unified holidays table - shared with SLA system)
|
|
// Include global holidays (schedule_id IS NULL) that apply to all schedules
|
|
const startDateStr = start.toISOString().split('T')[0];
|
|
const endDateStr = end.toISOString().split('T')[0];
|
|
let holidays: IHoliday[] = [];
|
|
try {
|
|
holidays = await knexOrTrx('holidays')
|
|
.where('tenant', tenant)
|
|
.whereNull('schedule_id') // Only global holidays (not schedule-specific SLA holidays)
|
|
.where(function () {
|
|
this.whereBetween('holiday_date', [startDateStr, endDateStr])
|
|
.orWhere('is_recurring', true);
|
|
})
|
|
.select('*') as IHoliday[];
|
|
} catch {
|
|
// holidays table may not exist yet — proceed without holiday filtering
|
|
}
|
|
|
|
// Get master recurring entries that might have occurrences in the range
|
|
const masterEntries = await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.where('is_recurring', true)
|
|
.whereNotNull('recurrence_pattern')
|
|
.whereNull('original_entry_id')
|
|
.where('scheduled_start', '<=', end)
|
|
.andWhere(function () {
|
|
this.where('scheduled_end', '>=', start)
|
|
.orWhereRaw("(recurrence_pattern->>'endDate')::date >= ?", [start])
|
|
.orWhereRaw("(recurrence_pattern->>'endDate') IS NULL");
|
|
})
|
|
.select('*') as unknown as IScheduleEntry[];
|
|
|
|
if (masterEntries.length === 0) return [];
|
|
|
|
// Only return virtual instances — master entries are already included in getAll()
|
|
return ScheduleEntry.getRecurringEntriesWithAssignments(knexOrTrx, tenant, masterEntries, start, end, holidays);
|
|
},
|
|
|
|
/**
|
|
* Private helper: generates virtual recurring entries with user assignments.
|
|
*/
|
|
getRecurringEntriesWithAssignments: async (
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
entries: IScheduleEntry[],
|
|
start: Date,
|
|
end: Date,
|
|
holidays: IHoliday[] = []
|
|
): Promise<IScheduleEntry[]> => {
|
|
const result: IScheduleEntry[] = [];
|
|
|
|
// Get assigned user IDs for all master entries
|
|
const entryIds = entries.map((e): string => e.entry_id);
|
|
const assignedUserIds = await ScheduleEntry.getAssignedUserIds(knexOrTrx, tenant, entryIds);
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.recurrence_pattern) continue;
|
|
|
|
try {
|
|
// If recurrence_pattern is a string (from DB), parse it
|
|
if (typeof entry.recurrence_pattern === 'string') {
|
|
const pattern = JSON.parse(entry.recurrence_pattern) as IRecurrencePattern;
|
|
if (!pattern || Object.keys(pattern).length === 0) continue;
|
|
|
|
pattern.startDate = new Date(pattern.startDate);
|
|
pattern.startDate.setHours(0, 0, 0, 0);
|
|
|
|
if (pattern.endDate) {
|
|
pattern.endDate = new Date(pattern.endDate);
|
|
if (pattern.endDate < start) continue;
|
|
}
|
|
if (pattern.exceptions) {
|
|
pattern.exceptions = (pattern.exceptions || []).map((d): Date => new Date(d));
|
|
}
|
|
entry.recurrence_pattern = pattern;
|
|
}
|
|
|
|
// Calculate occurrences within the range, respecting endDate
|
|
const effectiveEnd =
|
|
entry.recurrence_pattern.endDate && entry.recurrence_pattern.endDate < end
|
|
? entry.recurrence_pattern.endDate
|
|
: end;
|
|
const occurrences = generateOccurrences(entry, start, effectiveEnd, { holidays });
|
|
|
|
// Create virtual entries for each occurrence
|
|
const duration =
|
|
new Date(entry.scheduled_end).getTime() - new Date(entry.scheduled_start).getTime();
|
|
const virtualEntries = occurrences
|
|
.filter((occurrence) => {
|
|
const utcDate = new Date(occurrence);
|
|
utcDate.setUTCHours(0, 0, 0, 0);
|
|
return !entry.recurrence_pattern?.exceptions?.some((ex) => {
|
|
const exDate = new Date(ex);
|
|
exDate.setUTCHours(0, 0, 0, 0);
|
|
return exDate.getTime() === utcDate.getTime();
|
|
});
|
|
})
|
|
.map(
|
|
(occurrence): IScheduleEntry => ({
|
|
...entry,
|
|
entry_id: `${entry.entry_id}_${occurrence.getTime()}`,
|
|
scheduled_start: occurrence,
|
|
scheduled_end: new Date(occurrence.getTime() + duration),
|
|
is_recurring: true,
|
|
original_entry_id: entry.entry_id,
|
|
assigned_user_ids: assignedUserIds[entry.entry_id] || [],
|
|
})
|
|
);
|
|
|
|
result.push(...virtualEntries);
|
|
} catch (error) {
|
|
console.error('Error processing recurring entry:', error);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
},
|
|
|
|
/**
|
|
* Get all schedule entries within a date range, including virtual recurring instances.
|
|
*/
|
|
getAll: async (
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
start: Date,
|
|
end: Date
|
|
): Promise<IScheduleEntry[]> => {
|
|
if (!tenant) {
|
|
throw new Error('Tenant context is required for getting schedule entries');
|
|
}
|
|
|
|
// Get all non-virtual, non-recurring-master entries.
|
|
// Recurring masters are excluded here because they are represented
|
|
// by the virtual instances generated below — including them would
|
|
// cause a duplicate on the first occurrence day.
|
|
const regularEntries = (await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.whereNull('original_entry_id')
|
|
.andWhere(function () {
|
|
this.where('is_recurring', false).orWhereNull('is_recurring');
|
|
})
|
|
.andWhere(function () {
|
|
this.whereBetween('scheduled_start', [start, end]).orWhereBetween('scheduled_end', [
|
|
start,
|
|
end,
|
|
]);
|
|
})
|
|
.select('*')
|
|
.orderBy('scheduled_start', 'asc')) as unknown as IScheduleEntry[];
|
|
|
|
// Get recurring virtual instances
|
|
const virtualEntries = await ScheduleEntry.getRecurringEntriesInRange(
|
|
knexOrTrx,
|
|
tenant,
|
|
start,
|
|
end
|
|
);
|
|
|
|
const allEntries = [...regularEntries, ...virtualEntries];
|
|
if (allEntries.length === 0) return allEntries;
|
|
|
|
// Get assigned user IDs for all non-virtual entries
|
|
const entryIds = regularEntries.map((e): string => e.entry_id);
|
|
const assignedUserIds = await ScheduleEntry.getAssignedUserIds(knexOrTrx, tenant, entryIds);
|
|
|
|
// Merge assigned user IDs into entries
|
|
return allEntries.map(
|
|
(entry): IScheduleEntry => ({
|
|
...entry,
|
|
assigned_user_ids: entry.assigned_user_ids || assignedUserIds[entry.entry_id] || [],
|
|
})
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Get the earliest schedule entry.
|
|
*/
|
|
getEarliest: async (
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string
|
|
): Promise<IScheduleEntry | undefined> => {
|
|
if (!tenant) {
|
|
throw new Error('Tenant context is required for getting earliest schedule entry');
|
|
}
|
|
|
|
const entry = (await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.orderBy('scheduled_start', 'asc')
|
|
.first()) as (IScheduleEntry & { entry_id: string }) | undefined;
|
|
|
|
if (!entry) return undefined;
|
|
|
|
if (entry.entry_id) {
|
|
const assignedUserIds = await ScheduleEntry.getAssignedUserIds(knexOrTrx, tenant, [
|
|
entry.entry_id,
|
|
]);
|
|
return {
|
|
...entry,
|
|
assigned_user_ids: assignedUserIds[entry.entry_id] || [],
|
|
};
|
|
}
|
|
|
|
return {
|
|
...entry,
|
|
assigned_user_ids: [],
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Get a single schedule entry by ID.
|
|
*/
|
|
get: async (
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
entry_id: string
|
|
): Promise<IScheduleEntry | undefined> => {
|
|
if (!tenant) {
|
|
throw new Error('Tenant context is required for getting schedule entry');
|
|
}
|
|
|
|
const entry = (await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', entry_id)
|
|
.first()) as (IScheduleEntry & { entry_id: string }) | undefined;
|
|
|
|
if (!entry) return undefined;
|
|
|
|
if (entry && entry_id) {
|
|
const assignedUserIds = await ScheduleEntry.getAssignedUserIds(knexOrTrx, tenant, [entry_id]);
|
|
return {
|
|
...entry,
|
|
assigned_user_ids: assignedUserIds[entry_id] || [],
|
|
};
|
|
}
|
|
|
|
return entry;
|
|
},
|
|
|
|
/**
|
|
* Create a new schedule entry.
|
|
*/
|
|
create: async (
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
entry: Omit<IScheduleEntry, 'entry_id' | 'created_at' | 'updated_at' | 'tenant'>,
|
|
options: CreateScheduleEntryOptions
|
|
): Promise<IScheduleEntry> => {
|
|
if (!tenant) {
|
|
throw new Error('Tenant context is required for creating schedule entry');
|
|
}
|
|
|
|
const entry_id = uuidv4();
|
|
|
|
// Prepare entry data
|
|
const entryData = {
|
|
entry_id,
|
|
title: entry.title,
|
|
scheduled_start: entry.scheduled_start,
|
|
scheduled_end: entry.scheduled_end,
|
|
notes: entry.notes,
|
|
status: entry.status || 'scheduled',
|
|
work_item_id: entry.work_item_type === 'ad_hoc' ? null : entry.work_item_id,
|
|
work_item_type: entry.work_item_type,
|
|
tenant,
|
|
recurrence_pattern:
|
|
entry.recurrence_pattern &&
|
|
typeof entry.recurrence_pattern === 'object' &&
|
|
Object.keys(entry.recurrence_pattern).length > 0
|
|
? JSON.stringify(entry.recurrence_pattern)
|
|
: null,
|
|
is_recurring: !!(
|
|
entry.recurrence_pattern &&
|
|
typeof entry.recurrence_pattern === 'object' &&
|
|
Object.keys(entry.recurrence_pattern).length > 0
|
|
),
|
|
is_private: entry.is_private || false,
|
|
};
|
|
|
|
// Create main entry
|
|
const [createdEntry] = await knexOrTrx('schedule_entries')
|
|
.insert(entryData)
|
|
.returning('*');
|
|
|
|
// Create assignee records
|
|
await ScheduleEntry.updateAssignees(knexOrTrx, tenant, createdEntry.entry_id, options.assignedUserIds);
|
|
|
|
return {
|
|
...createdEntry,
|
|
assigned_user_ids: options.assignedUserIds,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Update an existing schedule entry with recurrence scope support.
|
|
*
|
|
* updateType controls how recurring entries are modified:
|
|
* - SINGLE: Extract virtual instance to standalone entry, add exception to master
|
|
* - FUTURE: Split series at this point — truncate master, create new master for future
|
|
* - ALL: Update the master entry directly, preserving exceptions
|
|
*
|
|
* For non-recurring entries, updateType is ignored and a simple update is performed.
|
|
*/
|
|
update: async (
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
entry_id: string,
|
|
entry: Partial<IScheduleEntry> & { assigned_user_ids?: string[] },
|
|
updateType?: IEditScope
|
|
): Promise<IScheduleEntry | undefined> => {
|
|
if (!tenant) {
|
|
throw new Error('Tenant context is required for updating schedule entry');
|
|
}
|
|
|
|
// Parse entry ID to determine if it's a virtual instance
|
|
const isVirtualId = entry_id.includes('_');
|
|
const [masterId, timestamp] = isVirtualId ? entry_id.split('_') : [entry_id, null];
|
|
const masterEntryId = masterId;
|
|
const virtualTimestamp = timestamp ? new Date(parseInt(timestamp, 10)) : undefined;
|
|
|
|
// Get the master entry
|
|
const originalEntry = await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.first();
|
|
|
|
if (!originalEntry) {
|
|
return undefined;
|
|
}
|
|
|
|
// Handle recurring entries with scope
|
|
if (originalEntry.recurrence_pattern && updateType) {
|
|
const originalPattern = ScheduleEntry.parseRecurrencePattern(originalEntry.recurrence_pattern);
|
|
|
|
if (originalPattern) {
|
|
switch (updateType) {
|
|
case 'single': {
|
|
// SINGLE: Create a standalone concrete entry and add exception to master
|
|
const assignedUserIds = await ScheduleEntry.getAssignedUserIds(
|
|
knexOrTrx,
|
|
tenant,
|
|
[masterEntryId]
|
|
);
|
|
|
|
const standaloneId = uuidv4();
|
|
await knexOrTrx('schedule_entries').insert({
|
|
entry_id: standaloneId,
|
|
title: entry.title || originalEntry.title,
|
|
scheduled_start: entry.scheduled_start || originalEntry.scheduled_start,
|
|
scheduled_end: entry.scheduled_end || originalEntry.scheduled_end,
|
|
notes: entry.notes || originalEntry.notes,
|
|
status: entry.status || originalEntry.status,
|
|
work_item_id: entry.work_item_id || originalEntry.work_item_id,
|
|
work_item_type: entry.work_item_type || originalEntry.work_item_type,
|
|
tenant,
|
|
is_recurring: false,
|
|
original_entry_id: null,
|
|
recurrence_pattern: null,
|
|
is_private: entry.is_private !== undefined ? entry.is_private : originalEntry.is_private,
|
|
});
|
|
|
|
// Copy assignments from master to standalone entry
|
|
await ScheduleEntry.updateAssignees(
|
|
knexOrTrx,
|
|
tenant,
|
|
standaloneId,
|
|
entry.assigned_user_ids || assignedUserIds[masterEntryId] || []
|
|
);
|
|
|
|
// Add exception for the occurrence being extracted, not necessarily the
|
|
// occurrence's new target date. Virtual recurring ids carry the
|
|
// original occurrence timestamp; using a rescheduled start here would
|
|
// leave the original generated occurrence visible alongside the
|
|
// standalone override.
|
|
const exceptionDate = virtualTimestamp
|
|
? new Date(virtualTimestamp)
|
|
: new Date(entry.scheduled_start || originalEntry.scheduled_start);
|
|
exceptionDate.setUTCHours(0, 0, 0, 0);
|
|
const updatedPattern = {
|
|
...originalPattern,
|
|
exceptions: [...(originalPattern.exceptions || []), exceptionDate],
|
|
};
|
|
|
|
await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.update({
|
|
recurrence_pattern: JSON.stringify(updatedPattern),
|
|
});
|
|
|
|
return {
|
|
...originalEntry,
|
|
entry_id: standaloneId,
|
|
title: entry.title || originalEntry.title,
|
|
scheduled_start: entry.scheduled_start || originalEntry.scheduled_start,
|
|
scheduled_end: entry.scheduled_end || originalEntry.scheduled_end,
|
|
notes: entry.notes || originalEntry.notes,
|
|
status: entry.status || originalEntry.status,
|
|
work_item_id: entry.work_item_id || originalEntry.work_item_id,
|
|
work_item_type: entry.work_item_type || originalEntry.work_item_type,
|
|
is_recurring: false,
|
|
original_entry_id: null,
|
|
is_private: entry.is_private !== undefined ? entry.is_private : originalEntry.is_private,
|
|
assigned_user_ids: entry.assigned_user_ids || assignedUserIds[masterEntryId] || [],
|
|
};
|
|
}
|
|
|
|
case 'future': {
|
|
// FUTURE: Split the recurrence into two series
|
|
if (!virtualTimestamp) {
|
|
throw new Error('Virtual timestamp is required for future updates');
|
|
}
|
|
|
|
const newMasterId = uuidv4();
|
|
|
|
// Truncate original master to end before the current instance
|
|
const originalEndDate = new Date(virtualTimestamp);
|
|
originalEndDate.setDate(originalEndDate.getDate() - 1);
|
|
originalEndDate.setHours(23, 59, 59, 999);
|
|
|
|
const futureOriginalPattern = {
|
|
...originalPattern,
|
|
endDate: originalEndDate,
|
|
exceptions: originalPattern.exceptions?.filter(
|
|
(d) => new Date(d) < virtualTimestamp
|
|
),
|
|
};
|
|
|
|
await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.update({
|
|
recurrence_pattern: JSON.stringify(futureOriginalPattern),
|
|
});
|
|
|
|
// Create new master starting at the current instance
|
|
const newStartDate = entry.scheduled_start || virtualTimestamp;
|
|
const newPattern = entry.recurrence_pattern
|
|
? {
|
|
...entry.recurrence_pattern,
|
|
startDate: newStartDate,
|
|
exceptions: [],
|
|
}
|
|
: {
|
|
...originalPattern,
|
|
startDate: newStartDate,
|
|
endDate: originalPattern.endDate,
|
|
exceptions: originalPattern.exceptions?.filter(
|
|
(d) => new Date(d) >= virtualTimestamp
|
|
),
|
|
};
|
|
|
|
const newMasterEntry = {
|
|
entry_id: newMasterId,
|
|
title: entry.title || originalEntry.title,
|
|
scheduled_start: newStartDate,
|
|
scheduled_end: entry.scheduled_end || originalEntry.scheduled_end,
|
|
notes: entry.notes || originalEntry.notes,
|
|
status: entry.status || originalEntry.status,
|
|
work_item_id: entry.work_item_id || originalEntry.work_item_id,
|
|
work_item_type: entry.work_item_type || originalEntry.work_item_type,
|
|
tenant,
|
|
recurrence_pattern: JSON.stringify(newPattern),
|
|
is_recurring: true,
|
|
original_entry_id: null,
|
|
is_private: entry.is_private !== undefined ? entry.is_private : originalEntry.is_private,
|
|
};
|
|
|
|
await knexOrTrx('schedule_entries').insert(newMasterEntry);
|
|
|
|
const masterAssignees = await ScheduleEntry.getAssignedUserIds(
|
|
knexOrTrx,
|
|
tenant,
|
|
[masterEntryId]
|
|
);
|
|
await ScheduleEntry.updateAssignees(
|
|
knexOrTrx,
|
|
tenant,
|
|
newMasterId,
|
|
entry.assigned_user_ids || masterAssignees[masterEntryId] || []
|
|
);
|
|
|
|
return {
|
|
...newMasterEntry,
|
|
assigned_user_ids:
|
|
entry.assigned_user_ids || masterAssignees[masterEntryId] || [],
|
|
} as unknown as IScheduleEntry;
|
|
}
|
|
|
|
case 'all': {
|
|
// ALL: Update the master entry directly, preserving exceptions
|
|
const allUpdatePattern = entry.recurrence_pattern
|
|
? {
|
|
frequency: entry.recurrence_pattern.frequency,
|
|
interval: entry.recurrence_pattern.interval,
|
|
startDate: originalPattern.startDate,
|
|
endDate: entry.recurrence_pattern.endDate || originalPattern.endDate,
|
|
exceptions: originalPattern.exceptions || [],
|
|
daysOfWeek: entry.recurrence_pattern.daysOfWeek || originalPattern.daysOfWeek,
|
|
dayOfMonth: entry.recurrence_pattern.dayOfMonth || originalPattern.dayOfMonth,
|
|
monthOfYear:
|
|
entry.recurrence_pattern.monthOfYear || originalPattern.monthOfYear,
|
|
count: entry.recurrence_pattern.count || originalPattern.count,
|
|
}
|
|
: originalPattern;
|
|
|
|
const [updatedMasterEntry] = await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.update({
|
|
title: entry.title || originalEntry.title,
|
|
scheduled_start: entry.scheduled_start || originalEntry.scheduled_start,
|
|
scheduled_end: entry.scheduled_end || originalEntry.scheduled_end,
|
|
notes: entry.notes || originalEntry.notes,
|
|
status: entry.status || originalEntry.status,
|
|
work_item_id: entry.work_item_id || originalEntry.work_item_id,
|
|
work_item_type: entry.work_item_type || originalEntry.work_item_type,
|
|
recurrence_pattern: JSON.stringify(allUpdatePattern),
|
|
is_recurring: true,
|
|
})
|
|
.returning('*');
|
|
|
|
if (entry.assigned_user_ids) {
|
|
await ScheduleEntry.updateAssignees(
|
|
knexOrTrx,
|
|
tenant,
|
|
masterEntryId,
|
|
entry.assigned_user_ids
|
|
);
|
|
}
|
|
|
|
const finalAssignees = entry.assigned_user_ids ||
|
|
(await ScheduleEntry.getAssignedUserIds(knexOrTrx, tenant, [masterEntryId]))[
|
|
masterEntryId
|
|
] || [];
|
|
|
|
return {
|
|
...updatedMasterEntry,
|
|
assigned_user_ids: finalAssignees,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Non-recurring path (or recurring without updateType): simple field update
|
|
// Check if we're removing recurrence from a recurring entry
|
|
const isRemovingRecurrence =
|
|
originalEntry.is_recurring &&
|
|
entry.recurrence_pattern !== undefined &&
|
|
(!entry.recurrence_pattern || Object.keys(entry.recurrence_pattern).length === 0);
|
|
|
|
// Build update data
|
|
const updateData: Record<string, unknown> = {};
|
|
|
|
if (isRemovingRecurrence) {
|
|
updateData.recurrence_pattern = null;
|
|
updateData.is_recurring = false;
|
|
}
|
|
|
|
if (entry.title !== undefined) updateData.title = entry.title;
|
|
if (entry.scheduled_start !== undefined) updateData.scheduled_start = entry.scheduled_start;
|
|
if (entry.scheduled_end !== undefined) updateData.scheduled_end = entry.scheduled_end;
|
|
if (entry.notes !== undefined) updateData.notes = entry.notes;
|
|
if (entry.status !== undefined) updateData.status = entry.status;
|
|
if (entry.work_item_id !== undefined) updateData.work_item_id = entry.work_item_id;
|
|
if (entry.work_item_type !== undefined) updateData.work_item_type = entry.work_item_type;
|
|
if (entry.is_private !== undefined) updateData.is_private = entry.is_private;
|
|
|
|
if (entry.recurrence_pattern !== undefined && !isRemovingRecurrence) {
|
|
updateData.recurrence_pattern =
|
|
entry.recurrence_pattern &&
|
|
typeof entry.recurrence_pattern === 'object' &&
|
|
Object.keys(entry.recurrence_pattern).length > 0
|
|
? JSON.stringify(entry.recurrence_pattern)
|
|
: null;
|
|
updateData.is_recurring = !!(
|
|
entry.recurrence_pattern &&
|
|
typeof entry.recurrence_pattern === 'object' &&
|
|
Object.keys(entry.recurrence_pattern).length > 0
|
|
);
|
|
}
|
|
|
|
// Update the entry
|
|
const [updatedEntry] = await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.update(updateData)
|
|
.returning('*');
|
|
|
|
// Update assignees if provided
|
|
if (entry.assigned_user_ids) {
|
|
await ScheduleEntry.updateAssignees(knexOrTrx, tenant, masterEntryId, entry.assigned_user_ids);
|
|
updatedEntry.assigned_user_ids = entry.assigned_user_ids;
|
|
} else {
|
|
const assignedUserIds = await ScheduleEntry.getAssignedUserIds(knexOrTrx, tenant, [
|
|
masterEntryId,
|
|
]);
|
|
updatedEntry.assigned_user_ids = assignedUserIds[masterEntryId] || [];
|
|
}
|
|
|
|
return updatedEntry;
|
|
},
|
|
|
|
/**
|
|
* Delete a schedule entry with recurrence scope support.
|
|
*
|
|
* deleteType controls how recurring entries are removed:
|
|
* - SINGLE (virtual): Add exception date to master pattern
|
|
* - SINGLE (master): Create new master from next occurrence, delete original
|
|
* - FUTURE (virtual): Truncate master series end date to before this instance
|
|
* - FUTURE (master): Delete the entire series
|
|
* - ALL: Delete the master entry entirely
|
|
*
|
|
* For non-recurring entries, deleteType is ignored.
|
|
*/
|
|
delete: async (
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
entry_id: string,
|
|
deleteType?: IEditScope
|
|
): Promise<boolean> => {
|
|
if (!tenant) {
|
|
throw new Error('Tenant context is required for deleting schedule entry');
|
|
}
|
|
|
|
// Parse entry ID to determine if it's a virtual instance
|
|
const isVirtualId = entry_id.includes('_');
|
|
const [masterId, timestamp] = isVirtualId ? entry_id.split('_') : [entry_id, null];
|
|
const masterEntryId = masterId;
|
|
const virtualTimestamp = timestamp ? new Date(parseInt(timestamp, 10)) : undefined;
|
|
|
|
// Get the master entry
|
|
const originalEntry = await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.first();
|
|
|
|
if (!originalEntry) {
|
|
return false;
|
|
}
|
|
|
|
// Handle recurring entries with scope
|
|
if (originalEntry.recurrence_pattern && deleteType) {
|
|
const originalPattern = ScheduleEntry.parseRecurrencePattern(
|
|
originalEntry.recurrence_pattern
|
|
);
|
|
|
|
if (originalPattern) {
|
|
switch (deleteType) {
|
|
case 'single': {
|
|
if (virtualTimestamp) {
|
|
// Virtual instance: add exception date to master pattern
|
|
const exceptionDate = new Date(virtualTimestamp);
|
|
exceptionDate.setUTCHours(0, 0, 0, 0);
|
|
const updatedPattern = {
|
|
...originalPattern,
|
|
exceptions: [...(originalPattern.exceptions || []), exceptionDate],
|
|
};
|
|
|
|
await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.update({
|
|
recurrence_pattern: JSON.stringify(updatedPattern),
|
|
});
|
|
|
|
return true;
|
|
} else {
|
|
// Master entry: create new master from next occurrence, delete original
|
|
const now = new Date();
|
|
const futureDate = new Date(now);
|
|
futureDate.setFullYear(futureDate.getFullYear() + 1);
|
|
|
|
const occurrences = generateOccurrences(
|
|
{ ...originalEntry, recurrence_pattern: originalPattern } as IScheduleEntry,
|
|
originalEntry.scheduled_start,
|
|
futureDate
|
|
);
|
|
|
|
const masterStartTime = new Date(originalEntry.scheduled_start).getTime();
|
|
const nextOccurrence = occurrences.find(
|
|
(occ) => occ.getTime() > masterStartTime
|
|
);
|
|
|
|
if (nextOccurrence) {
|
|
const newMasterId = uuidv4();
|
|
const duration =
|
|
new Date(originalEntry.scheduled_end).getTime() -
|
|
new Date(originalEntry.scheduled_start).getTime();
|
|
|
|
const exceptionDate = new Date(originalEntry.scheduled_start);
|
|
exceptionDate.setUTCHours(0, 0, 0, 0);
|
|
|
|
const newPattern = {
|
|
...originalPattern,
|
|
startDate: nextOccurrence,
|
|
exceptions: [...(originalPattern.exceptions || []), exceptionDate],
|
|
};
|
|
|
|
await knexOrTrx('schedule_entries').insert({
|
|
entry_id: newMasterId,
|
|
title: originalEntry.title,
|
|
scheduled_start: nextOccurrence,
|
|
scheduled_end: new Date(nextOccurrence.getTime() + duration),
|
|
notes: originalEntry.notes,
|
|
status: originalEntry.status,
|
|
work_item_id: originalEntry.work_item_id,
|
|
work_item_type: originalEntry.work_item_type,
|
|
tenant,
|
|
recurrence_pattern: JSON.stringify(newPattern),
|
|
is_recurring: true,
|
|
is_private: originalEntry.is_private,
|
|
});
|
|
|
|
// Copy assignees to new master
|
|
const assignedUserIds = await ScheduleEntry.getAssignedUserIds(
|
|
knexOrTrx,
|
|
tenant,
|
|
[masterEntryId]
|
|
);
|
|
await ScheduleEntry.updateAssignees(
|
|
knexOrTrx,
|
|
tenant,
|
|
newMasterId,
|
|
assignedUserIds[masterEntryId] || []
|
|
);
|
|
}
|
|
|
|
// Delete assignees then original master
|
|
await knexOrTrx('schedule_entry_assignees')
|
|
.where('schedule_entry_assignees.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.del();
|
|
|
|
await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.del();
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
case 'future': {
|
|
if (virtualTimestamp) {
|
|
// Truncate series to end before this instance
|
|
const endDate = new Date(virtualTimestamp);
|
|
endDate.setDate(endDate.getDate() - 1);
|
|
endDate.setHours(23, 59, 59, 999);
|
|
|
|
const updatedPattern = {
|
|
...originalPattern,
|
|
endDate,
|
|
exceptions:
|
|
originalPattern.exceptions?.filter(
|
|
(d) => new Date(d) < virtualTimestamp
|
|
) || [],
|
|
};
|
|
|
|
await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.update({
|
|
recurrence_pattern: JSON.stringify(updatedPattern),
|
|
});
|
|
|
|
return true;
|
|
} else {
|
|
// Master entry in FUTURE mode = delete entire series
|
|
await knexOrTrx('schedule_entry_assignees')
|
|
.where('schedule_entry_assignees.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.del();
|
|
|
|
await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.del();
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
case 'all': {
|
|
// Delete assignees first, then the master entry
|
|
await knexOrTrx('schedule_entry_assignees')
|
|
.where('schedule_entry_assignees.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.del();
|
|
|
|
const deletedCount = await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', masterEntryId)
|
|
.del();
|
|
|
|
return deletedCount > 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Non-recurring path: simple delete
|
|
await knexOrTrx('schedule_entry_assignees')
|
|
.where('schedule_entry_assignees.tenant', tenant)
|
|
.andWhere('entry_id', entry_id)
|
|
.del();
|
|
|
|
const deletedCount = await knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.andWhere('entry_id', entry_id)
|
|
.del();
|
|
|
|
return deletedCount > 0;
|
|
},
|
|
|
|
/**
|
|
* Get schedule entries by work item.
|
|
*/
|
|
getByWorkItem: async (
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
workItemId: string,
|
|
workItemType: string
|
|
): Promise<IScheduleEntry[]> => {
|
|
if (!tenant) {
|
|
throw new Error('Tenant context is required for getting schedule entries by work item');
|
|
}
|
|
|
|
const entries = (await knexOrTrx('schedule_entries')
|
|
.where({
|
|
'schedule_entries.tenant': tenant,
|
|
work_item_id: workItemId,
|
|
work_item_type: workItemType,
|
|
})
|
|
.select('*')
|
|
.orderBy('scheduled_start', 'asc')) as unknown as IScheduleEntry[];
|
|
|
|
if (entries.length === 0) return entries;
|
|
|
|
// Get assigned user IDs
|
|
const entryIds = entries.map((e): string => e.entry_id);
|
|
const assignedUserIds = await ScheduleEntry.getAssignedUserIds(knexOrTrx, tenant, entryIds);
|
|
|
|
return entries.map(
|
|
(entry): IScheduleEntry => ({
|
|
...entry,
|
|
assigned_user_ids: assignedUserIds[entry.entry_id] || [],
|
|
})
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Get schedule entries by user.
|
|
*/
|
|
getByUser: async (
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
userId: string,
|
|
start?: Date,
|
|
end?: Date
|
|
): Promise<IScheduleEntry[]> => {
|
|
if (!tenant) {
|
|
throw new Error('Tenant context is required for getting schedule entries by user');
|
|
}
|
|
|
|
// Get entry IDs assigned to this user
|
|
let assignmentQuery = knexOrTrx('schedule_entry_assignees')
|
|
.where('schedule_entry_assignees.tenant', tenant)
|
|
.andWhere('user_id', userId)
|
|
.select('entry_id');
|
|
|
|
const assignmentResult = await assignmentQuery;
|
|
const entryIds = assignmentResult.map(a => a.entry_id);
|
|
|
|
if (entryIds.length === 0) return [];
|
|
|
|
// Get the actual entries
|
|
let entriesQuery = knexOrTrx('schedule_entries')
|
|
.where('schedule_entries.tenant', tenant)
|
|
.whereIn('entry_id', entryIds);
|
|
|
|
if (start && end) {
|
|
entriesQuery = entriesQuery.andWhere(function () {
|
|
this.whereBetween('scheduled_start', [start, end]).orWhereBetween('scheduled_end', [
|
|
start,
|
|
end,
|
|
]);
|
|
});
|
|
}
|
|
|
|
const entries = (await entriesQuery
|
|
.select('*')
|
|
.orderBy('scheduled_start', 'asc')) as unknown as IScheduleEntry[];
|
|
|
|
if (entries.length === 0) return entries;
|
|
|
|
// Get all assigned user IDs
|
|
const allEntryIds = entries.map((e): string => e.entry_id);
|
|
const assignedUserIds = await ScheduleEntry.getAssignedUserIds(knexOrTrx, tenant, allEntryIds);
|
|
|
|
return entries.map(
|
|
(entry): IScheduleEntry => ({
|
|
...entry,
|
|
assigned_user_ids: assignedUserIds[entry.entry_id] || [],
|
|
})
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Parse recurrence pattern from string or object.
|
|
*/
|
|
parseRecurrencePattern: (
|
|
pattern: string | IRecurrencePattern | null
|
|
): IRecurrencePattern | null => {
|
|
if (!pattern) return null;
|
|
if (typeof pattern === 'object') return pattern as IRecurrencePattern;
|
|
try {
|
|
return JSON.parse(pattern) as IRecurrencePattern;
|
|
} catch (error) {
|
|
console.error('Error parsing recurrence pattern:', error);
|
|
return null;
|
|
}
|
|
},
|
|
};
|
|
|
|
export default ScheduleEntry;
|