thatcher 1.0.72 → 1.0.74

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.72",
3
+ "version": "1.0.74",
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",
@@ -231,10 +231,32 @@ 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
+
234
256
  export class ConfigGeneratorEngine {
235
257
  constructor(masterConfig) {
236
258
  if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
237
- this.masterConfig = deepFreeze(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))));
259
+ this.masterConfig = deepFreeze(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))))));
238
260
  this.specCache = new LRUCache(100);
239
261
  this.debugMode = false;
240
262
  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,7 @@ async function checkStockBalance(entityName, data) {
174
190
  return null;
175
191
  }
176
192
 
177
- export async function validateEntity(entityName, data, existingRecord = null) {
193
+ export async function validateEntity(entityName, data, existingRecord = null, options = {}) {
178
194
  const spec = getSpec(entityName);
179
195
  const errors = {};
180
196
 
@@ -201,6 +217,9 @@ export async function validateEntity(entityName, data, existingRecord = null) {
201
217
  const stockErr = await checkStockBalance(entityName, data);
202
218
  if (stockErr) errors.quantity = stockErr;
203
219
 
220
+ const ownershipErr = checkTimeEntryOwnership(entityName, data, options);
221
+ if (ownershipErr) errors.user_id = ownershipErr;
222
+
204
223
  return errors;
205
224
  }
206
225
 
@@ -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,17 @@ 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
+
141
149
  const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
142
150
 
143
- const fieldRows = stockRow + visibleFields.map(([k, f]) =>
151
+ const fieldRows = stockRow + timeTrackingRows + visibleFields.map(([k, f]) =>
144
152
  `<div class="detail-row">
145
153
  <span class="detail-row-label">${esc(f.label || k)}</span>
146
154
  <span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
@@ -9,7 +9,7 @@ import { renderEngagementGrid } from '@/ui/engagement-grid-renderer.js';
9
9
  import { renderBoardView } from '@/ui/board-view-renderer.js';
10
10
  import { renderGridView } from '@/ui/grid-view-renderer.js';
11
11
  import { renderCalendarView, renderTimelineView } from '@/ui/calendar-view-renderer.js';
12
- import { renderCountByFieldReport, renderCountOverTimeReport } from '@/ui/report-renderer.js';
12
+ import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport } from '@/ui/report-renderer.js';
13
13
  import { renderClientProgress } from '@/ui/client-progress-renderer.js';
14
14
  import { renderLetterWorkflow } from '@/ui/letter-workflow-renderer.js';
15
15
  import { renderAdvancedSearch } from '@/ui/advanced-search-renderer.js';
@@ -125,6 +125,30 @@ 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
+ }
128
152
  // get(...,{user}) above already enforced row/org access for this exact
129
153
  // record (a denied/absent record returns null before this point), so
130
154
  // fetching its audit trail here is scoped by construction -- there is no
@@ -303,6 +327,27 @@ export async function handlePage(pathname, req, res) {
303
327
  const granularity = params.get('granularity') || 'month';
304
328
  return renderCountOverTimeReport(user, entityName, spec, items, dateField, granularity);
305
329
  }
330
+ if (report === 'sum-by-field') {
331
+ const field = params.get('field') || '';
332
+ const groupBy = params.get('group_by') || '';
333
+ return renderSumByFieldReport(user, entityName, spec, items, field, groupBy);
334
+ }
335
+ if (report === 'rollup') {
336
+ const refField = params.get('ref_field') || '';
337
+ const rollupField = params.get('rollup_field') || '';
338
+ // The rollup groups by a field on the RELATED entity (refField.ref), so
339
+ // that entity's list access must be checked the same way viewing it
340
+ // directly would be -- a cross-entity report must not become a side
341
+ // channel for reading data the user couldn't otherwise view.
342
+ const refFieldDef = spec.fields?.[refField];
343
+ const relatedEntity = refFieldDef?.ref;
344
+ if (!relatedEntity || !canList(user, relatedEntity)) {
345
+ return renderAccessDenied(user, relatedEntity || entityName, 'view');
346
+ }
347
+ const relatedSpec = getSpec(relatedEntity);
348
+ const relatedRecords = await list(relatedEntity, {}, { user });
349
+ return renderRollupReport(user, entityName, spec, items, refField, relatedEntity, relatedSpec, relatedRecords, rollupField);
350
+ }
306
351
  const view = params.get('view');
307
352
  if (view === 'board') return renderBoardView(user, entityName, spec, items);
308
353
  if (view === 'grid') return renderGridView(user, entityName, spec, items);
@@ -89,6 +89,101 @@ export function renderCountByFieldReport(user, entityName, spec, records, field)
89
89
  return page(user, `${label} Report | Thatcher`, null, content);
90
90
  }
91
91
 
92
+ export function sumByField(records, spec, field, groupBy) {
93
+ const sums = new Map();
94
+ for (const r of records) {
95
+ const raw = r[field];
96
+ const num = typeof raw === 'string' ? Number(raw) : raw;
97
+ if (typeof num !== 'number' || isNaN(num)) continue;
98
+ const label = bucketLabel(spec, groupBy, r[groupBy]);
99
+ sums.set(label, (sums.get(label) || 0) + num);
100
+ }
101
+ const sorted = [...sums.entries()].sort((a, b) => b[1] - a[1]);
102
+ if (sorted.length <= MAX_BUCKETS) return sorted;
103
+ const top = sorted.slice(0, MAX_BUCKETS - 1);
104
+ const otherSum = sorted.slice(MAX_BUCKETS - 1).reduce((sum, [, v]) => sum + v, 0);
105
+ return [...top, ['(other)', otherSum]];
106
+ }
107
+
108
+ function formatSumValue(value, fieldDef) {
109
+ if (fieldDef?.type === 'currency') return (fieldDef.currency_symbol || '$') + (value / 100).toFixed(2);
110
+ return String(Math.round(value * 100) / 100);
111
+ }
112
+
113
+ function sumBarChart(buckets, fieldDef) {
114
+ if (!buckets.length) return emptyState('No data to report on', 'bar-chart');
115
+ const max = Math.max(...buckets.map(([, v]) => Math.abs(v)), 1);
116
+ const rows = buckets.map(([label, value]) => {
117
+ const pct = Math.round((Math.abs(value) / max) * 100);
118
+ return `<div class="report-bar-row" style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
119
+ <div style="min-width:140px;max-width:220px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(label)}">${esc(label)}</div>
120
+ <div style="flex:1;height:20px;background:var(--color-border,#eee);border-radius:4px;overflow:hidden">
121
+ <div style="height:100%;width:${pct}%;background:var(--color-primary,#3b82f6);border-radius:4px"></div>
122
+ </div>
123
+ <div style="min-width:80px;text-align:right;font-size:13px;font-weight:600">${esc(formatSumValue(value, fieldDef))}</div>
124
+ </div>`;
125
+ }).join('');
126
+ return `<div class="report-bar-chart">${rows}</div>`;
127
+ }
128
+
129
+ export function renderSumByFieldReport(user, entityName, spec, records, field, groupBy) {
130
+ const label = getEntityLabel(spec, true) || entityName;
131
+ const fieldDef = spec.fields?.[field];
132
+ const groupByDef = spec.fields?.[groupBy];
133
+ if (!fieldDef || !groupByDef) {
134
+ return page(user, `${label} | Report`, null,
135
+ `<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
136
+ <div class="report-empty-state">Unknown field "${esc(!fieldDef ? field : groupBy)}" for this entity.</div>`);
137
+ }
138
+ const buckets = sumByField(records, spec, field, groupBy);
139
+ const content = `<div class="page-header">
140
+ <div><h1 class="page-title">${esc(label)}: ${esc(fieldDef.label || field)} by ${esc(groupByDef.label || groupBy)}</h1><p class="page-subtitle">${records.length} total records</p></div>
141
+ </div>
142
+ ${sumBarChart(buckets, fieldDef)}`;
143
+ return page(user, `${label} Report | Thatcher`, null, content);
144
+ }
145
+
146
+ // Cross-entity rollup: entity A's records are grouped by a field on related
147
+ // entity B, joined through A's ref field. relatedRecords must already be the
148
+ // CALLER's row/org-scoped list() result for B -- this function only joins
149
+ // and counts, it enforces no access itself (the access decision -- can this
150
+ // user list B at all -- is made by the caller before relatedRecords exists).
151
+ export function rollupByRelatedField(records, refField, relatedRecords, relatedSpec, rollupField) {
152
+ const relatedById = new Map(relatedRecords.map(r => [String(r.id), r]));
153
+ const counts = new Map();
154
+ let unmatched = 0;
155
+ for (const r of records) {
156
+ const refId = r[refField];
157
+ const related = refId != null ? relatedById.get(String(refId)) : null;
158
+ if (!related) { unmatched++; continue; }
159
+ const label = bucketLabel(relatedSpec, rollupField, related[rollupField]);
160
+ counts.set(label, (counts.get(label) || 0) + 1);
161
+ }
162
+ const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
163
+ return { buckets: sorted.length > MAX_BUCKETS ? sorted.slice(0, MAX_BUCKETS) : sorted, unmatched };
164
+ }
165
+
166
+ export function renderRollupReport(user, entityName, spec, records, refField, relatedEntity, relatedSpec, relatedRecords, rollupField) {
167
+ const label = getEntityLabel(spec, true) || entityName;
168
+ const relatedLabel = getEntityLabel(relatedSpec, true) || relatedEntity;
169
+ const rollupFieldDef = relatedSpec?.fields?.[rollupField];
170
+ if (!spec.fields?.[refField] || !rollupFieldDef) {
171
+ return page(user, `${label} | Report`, null,
172
+ `<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
173
+ <div class="report-empty-state">Unknown field for this rollup.</div>`);
174
+ }
175
+ const { buckets, unmatched } = rollupByRelatedField(records, refField, relatedRecords, relatedSpec, rollupField);
176
+ const notice = unmatched
177
+ ? `<div class="report-notice">${unmatched} record${unmatched === 1 ? '' : 's'} without a resolvable ${esc(relatedLabel)} excluded</div>`
178
+ : '';
179
+ const content = `<div class="page-header">
180
+ <div><h1 class="page-title">${esc(label)} by ${esc(relatedLabel)}.${esc(rollupFieldDef.label || rollupField)}</h1><p class="page-subtitle">${records.length} total records</p></div>
181
+ </div>
182
+ ${notice}
183
+ ${barChart(buckets)}`;
184
+ return page(user, `${label} Report | Thatcher`, null, content);
185
+ }
186
+
92
187
  export function renderCountOverTimeReport(user, entityName, spec, records, dateField, granularity) {
93
188
  const label = getEntityLabel(spec, true) || entityName;
94
189
  const fieldDef = spec.fields?.[dateField];