thatcher 1.0.74 → 1.0.76
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 +1 -1
- package/src/lib/bulk-operations.js +30 -0
- package/src/lib/config-generator-engine.js +40 -1
- package/src/lib/contract-expiry.js +13 -0
- package/src/lib/scheduler-engine.js +12 -1
- package/src/lib/validation/entity-validators.js +21 -0
- package/src/ui/entity-renderer.js +15 -1
- package/src/ui/page-handler.js +9 -0
package/package.json
CHANGED
|
@@ -56,6 +56,33 @@ async function bulkTransitionOne(entityName, id, workflowName, toState, user) {
|
|
|
56
56
|
return transition(entityName, id, workflowName, toState, user, 'bulk operation');
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
async function bulkNotifyOne(entityName, spec, id, user, message) {
|
|
60
|
+
// Same permission/row-access gate every other bulk action already applies
|
|
61
|
+
// -- 'notify' reads the record to build the notification, so it must not
|
|
62
|
+
// bypass the checks a plain view of that record would require.
|
|
63
|
+
await requirePermission(user, spec, 'view');
|
|
64
|
+
const existing = await get(entityName, id, { user });
|
|
65
|
+
if (!existing) throw new Error('Not found');
|
|
66
|
+
if (!permissionService.checkRowAccess(user, spec, existing)) throw new Error('Access denied');
|
|
67
|
+
|
|
68
|
+
const recipientId = existing.owner_id;
|
|
69
|
+
if (!recipientId) throw new Error('Record has no owner_id to notify');
|
|
70
|
+
|
|
71
|
+
const { createNotification } = await import('../services/notification-engine.js');
|
|
72
|
+
// createNotification stores user_id as the sole addressee -- the existing
|
|
73
|
+
// /notifications route already filters list('notification',{user_id:...})
|
|
74
|
+
// scoped by {user}, so a notification is only ever visible to the user it
|
|
75
|
+
// names, the same row-access guarantee every other entity gets.
|
|
76
|
+
return createNotification({
|
|
77
|
+
user_id: recipientId,
|
|
78
|
+
title: message || `${spec.label || entityName} update`,
|
|
79
|
+
message: message || `${spec.label || entityName} "${existing.name || id}" requires attention`,
|
|
80
|
+
entity_type: entityName,
|
|
81
|
+
entity_id: id,
|
|
82
|
+
created_by: user?.id || 'system',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
59
86
|
export async function runBulkOperation(entityName, spec, ids, action, user) {
|
|
60
87
|
if (!Array.isArray(ids) || !ids.length) return { ok: false, error: 'ids array required and must be non-empty' };
|
|
61
88
|
if (ids.length > MAX_BULK_IDS) return { ok: false, error: `Cannot process more than ${MAX_BULK_IDS} ids in one bulk operation` };
|
|
@@ -77,6 +104,9 @@ export async function runBulkOperation(entityName, spec, ids, action, user) {
|
|
|
77
104
|
if (!action.workflow || !action.toState) throw new Error('action.workflow and action.toState required');
|
|
78
105
|
await bulkTransitionOne(entityName, id, action.workflow, action.toState, user);
|
|
79
106
|
results.push({ id, success: true });
|
|
107
|
+
} else if (action.type === 'notify') {
|
|
108
|
+
await bulkNotifyOne(entityName, spec, id, user, action.message);
|
|
109
|
+
results.push({ id, success: true });
|
|
80
110
|
} else {
|
|
81
111
|
results.push({ id, success: false, error: `Unknown action type "${action.type}"` });
|
|
82
112
|
}
|
|
@@ -253,10 +253,49 @@ function withTimeTrackingDefaults(masterConfig) {
|
|
|
253
253
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
254
254
|
}
|
|
255
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
|
+
|
|
256
295
|
export class ConfigGeneratorEngine {
|
|
257
296
|
constructor(masterConfig) {
|
|
258
297
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
259
|
-
this.masterConfig = deepFreeze(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))))));
|
|
298
|
+
this.masterConfig = deepFreeze(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))))));
|
|
260
299
|
this.specCache = new LRUCache(100);
|
|
261
300
|
this.debugMode = false;
|
|
262
301
|
this._plugins = new Map();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Single source of truth for contract expiry math, shared between
|
|
2
|
+
// page-handler.js's detail-view computation and scheduler-engine.js's
|
|
3
|
+
// expiry-notification pre-filter -- kept in one place so the two never drift
|
|
4
|
+
// against each other the way a duplicated formula could.
|
|
5
|
+
export function daysUntilExpiry(endDate, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
6
|
+
return Math.floor((Number(endDate) - nowSeconds) / 86400);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isWithinNoticeWindow(contract, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
10
|
+
if (contract.status !== 'active' || contract.end_date == null) return false;
|
|
11
|
+
const days = daysUntilExpiry(contract.end_date, nowSeconds);
|
|
12
|
+
return days <= (contract.notice_period_days ?? 30);
|
|
13
|
+
}
|
|
@@ -35,7 +35,18 @@ async function runOneJob(job, nowTs) {
|
|
|
35
35
|
// scoping crud-handlers.js's read path enforces for an interactive
|
|
36
36
|
// request, so a job cannot reach records outside its owner's org just
|
|
37
37
|
// because it runs unattended.
|
|
38
|
-
|
|
38
|
+
let targets = await list(job.entity, filter, { user: owner });
|
|
39
|
+
|
|
40
|
+
// job.filter can only express stored-field equality (e.g. status:active);
|
|
41
|
+
// "expiring within notice_period_days" is a per-record computed
|
|
42
|
+
// comparison, not a filterable field, so it's checked here in JS against
|
|
43
|
+
// the SAME shared math the contract detail view uses -- never a second,
|
|
44
|
+
// divergent formula.
|
|
45
|
+
if (job.entity === 'contract' && action?.type === 'notify') {
|
|
46
|
+
const { isWithinNoticeWindow } = await import('./contract-expiry.js');
|
|
47
|
+
targets = targets.filter(c => isWithinNoticeWindow(c, nowTs));
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
const ids = targets.map(r => r.id);
|
|
40
51
|
|
|
41
52
|
let result = { ok: true, total: 0, succeeded: 0, failed: 0, results: [] };
|
|
@@ -190,6 +190,21 @@ async function checkStockBalance(entityName, data) {
|
|
|
190
190
|
return null;
|
|
191
191
|
}
|
|
192
192
|
|
|
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
|
+
|
|
193
208
|
export async function validateEntity(entityName, data, existingRecord = null, options = {}) {
|
|
194
209
|
const spec = getSpec(entityName);
|
|
195
210
|
const errors = {};
|
|
@@ -220,6 +235,9 @@ export async function validateEntity(entityName, data, existingRecord = null, op
|
|
|
220
235
|
const ownershipErr = checkTimeEntryOwnership(entityName, data, options);
|
|
221
236
|
if (ownershipErr) errors.user_id = ownershipErr;
|
|
222
237
|
|
|
238
|
+
const dateOrderErr = checkContractDateOrder(entityName, data, existingRecord);
|
|
239
|
+
if (dateOrderErr) errors.end_date = dateOrderErr;
|
|
240
|
+
|
|
223
241
|
return errors;
|
|
224
242
|
}
|
|
225
243
|
|
|
@@ -295,6 +313,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
|
|
|
295
313
|
const depErr = await checkTaskDependencies(entityName, changes, existingRecord);
|
|
296
314
|
if (depErr) errors.status = depErr;
|
|
297
315
|
|
|
316
|
+
const dateOrderErr = checkContractDateOrder(entityName, changes, existingRecord);
|
|
317
|
+
if (dateOrderErr) errors.end_date = dateOrderErr;
|
|
318
|
+
|
|
298
319
|
return errors;
|
|
299
320
|
}
|
|
300
321
|
|
|
@@ -146,9 +146,23 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
|
|
|
146
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
147
|
: ''
|
|
148
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
|
+
|
|
149
163
|
const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
|
|
150
164
|
|
|
151
|
-
const fieldRows = stockRow + timeTrackingRows + visibleFields.map(([k, f]) =>
|
|
165
|
+
const fieldRows = stockRow + timeTrackingRows + expiryRow + visibleFields.map(([k, f]) =>
|
|
152
166
|
`<div class="detail-row">
|
|
153
167
|
<span class="detail-row-label">${esc(f.label || k)}</span>
|
|
154
168
|
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
|
package/src/ui/page-handler.js
CHANGED
|
@@ -149,6 +149,15 @@ async function handleGenericEntityView(user, entityName, id, req) {
|
|
|
149
149
|
resolvedItem = { ...resolvedItem, total_hours: totalHours, billable_amount: billableAmount };
|
|
150
150
|
} catch { resolvedItem = { ...resolvedItem, total_hours: 0, billable_amount: 0 }; }
|
|
151
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 { daysUntilExpiry } = await import('@/lib/contract-expiry.js');
|
|
159
|
+
resolvedItem = { ...resolvedItem, days_until_expiry: daysUntilExpiry(resolvedItem.end_date) };
|
|
160
|
+
}
|
|
152
161
|
// get(...,{user}) above already enforced row/org access for this exact
|
|
153
162
|
// record (a denied/absent record returns null before this point), so
|
|
154
163
|
// fetching its audit trail here is scoped by construction -- there is no
|