@happyvertical/smrt-timesheets 0.56.0 → 0.56.2
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/AGENTS.md +34 -0
- package/README.md +34 -1
- package/dist/attendance.js +2 -2
- package/dist/chunks/{attendance-DGVy-iHI.js → attendance-CDfhph1R.js} +3 -3
- package/dist/chunks/{attendance-DGVy-iHI.js.map → attendance-CDfhph1R.js.map} +1 -1
- package/dist/chunks/{attendance-B1jCbqSb.js → attendance-mk6nqJhX.js} +3 -3
- package/dist/chunks/{attendance-B1jCbqSb.js.map → attendance-mk6nqJhX.js.map} +1 -1
- package/dist/chunks/{models-0-YD5F_o.js → models-C1krjn40.js} +2 -2
- package/dist/chunks/{models-0-YD5F_o.js.map → models-C1krjn40.js.map} +1 -1
- package/dist/chunks/{rollup-CDqzCxI2.js → rollup-C8p1PAXX.js} +3 -3
- package/dist/chunks/{rollup-CDqzCxI2.js.map → rollup-C8p1PAXX.js.map} +1 -1
- package/dist/chunks/service-time-entry-Cxh0pt0J.js +286 -0
- package/dist/chunks/service-time-entry-Cxh0pt0J.js.map +1 -0
- package/dist/chunks/time-entry-actions-Bp--TxUD.js +576 -0
- package/dist/chunks/time-entry-actions-Bp--TxUD.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +113 -334
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +373 -17
- package/dist/models/service-snapshots.d.ts.map +1 -1
- package/dist/models/service-time-entry.d.ts +36 -1
- package/dist/models/service-time-entry.d.ts.map +1 -1
- package/dist/recipes.d.ts +124 -0
- package/dist/recipes.d.ts.map +1 -0
- package/dist/rollup.js +1 -1
- package/dist/services/action-principal.d.ts +54 -0
- package/dist/services/action-principal.d.ts.map +1 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/time-entry-actions.d.ts +83 -0
- package/dist/services/time-entry-actions.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +230 -34
- package/package.json +8 -7
- package/dist/chunks/service-time-entry-t7P-bkOQ.js +0 -227
- package/dist/chunks/service-time-entry-t7P-bkOQ.js.map +0 -1
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { a as validateDurationHours, i as ServiceTimeEntryCollection, t as SERVICE_DURATION_HOURS_EVIDENCE } from "./service-time-entry-Cxh0pt0J.js";
|
|
2
|
+
import { SmrtCollection, SmrtObject, ValidationError, field, foreignKey, smrt } from "@happyvertical/smrt-core";
|
|
3
|
+
import { TenantScoped, getCurrentTenant, tenantId, withTenant } from "@happyvertical/smrt-tenancy";
|
|
4
|
+
import { OperationPermissionError, UserCollection, assertOperationPermission, getCurrentSessionPermissionContext } from "@happyvertical/smrt-users";
|
|
5
|
+
//#region \0rolldown/runtime.js
|
|
6
|
+
var __defProp$1 = Object.defineProperty;
|
|
7
|
+
var __exportAll = (all, no_symbols) => {
|
|
8
|
+
let target = {};
|
|
9
|
+
for (var name in all) __defProp$1(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true
|
|
12
|
+
});
|
|
13
|
+
if (!no_symbols) __defProp$1(target, Symbol.toStringTag, { value: "Module" });
|
|
14
|
+
return target;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/models/service-snapshots.ts
|
|
18
|
+
var __defProp = Object.defineProperty;
|
|
19
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
20
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
|
|
21
|
+
enumerable: true,
|
|
22
|
+
configurable: true,
|
|
23
|
+
writable: true,
|
|
24
|
+
value
|
|
25
|
+
}) : obj[key] = value;
|
|
26
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
27
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
28
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
29
|
+
if (kind && result) __defProp(target, key, result);
|
|
30
|
+
return result;
|
|
31
|
+
};
|
|
32
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
33
|
+
function assertSnapshotMinorUnits(model, amount) {
|
|
34
|
+
if (typeof amount !== "number" || !Number.isSafeInteger(amount)) throw new ValidationError(`${model}.amount must be a safe integer number of minor units (cents) \u2014 got ${String(amount)}. $19.99 is 1999.`, "VALIDATION_INVALID_VALUE", {
|
|
35
|
+
fieldName: "amount",
|
|
36
|
+
value: amount,
|
|
37
|
+
expectedType: "safe integer minor units"
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
var chargeSnapshot = /* @__PURE__ */ new WeakMap();
|
|
41
|
+
var compensationSnapshot = /* @__PURE__ */ new WeakMap();
|
|
42
|
+
async function immutableCommercialSnapshot(object, fields) {
|
|
43
|
+
if (!object.id) return null;
|
|
44
|
+
try {
|
|
45
|
+
const row = await object.db.get(object.tableName, { id: object.id });
|
|
46
|
+
if (!row) return null;
|
|
47
|
+
return JSON.stringify(Object.fromEntries(fields.map((key) => [key, row[key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)] ?? null])));
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function commercialSnapshot(object, fields) {
|
|
53
|
+
return JSON.stringify(Object.fromEntries(fields.map((key) => [key, object[key] ?? null])));
|
|
54
|
+
}
|
|
55
|
+
var CHARGE_FIELDS = [
|
|
56
|
+
"timeEntryId",
|
|
57
|
+
"amount",
|
|
58
|
+
"currency",
|
|
59
|
+
"pricingVersion",
|
|
60
|
+
"strategy",
|
|
61
|
+
"rateSnapshot",
|
|
62
|
+
"sourceChargeRef"
|
|
63
|
+
];
|
|
64
|
+
var COMPENSATION_FIELDS = [
|
|
65
|
+
"timeEntryId",
|
|
66
|
+
"amount",
|
|
67
|
+
"currency",
|
|
68
|
+
"termsVersion",
|
|
69
|
+
"rateSnapshot"
|
|
70
|
+
];
|
|
71
|
+
var ServiceChargeSnapshot = class extends SmrtObject {
|
|
72
|
+
tenantId = null;
|
|
73
|
+
timeEntryId = "";
|
|
74
|
+
/**
|
|
75
|
+
* Client charge in **integer minor units** of {@link currency} — `$19.99` is
|
|
76
|
+
* `1999`. The `= 0` initializer is load-bearing: SMRT maps an integer literal
|
|
77
|
+
* to INTEGER and a decimal literal to DECIMAL (#2401).
|
|
78
|
+
*
|
|
79
|
+
* Copied verbatim from the upstream `ClientCharge.amount`, so the two carry
|
|
80
|
+
* the same unit by construction; a split would land cents in a major-units
|
|
81
|
+
* column with no error anywhere.
|
|
82
|
+
*/
|
|
83
|
+
amount = 0;
|
|
84
|
+
currency = "USD";
|
|
85
|
+
pricingVersion = "";
|
|
86
|
+
strategy = "";
|
|
87
|
+
rateSnapshot = "{}";
|
|
88
|
+
sourceChargeRef = "";
|
|
89
|
+
createdAt = /* @__PURE__ */ new Date();
|
|
90
|
+
async initialize() {
|
|
91
|
+
await super.initialize();
|
|
92
|
+
if (await this.isSaved()) chargeSnapshot.set(this, commercialSnapshot(this, CHARGE_FIELDS));
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
async save() {
|
|
96
|
+
assertSnapshotMinorUnits("ServiceChargeSnapshot", this.amount);
|
|
97
|
+
const current = commercialSnapshot(this, CHARGE_FIELDS);
|
|
98
|
+
const prior = await immutableCommercialSnapshot(this, CHARGE_FIELDS) ?? chargeSnapshot.get(this) ?? null;
|
|
99
|
+
if (prior && prior !== current) throw new Error("ServiceChargeSnapshot is immutable; append an upstream adjustment instead.");
|
|
100
|
+
const result = await super.save();
|
|
101
|
+
chargeSnapshot.set(this, current);
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
__publicField(ServiceChargeSnapshot, "__smrtPackage__", "@happyvertical/smrt-timesheets");
|
|
106
|
+
__decorateClass([tenantId({ nullable: true })], ServiceChargeSnapshot.prototype, "tenantId", 2);
|
|
107
|
+
__decorateClass([foreignKey("ServiceTimeEntry", { required: true })], ServiceChargeSnapshot.prototype, "timeEntryId", 2);
|
|
108
|
+
__decorateClass([field({ type: "text" })], ServiceChargeSnapshot.prototype, "currency", 2);
|
|
109
|
+
__decorateClass([field({ type: "text" })], ServiceChargeSnapshot.prototype, "pricingVersion", 2);
|
|
110
|
+
__decorateClass([field({ type: "text" })], ServiceChargeSnapshot.prototype, "strategy", 2);
|
|
111
|
+
__decorateClass([field({ type: "text" })], ServiceChargeSnapshot.prototype, "rateSnapshot", 2);
|
|
112
|
+
__decorateClass([field({ type: "text" })], ServiceChargeSnapshot.prototype, "sourceChargeRef", 2);
|
|
113
|
+
ServiceChargeSnapshot = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
|
|
114
|
+
tableName: "service_charge_snapshots",
|
|
115
|
+
previousQualifiedNames: ["@happyvertical/smrt-projects:ServiceChargeSnapshot"],
|
|
116
|
+
conflictColumns: ["time_entry_id"],
|
|
117
|
+
api: { include: ["list", "get"] },
|
|
118
|
+
cli: { include: ["list", "get"] },
|
|
119
|
+
mcp: { include: ["list", "get"] }
|
|
120
|
+
})], ServiceChargeSnapshot);
|
|
121
|
+
var ServiceCompensationSnapshot = class extends SmrtObject {
|
|
122
|
+
tenantId = null;
|
|
123
|
+
timeEntryId = "";
|
|
124
|
+
/**
|
|
125
|
+
* Provider earning in **integer minor units** of {@link currency} (#2401).
|
|
126
|
+
*
|
|
127
|
+
* Converts with {@link ServiceChargeSnapshot.amount}: the delivery margin is
|
|
128
|
+
* `charge - compensation`, so a mixed pair would make that subtraction
|
|
129
|
+
* meaningless.
|
|
130
|
+
*/
|
|
131
|
+
amount = 0;
|
|
132
|
+
currency = "USD";
|
|
133
|
+
termsVersion = "";
|
|
134
|
+
rateSnapshot = "{}";
|
|
135
|
+
createdAt = /* @__PURE__ */ new Date();
|
|
136
|
+
async initialize() {
|
|
137
|
+
await super.initialize();
|
|
138
|
+
if (await this.isSaved()) compensationSnapshot.set(this, commercialSnapshot(this, COMPENSATION_FIELDS));
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
async save() {
|
|
142
|
+
assertSnapshotMinorUnits("ServiceCompensationSnapshot", this.amount);
|
|
143
|
+
const current = commercialSnapshot(this, COMPENSATION_FIELDS);
|
|
144
|
+
const prior = await immutableCommercialSnapshot(this, COMPENSATION_FIELDS) ?? compensationSnapshot.get(this) ?? null;
|
|
145
|
+
if (prior && prior !== current) throw new Error("ServiceCompensationSnapshot is immutable; create explicit settlement evidence instead.");
|
|
146
|
+
const result = await super.save();
|
|
147
|
+
compensationSnapshot.set(this, current);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
__publicField(ServiceCompensationSnapshot, "__smrtPackage__", "@happyvertical/smrt-timesheets");
|
|
152
|
+
__decorateClass([tenantId({ nullable: true })], ServiceCompensationSnapshot.prototype, "tenantId", 2);
|
|
153
|
+
__decorateClass([foreignKey("ServiceTimeEntry", { required: true })], ServiceCompensationSnapshot.prototype, "timeEntryId", 2);
|
|
154
|
+
__decorateClass([field({ type: "text" })], ServiceCompensationSnapshot.prototype, "currency", 2);
|
|
155
|
+
__decorateClass([field({ type: "text" })], ServiceCompensationSnapshot.prototype, "termsVersion", 2);
|
|
156
|
+
__decorateClass([field({ type: "text" })], ServiceCompensationSnapshot.prototype, "rateSnapshot", 2);
|
|
157
|
+
ServiceCompensationSnapshot = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
|
|
158
|
+
tableName: "service_compensation_snapshots",
|
|
159
|
+
previousQualifiedNames: ["@happyvertical/smrt-projects:ServiceCompensationSnapshot"],
|
|
160
|
+
conflictColumns: ["time_entry_id"],
|
|
161
|
+
api: { include: ["list", "get"] },
|
|
162
|
+
cli: { include: ["list", "get"] },
|
|
163
|
+
mcp: { include: ["list", "get"] }
|
|
164
|
+
})], ServiceCompensationSnapshot);
|
|
165
|
+
var ServiceChargeSnapshotCollection = class extends SmrtCollection {};
|
|
166
|
+
__publicField(ServiceChargeSnapshotCollection, "__smrtPackage__", "@happyvertical/smrt-timesheets");
|
|
167
|
+
__publicField(ServiceChargeSnapshotCollection, "_itemClass", ServiceChargeSnapshot);
|
|
168
|
+
ServiceChargeSnapshotCollection = __decorateClass([smrt({
|
|
169
|
+
mcp: { include: ["list", "get"] },
|
|
170
|
+
cli: false
|
|
171
|
+
})], ServiceChargeSnapshotCollection);
|
|
172
|
+
var ServiceCompensationSnapshotCollection = class extends SmrtCollection {};
|
|
173
|
+
__publicField(ServiceCompensationSnapshotCollection, "__smrtPackage__", "@happyvertical/smrt-timesheets");
|
|
174
|
+
__publicField(ServiceCompensationSnapshotCollection, "_itemClass", ServiceCompensationSnapshot);
|
|
175
|
+
ServiceCompensationSnapshotCollection = __decorateClass([smrt({
|
|
176
|
+
mcp: { include: ["list", "get"] },
|
|
177
|
+
cli: false
|
|
178
|
+
})], ServiceCompensationSnapshotCollection);
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/services/action-principal.ts
|
|
181
|
+
var TimesheetActionError = class extends Error {
|
|
182
|
+
status;
|
|
183
|
+
code;
|
|
184
|
+
publicMessage;
|
|
185
|
+
constructor(status, code, message) {
|
|
186
|
+
super(message);
|
|
187
|
+
this.name = "TimesheetActionError";
|
|
188
|
+
this.status = status;
|
|
189
|
+
this.code = code;
|
|
190
|
+
this.publicMessage = message;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
function profileIdOf(user) {
|
|
194
|
+
if (!user || typeof user !== "object") return null;
|
|
195
|
+
const value = user.profileId;
|
|
196
|
+
return typeof value === "string" && value ? value : null;
|
|
197
|
+
}
|
|
198
|
+
function ambientPrincipal() {
|
|
199
|
+
const tenant = getCurrentTenant();
|
|
200
|
+
const session = getCurrentSessionPermissionContext();
|
|
201
|
+
if (tenant?.tenantId) {
|
|
202
|
+
const sessionProfile = session?.userId && session.userId === tenant.userId ? profileIdOf(session.user) : null;
|
|
203
|
+
return {
|
|
204
|
+
tenantId: tenant.tenantId,
|
|
205
|
+
userId: tenant.userId ?? null,
|
|
206
|
+
permissions: tenant.permissions,
|
|
207
|
+
profileId: profileIdOf(tenant.user) ?? sessionProfile,
|
|
208
|
+
fromTenancy: true
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
tenantId: session?.tenantId ?? null,
|
|
213
|
+
userId: session?.userId ?? null,
|
|
214
|
+
permissions: session?.permissionSet ?? /* @__PURE__ */ new Set(),
|
|
215
|
+
profileId: profileIdOf(session?.user),
|
|
216
|
+
fromTenancy: false
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async function loadProfileId(userId, options) {
|
|
220
|
+
try {
|
|
221
|
+
return profileIdOf(await (await UserCollection.create({ db: options.db ?? options.persistence })).get({ id: userId }));
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function principalHolds(collection, action, options) {
|
|
227
|
+
const ambient = ambientPrincipal();
|
|
228
|
+
try {
|
|
229
|
+
await assertOperationPermission({
|
|
230
|
+
collection,
|
|
231
|
+
action,
|
|
232
|
+
db: options.db ?? options.persistence,
|
|
233
|
+
tenantId: ambient.tenantId,
|
|
234
|
+
userId: ambient.userId,
|
|
235
|
+
permissionSet: ambient.permissions,
|
|
236
|
+
allowSystemContextBypass: false,
|
|
237
|
+
allowSuperAdminBypass: false
|
|
238
|
+
});
|
|
239
|
+
return true;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error instanceof OperationPermissionError) return false;
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function authorizeTimesheetAction(collection, action, options, guard) {
|
|
246
|
+
const ambient = ambientPrincipal();
|
|
247
|
+
try {
|
|
248
|
+
await assertOperationPermission({
|
|
249
|
+
collection,
|
|
250
|
+
action,
|
|
251
|
+
db: options.db ?? options.persistence,
|
|
252
|
+
tenantId: ambient.tenantId,
|
|
253
|
+
userId: ambient.userId,
|
|
254
|
+
permissionSet: ambient.permissions,
|
|
255
|
+
allowSystemContextBypass: false,
|
|
256
|
+
allowSuperAdminBypass: guard.allowSuperAdminBypass
|
|
257
|
+
});
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error instanceof OperationPermissionError) throw new TimesheetActionError(403, "permission_denied", error.message);
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
if (!ambient.tenantId || !ambient.userId) throw new TimesheetActionError(403, "permission_denied", "Time entry actions need an authenticated tenant context.");
|
|
263
|
+
const profileId = ambient.profileId ?? await loadProfileId(ambient.userId, options);
|
|
264
|
+
if (!profileId) throw new TimesheetActionError(403, "actor_profile_required", "Time entry actions need a user with a linked profile.");
|
|
265
|
+
return {
|
|
266
|
+
tenantId: ambient.tenantId,
|
|
267
|
+
userId: ambient.userId,
|
|
268
|
+
permissions: ambient.permissions,
|
|
269
|
+
profileId,
|
|
270
|
+
fromTenancy: ambient.fromTenancy
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
async function withActionPrincipal(principal, fn) {
|
|
274
|
+
if (principal.fromTenancy) return fn();
|
|
275
|
+
return withTenant({
|
|
276
|
+
tenantId: principal.tenantId,
|
|
277
|
+
userId: principal.userId,
|
|
278
|
+
permissions: new Set(principal.permissions)
|
|
279
|
+
}, fn);
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/services/service-evidence-service.ts
|
|
283
|
+
var ServiceEvidenceService = class ServiceEvidenceService {
|
|
284
|
+
constructor(entries, charges, compensation, commercial) {
|
|
285
|
+
this.entries = entries;
|
|
286
|
+
this.charges = charges;
|
|
287
|
+
this.compensation = compensation;
|
|
288
|
+
this.commercial = commercial;
|
|
289
|
+
}
|
|
290
|
+
entries;
|
|
291
|
+
charges;
|
|
292
|
+
compensation;
|
|
293
|
+
commercial;
|
|
294
|
+
static async create(options = {}, commercial) {
|
|
295
|
+
const [entries, charges, compensation] = await Promise.all([
|
|
296
|
+
ServiceTimeEntryCollection.create(options),
|
|
297
|
+
ServiceChargeSnapshotCollection.create(options),
|
|
298
|
+
ServiceCompensationSnapshotCollection.create(options)
|
|
299
|
+
]);
|
|
300
|
+
return new ServiceEvidenceService(entries, charges, compensation, commercial);
|
|
301
|
+
}
|
|
302
|
+
async record(input) {
|
|
303
|
+
if (![
|
|
304
|
+
"timer",
|
|
305
|
+
"manual",
|
|
306
|
+
"import",
|
|
307
|
+
"agent"
|
|
308
|
+
].includes(input.source)) throw new Error("Unsupported service time source.");
|
|
309
|
+
if (Boolean(input.workRefType) !== Boolean(input.workRefId)) throw new Error("workRefType and workRefId must be provided together.");
|
|
310
|
+
if (!(input.workRefType && input.workRefId)) throw new Error("Service time requires a work reference.");
|
|
311
|
+
if (input.participantKind === "human" && !input.participantProfileId) throw new Error("Human service time requires participantProfileId.");
|
|
312
|
+
if (input.participantKind === "agent" && !input.agentRef) throw new Error("Agent service time requires agentRef.");
|
|
313
|
+
if (input.source === "timer" && (!input.startedAt || !input.endedAt)) throw new Error("Timer service time requires startedAt and endedAt.");
|
|
314
|
+
if (input.startedAt && input.endedAt && input.startedAt >= input.endedAt) throw new Error("Service time startedAt must be before endedAt.");
|
|
315
|
+
const { durationHours, ...fields } = input;
|
|
316
|
+
if (input.evidence?.some((item) => item?.kind === "@happyvertical/smrt-timesheets:duration-hours")) throw new Error("Use durationHours to supply reserved decimal-hours evidence.");
|
|
317
|
+
const hours = durationHours === void 0 ? null : validateDurationHours(durationHours);
|
|
318
|
+
if (hours !== null && (input.durationSeconds != null || input.startedAt || input.endedAt)) throw new Error("Decimal-hours-only service time cannot also supply seconds or timestamps.");
|
|
319
|
+
if (hours === null && input.durationSeconds === null) throw new Error("Null durationSeconds requires durationHours.");
|
|
320
|
+
const duration = hours !== null ? null : input.durationSeconds ?? (input.startedAt && input.endedAt ? Math.round((input.endedAt.getTime() - input.startedAt.getTime()) / 1e3) : 0);
|
|
321
|
+
if (duration !== null && (!Number.isSafeInteger(duration) || duration <= 0)) throw new Error("Service time durationSeconds must be a positive integer.");
|
|
322
|
+
const entry = await this.entries.create({
|
|
323
|
+
...fields,
|
|
324
|
+
durationSeconds: duration,
|
|
325
|
+
evidence: JSON.stringify([...input.evidence ?? [], ...hours === null ? [] : [{
|
|
326
|
+
kind: SERVICE_DURATION_HOURS_EVIDENCE,
|
|
327
|
+
hours
|
|
328
|
+
}]]),
|
|
329
|
+
metadata: JSON.stringify(input.metadata ?? {}),
|
|
330
|
+
status: "draft",
|
|
331
|
+
submittedAt: null,
|
|
332
|
+
submittedByProfileId: null,
|
|
333
|
+
approvedAt: null,
|
|
334
|
+
approvedByProfileId: null,
|
|
335
|
+
approvalPath: "",
|
|
336
|
+
rejectedAt: null,
|
|
337
|
+
rejectedByProfileId: null,
|
|
338
|
+
rejectionReason: "",
|
|
339
|
+
correctionOfId: null
|
|
340
|
+
});
|
|
341
|
+
await entry.save();
|
|
342
|
+
return entry;
|
|
343
|
+
}
|
|
344
|
+
async submit(entry, profileId) {
|
|
345
|
+
if (entry.status !== "draft" && entry.status !== "rejected") throw new Error("Only draft or rejected service time may be submitted.");
|
|
346
|
+
entry.status = "submitted";
|
|
347
|
+
entry.submittedAt = /* @__PURE__ */ new Date();
|
|
348
|
+
entry.submittedByProfileId = profileId ?? null;
|
|
349
|
+
return entry.save();
|
|
350
|
+
}
|
|
351
|
+
async approve(entry, options) {
|
|
352
|
+
if (entry.status !== "submitted" && entry.status !== "approved") throw new Error("Only submitted service time or an incomplete approval may be approved.");
|
|
353
|
+
if (!this.commercial) throw new Error("A ServiceCommercialResolver is required to approve service time.");
|
|
354
|
+
const timeEntryId = entry.id;
|
|
355
|
+
if (!timeEntryId) throw new Error("ServiceTimeEntry must be saved before approval.");
|
|
356
|
+
const existingCharge = (await this.charges.list({
|
|
357
|
+
where: { timeEntryId },
|
|
358
|
+
limit: 1
|
|
359
|
+
}))[0];
|
|
360
|
+
const existingCompensation = (await this.compensation.list({
|
|
361
|
+
where: { timeEntryId },
|
|
362
|
+
limit: 1
|
|
363
|
+
}))[0];
|
|
364
|
+
if (entry.status === "approved" && existingCharge && existingCompensation) return entry;
|
|
365
|
+
await this.commercial.validateEntry?.(entry);
|
|
366
|
+
if (entry.status === "submitted") {
|
|
367
|
+
entry.status = "approved";
|
|
368
|
+
entry.approvedAt = /* @__PURE__ */ new Date();
|
|
369
|
+
entry.approvedByProfileId = options.actorProfileId ?? null;
|
|
370
|
+
entry.approvalPath = options.approvalPath;
|
|
371
|
+
try {
|
|
372
|
+
await entry.save();
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (!(error instanceof Error && "code" in error && error.code === "RUNTIME_REVISION_CONFLICT")) throw error;
|
|
375
|
+
const concurrent = await this.entries.get(timeEntryId);
|
|
376
|
+
if (concurrent?.status !== "approved") throw error;
|
|
377
|
+
entry = concurrent;
|
|
378
|
+
await this.commercial.validateEntry?.(entry);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const provider = existingCompensation ? null : await this.commercial.compensateProvider(entry);
|
|
382
|
+
if (provider) {
|
|
383
|
+
const snapshotId = await deterministicSnapshotId("service-compensation-snapshot", timeEntryId);
|
|
384
|
+
try {
|
|
385
|
+
await this.compensation.create({
|
|
386
|
+
id: snapshotId,
|
|
387
|
+
tenantId: entry.tenantId,
|
|
388
|
+
timeEntryId,
|
|
389
|
+
amount: provider.amount,
|
|
390
|
+
currency: provider.currency ?? "USD",
|
|
391
|
+
termsVersion: provider.version,
|
|
392
|
+
rateSnapshot: JSON.stringify(provider.terms),
|
|
393
|
+
_insertOnly: true
|
|
394
|
+
});
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (!(await this.compensation.get(snapshotId) ?? (await this.compensation.list({
|
|
397
|
+
where: { timeEntryId },
|
|
398
|
+
limit: 1
|
|
399
|
+
}))[0])) throw error;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const client = existingCharge ? null : await this.commercial.priceClient(entry);
|
|
403
|
+
if (client) {
|
|
404
|
+
const snapshotId = await deterministicSnapshotId("service-charge-snapshot", timeEntryId);
|
|
405
|
+
try {
|
|
406
|
+
await this.charges.create({
|
|
407
|
+
id: snapshotId,
|
|
408
|
+
tenantId: entry.tenantId,
|
|
409
|
+
timeEntryId,
|
|
410
|
+
amount: client.amount,
|
|
411
|
+
currency: client.currency ?? "USD",
|
|
412
|
+
pricingVersion: client.version,
|
|
413
|
+
strategy: client.strategy ?? "",
|
|
414
|
+
rateSnapshot: JSON.stringify(client.terms),
|
|
415
|
+
sourceChargeRef: client.sourceRef ?? "",
|
|
416
|
+
_insertOnly: true
|
|
417
|
+
});
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (!(await this.charges.get(snapshotId) ?? (await this.charges.list({
|
|
420
|
+
where: { timeEntryId },
|
|
421
|
+
limit: 1
|
|
422
|
+
}))[0])) throw error;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return entry;
|
|
426
|
+
}
|
|
427
|
+
async correct(entry, input) {
|
|
428
|
+
if (entry.status !== "approved") throw new Error("Only approved service time may be corrected.");
|
|
429
|
+
if (input.tenantId !== void 0 && input.tenantId !== entry.tenantId) throw new Error("Service time corrections must remain in the original tenant.");
|
|
430
|
+
const correction = await this.record({
|
|
431
|
+
...input,
|
|
432
|
+
tenantId: entry.tenantId
|
|
433
|
+
});
|
|
434
|
+
correction.correctionOfId = entry.id ?? null;
|
|
435
|
+
await correction.save();
|
|
436
|
+
entry.status = "corrected";
|
|
437
|
+
await entry.save();
|
|
438
|
+
return correction;
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
async function deterministicSnapshotId(kind, timeEntryId) {
|
|
442
|
+
const bytes = new TextEncoder().encode(JSON.stringify([kind, timeEntryId]));
|
|
443
|
+
const uuid = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)).slice(0, 16);
|
|
444
|
+
uuid[6] = uuid[6] & 15 | 80;
|
|
445
|
+
uuid[8] = uuid[8] & 63 | 128;
|
|
446
|
+
const hex = Array.from(uuid, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
447
|
+
return [
|
|
448
|
+
hex.slice(0, 8),
|
|
449
|
+
hex.slice(8, 12),
|
|
450
|
+
hex.slice(12, 16),
|
|
451
|
+
hex.slice(16, 20),
|
|
452
|
+
hex.slice(20)
|
|
453
|
+
].join("-");
|
|
454
|
+
}
|
|
455
|
+
//#endregion
|
|
456
|
+
//#region src/services/time-entry-actions.ts
|
|
457
|
+
var time_entry_actions_exports = /* @__PURE__ */ __exportAll({
|
|
458
|
+
SERVICE_TIME_ENTRY_ACTION_APPROVAL_PATH: () => SERVICE_TIME_ENTRY_ACTION_APPROVAL_PATH,
|
|
459
|
+
approveServiceTimeAction: () => approveServiceTimeAction,
|
|
460
|
+
getServiceTimeEntryCommercialResolver: () => getServiceTimeEntryCommercialResolver,
|
|
461
|
+
recordServiceTimeAction: () => recordServiceTimeAction,
|
|
462
|
+
setServiceTimeEntryCommercialResolver: () => setServiceTimeEntryCommercialResolver,
|
|
463
|
+
submitServiceTimeAction: () => submitServiceTimeAction
|
|
464
|
+
});
|
|
465
|
+
var SERVICE_TIME_ENTRY_ACTION_APPROVAL_PATH = "operator";
|
|
466
|
+
var actionCommercialResolver = null;
|
|
467
|
+
function setServiceTimeEntryCommercialResolver(resolver) {
|
|
468
|
+
actionCommercialResolver = resolver;
|
|
469
|
+
}
|
|
470
|
+
function getServiceTimeEntryCommercialResolver() {
|
|
471
|
+
return actionCommercialResolver;
|
|
472
|
+
}
|
|
473
|
+
function asRecord(value) {
|
|
474
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TimesheetActionError(400, "invalid_request", "The action body must be a JSON object.");
|
|
475
|
+
return value;
|
|
476
|
+
}
|
|
477
|
+
function optionalString(body, key) {
|
|
478
|
+
const value = body[key];
|
|
479
|
+
if (value === void 0 || value === null) return void 0;
|
|
480
|
+
if (typeof value !== "string") throw new TimesheetActionError(400, "invalid_request", `${key} must be a string.`);
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
483
|
+
function requiredString(body, key) {
|
|
484
|
+
const value = optionalString(body, key)?.trim();
|
|
485
|
+
if (!value) throw new TimesheetActionError(400, "invalid_request", `${key} is required.`);
|
|
486
|
+
return value;
|
|
487
|
+
}
|
|
488
|
+
function optionalInstant(body, key) {
|
|
489
|
+
const value = optionalString(body, key);
|
|
490
|
+
if (value === void 0) return null;
|
|
491
|
+
const date = new Date(value);
|
|
492
|
+
if (Number.isNaN(date.getTime())) throw new TimesheetActionError(400, "invalid_request", `${key} must be an ISO 8601 instant.`);
|
|
493
|
+
return date;
|
|
494
|
+
}
|
|
495
|
+
async function serviceCall(fn) {
|
|
496
|
+
try {
|
|
497
|
+
return await fn();
|
|
498
|
+
} catch (error) {
|
|
499
|
+
if (error instanceof Error && error.constructor === Error) throw new TimesheetActionError(400, "invalid_request", error.message);
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async function evidenceService(entries, commercial) {
|
|
504
|
+
const options = entries.options;
|
|
505
|
+
const [charges, compensation] = await Promise.all([ServiceChargeSnapshotCollection.create(options), ServiceCompensationSnapshotCollection.create(options)]);
|
|
506
|
+
return new ServiceEvidenceService(entries, charges, compensation, commercial);
|
|
507
|
+
}
|
|
508
|
+
function itemClass(entries) {
|
|
509
|
+
return entries.constructor._itemClass;
|
|
510
|
+
}
|
|
511
|
+
async function loadEntry(entries, principal, body) {
|
|
512
|
+
const entryId = requiredString(body, "entryId");
|
|
513
|
+
const entry = await entries.get({ id: entryId });
|
|
514
|
+
if (!entry || (entry.tenantId ?? null) !== principal.tenantId) throw new TimesheetActionError(404, "not_found", `Service time entry ${entryId} was not found.`);
|
|
515
|
+
return entry;
|
|
516
|
+
}
|
|
517
|
+
async function recordServiceTimeAction(entries, input) {
|
|
518
|
+
const body = asRecord(input);
|
|
519
|
+
const principal = await authorizeTimesheetAction(itemClass(entries), "record", entries.options, { allowSuperAdminBypass: true });
|
|
520
|
+
const participantProfileId = optionalString(body, "participantProfileId")?.trim() || principal.profileId;
|
|
521
|
+
if (participantProfileId !== principal.profileId && !await principalHolds(itemClass(entries), "approve", entries.options)) throw new TimesheetActionError(403, "permission_denied", "Recording another person's time needs the approve permission.");
|
|
522
|
+
const source = optionalString(body, "source") ?? "manual";
|
|
523
|
+
if (source !== "manual" && source !== "timer") throw new TimesheetActionError(400, "invalid_request", "source must be 'manual' or 'timer'.");
|
|
524
|
+
const durationSeconds = body.durationSeconds;
|
|
525
|
+
if (durationSeconds !== void 0 && durationSeconds !== null && typeof durationSeconds !== "number") throw new TimesheetActionError(400, "invalid_request", "durationSeconds must be a number.");
|
|
526
|
+
const evidence = body.evidence;
|
|
527
|
+
if (evidence !== void 0 && !Array.isArray(evidence)) throw new TimesheetActionError(400, "invalid_request", "evidence must be an array.");
|
|
528
|
+
const metadata = body.metadata;
|
|
529
|
+
if (metadata !== void 0 && (metadata === null || typeof metadata !== "object" || Array.isArray(metadata))) throw new TimesheetActionError(400, "invalid_request", "metadata must be an object.");
|
|
530
|
+
return withActionPrincipal(principal, async () => {
|
|
531
|
+
const service = await evidenceService(entries);
|
|
532
|
+
return serviceCall(() => service.record({
|
|
533
|
+
tenantId: principal.tenantId,
|
|
534
|
+
workRefType: requiredString(body, "workRefType"),
|
|
535
|
+
workRefId: requiredString(body, "workRefId"),
|
|
536
|
+
participantKind: "human",
|
|
537
|
+
participantProfileId,
|
|
538
|
+
source,
|
|
539
|
+
description: optionalString(body, "description") ?? "",
|
|
540
|
+
startedAt: optionalInstant(body, "startedAt"),
|
|
541
|
+
endedAt: optionalInstant(body, "endedAt"),
|
|
542
|
+
...durationSeconds !== void 0 ? { durationSeconds } : {},
|
|
543
|
+
...body.durationHours !== void 0 ? { durationHours: optionalString(body, "durationHours") } : {},
|
|
544
|
+
...evidence ? { evidence } : {},
|
|
545
|
+
...metadata ? { metadata } : {}
|
|
546
|
+
}));
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
async function submitServiceTimeAction(entries, input) {
|
|
550
|
+
const body = asRecord(input);
|
|
551
|
+
const principal = await authorizeTimesheetAction(itemClass(entries), "submit", entries.options, { allowSuperAdminBypass: true });
|
|
552
|
+
return withActionPrincipal(principal, async () => {
|
|
553
|
+
const entry = await loadEntry(entries, principal, body);
|
|
554
|
+
if (entry.participantProfileId !== principal.profileId && !await principalHolds(itemClass(entries), "approve", entries.options)) throw new TimesheetActionError(403, "permission_denied", "Submitting another person's time needs the approve permission.");
|
|
555
|
+
const service = await evidenceService(entries);
|
|
556
|
+
return serviceCall(() => service.submit(entry, principal.profileId));
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
async function approveServiceTimeAction(entries, input) {
|
|
560
|
+
const body = asRecord(input);
|
|
561
|
+
const principal = await authorizeTimesheetAction(itemClass(entries), "approve", entries.options, { allowSuperAdminBypass: false });
|
|
562
|
+
const source = actionCommercialResolver;
|
|
563
|
+
if (!source) throw new TimesheetActionError(409, "approval_not_configured", "No ServiceCommercialResolver is installed for time entry approval.");
|
|
564
|
+
return withActionPrincipal(principal, async () => {
|
|
565
|
+
const entry = await loadEntry(entries, principal, body);
|
|
566
|
+
const service = await evidenceService(entries, typeof source === "function" ? await source({ options: entries.options }) : source);
|
|
567
|
+
return serviceCall(() => service.approve(entry, {
|
|
568
|
+
actorProfileId: principal.profileId,
|
|
569
|
+
approvalPath: SERVICE_TIME_ENTRY_ACTION_APPROVAL_PATH
|
|
570
|
+
}));
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
//#endregion
|
|
574
|
+
export { setServiceTimeEntryCommercialResolver as a, ServiceEvidenceService as c, ServiceChargeSnapshotCollection as d, ServiceCompensationSnapshot as f, recordServiceTimeAction as i, TimesheetActionError as l, approveServiceTimeAction as n, submitServiceTimeAction as o, ServiceCompensationSnapshotCollection as p, getServiceTimeEntryCommercialResolver as r, time_entry_actions_exports as s, SERVICE_TIME_ENTRY_ACTION_APPROVAL_PATH as t, ServiceChargeSnapshot as u };
|
|
575
|
+
|
|
576
|
+
//# sourceMappingURL=time-entry-actions-Bp--TxUD.js.map
|