thatcher 1.0.77 → 1.0.78

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.77",
3
+ "version": "1.0.78",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -253,6 +253,27 @@ function withTimeTrackingDefaults(masterConfig) {
253
253
  return changed ? { ...masterConfig, entities } : masterConfig;
254
254
  }
255
255
 
256
+ const RESOURCE_ALLOCATION_ENTITY_DEFAULT = {
257
+ label: 'Resource Allocation',
258
+ label_plural: 'Resource Allocations',
259
+ system_entity: true,
260
+ fields: {
261
+ user_id: { type: 'ref', ref: 'user', required: true, label: 'User' },
262
+ project_id: { type: 'ref', ref: 'project', required: true, label: 'Project' },
263
+ allocated_hours_per_week: { type: 'number', required: true, min: 0, max: 168, label: 'Allocated Hours/Week' },
264
+ start_date: { type: 'date', required: true, label: 'Start Date' },
265
+ end_date: { type: 'date', required: true, label: 'End Date' },
266
+ role: { type: 'text', label: 'Role' },
267
+ },
268
+ };
269
+
270
+ function withResourceManagementDefaults(masterConfig) {
271
+ const entities = { ...(masterConfig.entities || {}) };
272
+ let changed = false;
273
+ if (!entities.resource_allocation) { entities.resource_allocation = RESOURCE_ALLOCATION_ENTITY_DEFAULT; changed = true; }
274
+ return changed ? { ...masterConfig, entities } : masterConfig;
275
+ }
276
+
256
277
  const CONTRACT_LIFECYCLE_WORKFLOW = {
257
278
  state_field: 'status',
258
279
  stages: [
@@ -295,7 +316,7 @@ function withContractDefaults(masterConfig) {
295
316
  export class ConfigGeneratorEngine {
296
317
  constructor(masterConfig) {
297
318
  if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
298
- this.masterConfig = deepFreeze(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))))));
319
+ this.masterConfig = deepFreeze(withResourceManagementDefaults(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))))))));
299
320
  this.specCache = new LRUCache(100);
300
321
  this.debugMode = false;
301
322
  this._plugins = new Map();
@@ -157,17 +157,30 @@ function resolveEnumOptions(fieldDef, entityName) {
157
157
  // UI-side, but server-side is the authoritative check per this session's SEC
158
158
  // pattern. Privileged roles (partner/manager) may log time for anyone, e.g.
159
159
  // entering a team member's hours on their behalf.
160
- function checkTimeEntryOwnership(entityName, data, options) {
161
- if (entityName !== 'time_entry') return null;
160
+ // Shared self-only check: a non-privileged actingUser may only write a
161
+ // record whose user_id matches their own id; partner/admin/manager may write
162
+ // for anyone. One function so every self-only entity enforces identically
163
+ // rather than each growing its own slightly-different copy over time.
164
+ function checkSelfOnlyOwnership(data, options, actionDescription) {
162
165
  const actingUser = options?.actingUser;
163
166
  if (!actingUser) return null;
164
167
  if (['partner', 'admin', 'manager'].includes(actingUser.role)) return null;
165
168
  if (data.user_id !== undefined && data.user_id !== actingUser.id) {
166
- return `Cannot log time for another user`;
169
+ return `Cannot ${actionDescription} for another user`;
167
170
  }
168
171
  return null;
169
172
  }
170
173
 
174
+ function checkTimeEntryOwnership(entityName, data, options) {
175
+ if (entityName !== 'time_entry') return null;
176
+ return checkSelfOnlyOwnership(data, options, 'log time');
177
+ }
178
+
179
+ function checkResourceAllocationOwnership(entityName, data, options) {
180
+ if (entityName !== 'resource_allocation') return null;
181
+ return checkSelfOnlyOwnership(data, options, 'create a resource allocation');
182
+ }
183
+
171
184
  // Stock movements are immutable history (checked at creation only, never on
172
185
  // edit -- there is no update path for them). An outbound movement (negative
173
186
  // quantity) that would drive the running balance below zero is invalid: the
@@ -205,6 +218,40 @@ function checkContractDateOrder(entityName, data, existingRecord) {
205
218
  return null;
206
219
  }
207
220
 
221
+ const WEEKLY_CAPACITY_HOURS = 40;
222
+
223
+ // Two date ranges overlap unless one entirely precedes the other -- the
224
+ // standard interval-intersection test, not a same-day/exact-match check.
225
+ function rangesOverlap(startA, endA, startB, endB) {
226
+ return Number(startA) <= Number(endB) && Number(startB) <= Number(endA);
227
+ }
228
+
229
+ // A user's total weekly allocation across every project they're assigned to,
230
+ // for any date range that overlaps the new/changed allocation, must not
231
+ // exceed a configurable weekly capacity. Checked against EVERY OTHER
232
+ // existing allocation for that user (excluding the record being updated, if
233
+ // any) plus the new one -- never trusting a client-supplied total.
234
+ async function checkResourceCapacity(entityName, data, existingRecord) {
235
+ if (entityName !== 'resource_allocation') return null;
236
+ const userId = data.user_id !== undefined ? data.user_id : existingRecord?.user_id;
237
+ const startDate = data.start_date !== undefined ? data.start_date : existingRecord?.start_date;
238
+ const endDate = data.end_date !== undefined ? data.end_date : existingRecord?.end_date;
239
+ const hours = Number(data.allocated_hours_per_week !== undefined ? data.allocated_hours_per_week : existingRecord?.allocated_hours_per_week);
240
+ if (!userId || startDate == null || endDate == null || !Number.isFinite(hours)) return null;
241
+
242
+ const { list } = await import('@/lib/busybase/store');
243
+ const existingAllocations = await list('resource_allocation', { user_id: userId });
244
+ const overlapping = existingAllocations.filter(a =>
245
+ a.id !== existingRecord?.id && rangesOverlap(startDate, endDate, a.start_date, a.end_date)
246
+ );
247
+ const overlappingTotal = overlapping.reduce((sum, a) => sum + (Number(a.allocated_hours_per_week) || 0), 0);
248
+ const resultingTotal = overlappingTotal + hours;
249
+ if (resultingTotal > WEEKLY_CAPACITY_HOURS) {
250
+ return `Over-allocated: this would bring the user's weekly total to ${resultingTotal}h against overlapping allocations, exceeding the ${WEEKLY_CAPACITY_HOURS}h capacity`;
251
+ }
252
+ return null;
253
+ }
254
+
208
255
  export async function validateEntity(entityName, data, existingRecord = null, options = {}) {
209
256
  const spec = getSpec(entityName);
210
257
  const errors = {};
@@ -232,12 +279,15 @@ export async function validateEntity(entityName, data, existingRecord = null, op
232
279
  const stockErr = await checkStockBalance(entityName, data);
233
280
  if (stockErr) errors.quantity = stockErr;
234
281
 
235
- const ownershipErr = checkTimeEntryOwnership(entityName, data, options);
282
+ const ownershipErr = checkTimeEntryOwnership(entityName, data, options) || checkResourceAllocationOwnership(entityName, data, options);
236
283
  if (ownershipErr) errors.user_id = ownershipErr;
237
284
 
238
285
  const dateOrderErr = checkContractDateOrder(entityName, data, existingRecord);
239
286
  if (dateOrderErr) errors.end_date = dateOrderErr;
240
287
 
288
+ const capacityErr = await checkResourceCapacity(entityName, data, existingRecord);
289
+ if (capacityErr) errors.allocated_hours_per_week = capacityErr;
290
+
241
291
  return errors;
242
292
  }
243
293
 
@@ -316,6 +366,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
316
366
  const dateOrderErr = checkContractDateOrder(entityName, changes, existingRecord);
317
367
  if (dateOrderErr) errors.end_date = dateOrderErr;
318
368
 
369
+ const capacityErr = await checkResourceCapacity(entityName, changes, existingRecord);
370
+ if (capacityErr) errors.allocated_hours_per_week = capacityErr;
371
+
319
372
  return errors;
320
373
  }
321
374
 
@@ -160,9 +160,23 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
160
160
  })()
161
161
  : ''
162
162
 
163
+ // allocated_hours_per_week/weekly_capacity_hours are computed (page-handler.js
164
+ // sums resource_allocation rows for this user), same not-a-spec-field
165
+ // treatment as current_stock/total_hours above.
166
+ const utilizationRow = entityName === 'user' && typeof item.allocated_hours_per_week === 'number'
167
+ ? (() => {
168
+ const over = item.allocated_hours_per_week > item.weekly_capacity_hours
169
+ const pillCls = over ? 'pill-danger' : 'pill-success'
170
+ return `<div class="detail-row">
171
+ <span class="detail-row-label">Resource Utilization</span>
172
+ <span class="detail-row-value"><span class="pill ${pillCls}">${esc(String(item.allocated_hours_per_week))}h / ${esc(String(item.weekly_capacity_hours))}h${over ? ' (Over-allocated)' : ''}</span></span>
173
+ </div>`
174
+ })()
175
+ : ''
176
+
163
177
  const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
164
178
 
165
- const fieldRows = stockRow + timeTrackingRows + expiryRow + visibleFields.map(([k, f]) =>
179
+ const fieldRows = stockRow + timeTrackingRows + expiryRow + utilizationRow + visibleFields.map(([k, f]) =>
166
180
  `<div class="detail-row">
167
181
  <span class="detail-row-label">${esc(f.label || k)}</span>
168
182
  <span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
@@ -158,6 +158,19 @@ async function handleGenericEntityView(user, entityName, id, req) {
158
158
  const { daysUntilExpiry } = await import('@/lib/contract-expiry.js');
159
159
  resolvedItem = { ...resolvedItem, days_until_expiry: daysUntilExpiry(resolvedItem.end_date) };
160
160
  }
161
+ if (entityName === 'user') {
162
+ // Resource utilization reuses the same "sum grouped by owner" rollup
163
+ // shape as task/project total_hours above, just grouped by allocation
164
+ // rather than time entry -- no new aggregation logic, only overlapping
165
+ // (currently-active) allocations count toward the displayed total.
166
+ try {
167
+ const allocations = await list('resource_allocation', { user_id: id });
168
+ const nowTs = Math.floor(Date.now() / 1000);
169
+ const activeAllocations = allocations.filter(a => Number(a.start_date) <= nowTs && nowTs <= Number(a.end_date));
170
+ const allocatedHoursPerWeek = activeAllocations.reduce((sum, a) => sum + (Number(a.allocated_hours_per_week) || 0), 0);
171
+ resolvedItem = { ...resolvedItem, allocated_hours_per_week: allocatedHoursPerWeek, weekly_capacity_hours: 40 };
172
+ } catch { resolvedItem = { ...resolvedItem, allocated_hours_per_week: 0, weekly_capacity_hours: 40 }; }
173
+ }
161
174
  // get(...,{user}) above already enforced row/org access for this exact
162
175
  // record (a denied/absent record returns null before this point), so
163
176
  // fetching its audit trail here is scoped by construction -- there is no