thatcher 1.0.73 → 1.0.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -231,10 +231,71 @@ function withInventoryDefaults(masterConfig) {
|
|
|
231
231
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
const TIME_ENTRY_ENTITY_DEFAULT = {
|
|
235
|
+
label: 'Time Entry',
|
|
236
|
+
label_plural: 'Time Entries',
|
|
237
|
+
system_entity: true,
|
|
238
|
+
fields: {
|
|
239
|
+
task_id: { type: 'ref', ref: 'task', required: true, label: 'Task' },
|
|
240
|
+
user_id: { type: 'ref', ref: 'user', required: true, label: 'User' },
|
|
241
|
+
date: { type: 'date', required: true, label: 'Date' },
|
|
242
|
+
hours: { type: 'number', required: true, min: 0, max: 24, label: 'Hours' },
|
|
243
|
+
billable: { type: 'bool', default: true, label: 'Billable' },
|
|
244
|
+
rate: { type: 'currency', label: 'Rate' },
|
|
245
|
+
notes: { type: 'textarea', label: 'Notes' },
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
function withTimeTrackingDefaults(masterConfig) {
|
|
250
|
+
const entities = { ...(masterConfig.entities || {}) };
|
|
251
|
+
let changed = false;
|
|
252
|
+
if (!entities.time_entry) { entities.time_entry = TIME_ENTRY_ENTITY_DEFAULT; changed = true; }
|
|
253
|
+
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const CONTRACT_LIFECYCLE_WORKFLOW = {
|
|
257
|
+
state_field: 'status',
|
|
258
|
+
stages: [
|
|
259
|
+
{ name: 'draft', label: 'Draft', forward: ['active'], backward: [] },
|
|
260
|
+
{ name: 'active', label: 'Active', forward: ['expired', 'renewed', 'terminated'], backward: [] },
|
|
261
|
+
{ name: 'expired', label: 'Expired', forward: ['renewed'], backward: [] },
|
|
262
|
+
{ name: 'terminated', label: 'Terminated', forward: [], backward: [] },
|
|
263
|
+
{ name: 'renewed', label: 'Renewed', forward: [], backward: [] },
|
|
264
|
+
],
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const CONTRACT_ENTITY_DEFAULT = {
|
|
268
|
+
label: 'Contract',
|
|
269
|
+
label_plural: 'Contracts',
|
|
270
|
+
system_entity: true,
|
|
271
|
+
workflow: 'contract_lifecycle',
|
|
272
|
+
fields: {
|
|
273
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
274
|
+
organization_id: { type: 'ref', ref: 'organization', label: 'Organization' },
|
|
275
|
+
opportunity_id: { type: 'ref', ref: 'opportunity', label: 'Opportunity' },
|
|
276
|
+
value: { type: 'currency', label: 'Value' },
|
|
277
|
+
start_date: { type: 'date', required: true, label: 'Start Date' },
|
|
278
|
+
end_date: { type: 'date', required: true, label: 'End Date' },
|
|
279
|
+
status: { type: 'enum', options: ['draft', 'active', 'expired', 'terminated', 'renewed'], default: 'draft', label: 'Status' },
|
|
280
|
+
auto_renew: { type: 'bool', default: false, label: 'Auto Renew' },
|
|
281
|
+
notice_period_days: { type: 'number', min: 0, default: 30, label: 'Notice Period (days)' },
|
|
282
|
+
owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
function withContractDefaults(masterConfig) {
|
|
287
|
+
const entities = { ...(masterConfig.entities || {}) };
|
|
288
|
+
const workflows = { ...(masterConfig.workflows || {}) };
|
|
289
|
+
let changed = false;
|
|
290
|
+
if (!entities.contract) { entities.contract = CONTRACT_ENTITY_DEFAULT; changed = true; }
|
|
291
|
+
if (!workflows.contract_lifecycle) { workflows.contract_lifecycle = CONTRACT_LIFECYCLE_WORKFLOW; changed = true; }
|
|
292
|
+
return changed ? { ...masterConfig, entities, workflows } : masterConfig;
|
|
293
|
+
}
|
|
294
|
+
|
|
234
295
|
export class ConfigGeneratorEngine {
|
|
235
296
|
constructor(masterConfig) {
|
|
236
297
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
237
|
-
this.masterConfig = deepFreeze(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))));
|
|
298
|
+
this.masterConfig = deepFreeze(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))))));
|
|
238
299
|
this.specCache = new LRUCache(100);
|
|
239
300
|
this.debugMode = false;
|
|
240
301
|
this._plugins = new Map();
|
|
@@ -152,6 +152,22 @@ function resolveEnumOptions(fieldDef, entityName) {
|
|
|
152
152
|
return [];
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// A non-privileged user may only log time against their own user_id -- a
|
|
156
|
+
// clerk POSTing another user's id must be rejected the same way it would be
|
|
157
|
+
// UI-side, but server-side is the authoritative check per this session's SEC
|
|
158
|
+
// pattern. Privileged roles (partner/manager) may log time for anyone, e.g.
|
|
159
|
+
// entering a team member's hours on their behalf.
|
|
160
|
+
function checkTimeEntryOwnership(entityName, data, options) {
|
|
161
|
+
if (entityName !== 'time_entry') return null;
|
|
162
|
+
const actingUser = options?.actingUser;
|
|
163
|
+
if (!actingUser) return null;
|
|
164
|
+
if (['partner', 'admin', 'manager'].includes(actingUser.role)) return null;
|
|
165
|
+
if (data.user_id !== undefined && data.user_id !== actingUser.id) {
|
|
166
|
+
return `Cannot log time for another user`;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
155
171
|
// Stock movements are immutable history (checked at creation only, never on
|
|
156
172
|
// edit -- there is no update path for them). An outbound movement (negative
|
|
157
173
|
// quantity) that would drive the running balance below zero is invalid: the
|
|
@@ -174,7 +190,22 @@ async function checkStockBalance(entityName, data) {
|
|
|
174
190
|
return null;
|
|
175
191
|
}
|
|
176
192
|
|
|
177
|
-
|
|
193
|
+
// A contract whose end_date is not strictly after its start_date is invalid
|
|
194
|
+
// regardless of what the form happened to allow -- checked wherever either
|
|
195
|
+
// date is present in the payload so it also catches a create that only sets
|
|
196
|
+
// one of the two dates against an existing record's other date on update.
|
|
197
|
+
function checkContractDateOrder(entityName, data, existingRecord) {
|
|
198
|
+
if (entityName !== 'contract') return null;
|
|
199
|
+
const startDate = data.start_date !== undefined ? data.start_date : existingRecord?.start_date;
|
|
200
|
+
const endDate = data.end_date !== undefined ? data.end_date : existingRecord?.end_date;
|
|
201
|
+
if (startDate == null || endDate == null) return null;
|
|
202
|
+
if (Number(endDate) <= Number(startDate)) {
|
|
203
|
+
return `end_date must be after start_date`;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function validateEntity(entityName, data, existingRecord = null, options = {}) {
|
|
178
209
|
const spec = getSpec(entityName);
|
|
179
210
|
const errors = {};
|
|
180
211
|
|
|
@@ -201,6 +232,12 @@ export async function validateEntity(entityName, data, existingRecord = null) {
|
|
|
201
232
|
const stockErr = await checkStockBalance(entityName, data);
|
|
202
233
|
if (stockErr) errors.quantity = stockErr;
|
|
203
234
|
|
|
235
|
+
const ownershipErr = checkTimeEntryOwnership(entityName, data, options);
|
|
236
|
+
if (ownershipErr) errors.user_id = ownershipErr;
|
|
237
|
+
|
|
238
|
+
const dateOrderErr = checkContractDateOrder(entityName, data, existingRecord);
|
|
239
|
+
if (dateOrderErr) errors.end_date = dateOrderErr;
|
|
240
|
+
|
|
204
241
|
return errors;
|
|
205
242
|
}
|
|
206
243
|
|
|
@@ -276,6 +313,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
|
|
|
276
313
|
const depErr = await checkTaskDependencies(entityName, changes, existingRecord);
|
|
277
314
|
if (depErr) errors.status = depErr;
|
|
278
315
|
|
|
316
|
+
const dateOrderErr = checkContractDateOrder(entityName, changes, existingRecord);
|
|
317
|
+
if (dateOrderErr) errors.end_date = dateOrderErr;
|
|
318
|
+
|
|
279
319
|
return errors;
|
|
280
320
|
}
|
|
281
321
|
|
package/src/server/server.js
CHANGED
|
@@ -417,7 +417,7 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
417
417
|
// existence and entity-specific rules (e.g. stock-movement balance
|
|
418
418
|
// enforcement below) were silently skipped for a hand-crafted create.
|
|
419
419
|
const { validateEntity, hasErrors } = await import('../lib/validation/entity-validators.js');
|
|
420
|
-
const createErrors = await validateEntity(entity, body, null);
|
|
420
|
+
const createErrors = await validateEntity(entity, body, null, { actingUser: user });
|
|
421
421
|
if (hasErrors(createErrors)) {
|
|
422
422
|
res.writeHead(400);
|
|
423
423
|
res.end(JSON.stringify({ error: 'Validation failed', errors: createErrors }));
|
|
@@ -138,9 +138,31 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
|
|
|
138
138
|
})()
|
|
139
139
|
: ''
|
|
140
140
|
|
|
141
|
+
// total_hours/billable_amount are computed (page-handler.js sums time_entry
|
|
142
|
+
// rows, directly for a task or joined through task for a project), same
|
|
143
|
+
// not-a-spec-field treatment as current_stock above.
|
|
144
|
+
const timeTrackingRows = (entityName === 'task' || entityName === 'project') && typeof item.total_hours === 'number'
|
|
145
|
+
? `<div class="detail-row"><span class="detail-row-label">Total Hours</span><span class="detail-row-value">${esc(String(item.total_hours))}</span></div>
|
|
146
|
+
<div class="detail-row"><span class="detail-row-label">Billable Amount</span><span class="detail-row-value">${esc('$' + (item.billable_amount / 100).toFixed(2))}</span></div>`
|
|
147
|
+
: ''
|
|
148
|
+
|
|
149
|
+
// days_until_expiry is computed (page-handler.js diffs end_date against
|
|
150
|
+
// now), never a spec.fields entry, same treatment as current_stock above.
|
|
151
|
+
const expiryRow = entityName === 'contract' && typeof item.days_until_expiry === 'number'
|
|
152
|
+
? (() => {
|
|
153
|
+
const warn = item.status === 'active' && item.days_until_expiry <= (item.notice_period_days ?? 30)
|
|
154
|
+
const pillCls = item.days_until_expiry < 0 ? 'pill-danger' : (warn ? 'pill-warning' : 'pill-success')
|
|
155
|
+
const label = item.days_until_expiry < 0 ? `Expired ${Math.abs(item.days_until_expiry)} days ago` : `${item.days_until_expiry} days`
|
|
156
|
+
return `<div class="detail-row">
|
|
157
|
+
<span class="detail-row-label">Days Until Expiry</span>
|
|
158
|
+
<span class="detail-row-value"><span class="pill ${pillCls}">${esc(label)}${warn ? ' (Renewal Notice)' : ''}</span></span>
|
|
159
|
+
</div>`
|
|
160
|
+
})()
|
|
161
|
+
: ''
|
|
162
|
+
|
|
141
163
|
const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
|
|
142
164
|
|
|
143
|
-
const fieldRows = stockRow + visibleFields.map(([k, f]) =>
|
|
165
|
+
const fieldRows = stockRow + timeTrackingRows + expiryRow + visibleFields.map(([k, f]) =>
|
|
144
166
|
`<div class="detail-row">
|
|
145
167
|
<span class="detail-row-label">${esc(f.label || k)}</span>
|
|
146
168
|
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
|
package/src/ui/page-handler.js
CHANGED
|
@@ -125,6 +125,40 @@ async function handleGenericEntityView(user, entityName, id, req) {
|
|
|
125
125
|
resolvedItem = { ...resolvedItem, current_stock: currentStock };
|
|
126
126
|
} catch { resolvedItem = { ...resolvedItem, current_stock: 0 }; }
|
|
127
127
|
}
|
|
128
|
+
if (entityName === 'task') {
|
|
129
|
+
// total_hours/billable_amount are derived from time_entry history, same
|
|
130
|
+
// never-a-stored-counter discipline as product.current_stock above.
|
|
131
|
+
try {
|
|
132
|
+
const entries = await list('time_entry', { task_id: id });
|
|
133
|
+
const totalHours = entries.reduce((sum, e) => sum + (Number(e.hours) || 0), 0);
|
|
134
|
+
const billableAmount = entries.filter(e => e.billable).reduce((sum, e) => sum + (Number(e.hours) || 0) * (Number(e.rate) || 0), 0);
|
|
135
|
+
resolvedItem = { ...resolvedItem, total_hours: totalHours, billable_amount: billableAmount };
|
|
136
|
+
} catch { resolvedItem = { ...resolvedItem, total_hours: 0, billable_amount: 0 }; }
|
|
137
|
+
}
|
|
138
|
+
if (entityName === 'project') {
|
|
139
|
+
// Project-level rollup joins through task the same way the rollup report
|
|
140
|
+
// added last pass joins entity A through a ref field to entity B -- no
|
|
141
|
+
// new join logic, just the same id-lookup-then-aggregate shape.
|
|
142
|
+
try {
|
|
143
|
+
const tasks = await list('task', { project_id: id });
|
|
144
|
+
const taskIds = new Set(tasks.map(t => t.id));
|
|
145
|
+
const allEntries = await list('time_entry', {});
|
|
146
|
+
const projectEntries = allEntries.filter(e => taskIds.has(e.task_id));
|
|
147
|
+
const totalHours = projectEntries.reduce((sum, e) => sum + (Number(e.hours) || 0), 0);
|
|
148
|
+
const billableAmount = projectEntries.filter(e => e.billable).reduce((sum, e) => sum + (Number(e.hours) || 0) * (Number(e.rate) || 0), 0);
|
|
149
|
+
resolvedItem = { ...resolvedItem, total_hours: totalHours, billable_amount: billableAmount };
|
|
150
|
+
} catch { resolvedItem = { ...resolvedItem, total_hours: 0, billable_amount: 0 }; }
|
|
151
|
+
}
|
|
152
|
+
if (entityName === 'contract' && resolvedItem.end_date != null) {
|
|
153
|
+
// days_until_expiry is computed from the already-access-checked record
|
|
154
|
+
// (get(...,{user}) above already returned null for a denied/absent
|
|
155
|
+
// contract before this line is ever reached), never a separate query
|
|
156
|
+
// that could leak the value to a caller who never proved they can view
|
|
157
|
+
// this specific contract.
|
|
158
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
159
|
+
const daysUntilExpiry = Math.floor((Number(resolvedItem.end_date) - nowSeconds) / 86400);
|
|
160
|
+
resolvedItem = { ...resolvedItem, days_until_expiry: daysUntilExpiry };
|
|
161
|
+
}
|
|
128
162
|
// get(...,{user}) above already enforced row/org access for this exact
|
|
129
163
|
// record (a denied/absent record returns null before this point), so
|
|
130
164
|
// fetching its audit trail here is scoped by construction -- there is no
|