@happyvertical/smrt-timesheets 0.55.23 → 0.56.0

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.
Files changed (53) hide show
  1. package/AGENTS.md +26 -0
  2. package/README.md +156 -11
  3. package/agents/period-rollup.md +20 -0
  4. package/dist/attendance/context.d.ts +80 -0
  5. package/dist/attendance/context.d.ts.map +1 -0
  6. package/dist/attendance/correction-models.d.ts +39 -0
  7. package/dist/attendance/correction-models.d.ts.map +1 -0
  8. package/dist/attendance/correction-service.d.ts +118 -0
  9. package/dist/attendance/correction-service.d.ts.map +1 -0
  10. package/dist/attendance/models.d.ts +57 -0
  11. package/dist/attendance/models.d.ts.map +1 -1
  12. package/dist/attendance/segments.d.ts +20 -0
  13. package/dist/attendance/segments.d.ts.map +1 -0
  14. package/dist/attendance/service.d.ts +106 -22
  15. package/dist/attendance/service.d.ts.map +1 -1
  16. package/dist/attendance/write.d.ts +12 -0
  17. package/dist/attendance/write.d.ts.map +1 -0
  18. package/dist/attendance.d.ts +2 -0
  19. package/dist/attendance.d.ts.map +1 -1
  20. package/dist/attendance.js +3 -323
  21. package/dist/chunks/{attendance-BVeStEBv.js → attendance-B1jCbqSb.js} +3 -3
  22. package/dist/chunks/{attendance-BVeStEBv.js.map → attendance-B1jCbqSb.js.map} +1 -1
  23. package/dist/chunks/attendance-DGVy-iHI.js +1246 -0
  24. package/dist/chunks/attendance-DGVy-iHI.js.map +1 -0
  25. package/dist/chunks/models-0-YD5F_o.js +372 -0
  26. package/dist/chunks/models-0-YD5F_o.js.map +1 -0
  27. package/dist/chunks/{rollup-U1IO_CMA.js → rollup-CDqzCxI2.js} +211 -6
  28. package/dist/chunks/rollup-CDqzCxI2.js.map +1 -0
  29. package/dist/chunks/service-time-entry-t7P-bkOQ.js +227 -0
  30. package/dist/chunks/service-time-entry-t7P-bkOQ.js.map +1 -0
  31. package/dist/index.js +5 -5
  32. package/dist/manifest.json +2965 -1358
  33. package/dist/rollup/correction-guard.d.ts +12 -0
  34. package/dist/rollup/correction-guard.d.ts.map +1 -0
  35. package/dist/rollup/models.d.ts +31 -1
  36. package/dist/rollup/models.d.ts.map +1 -1
  37. package/dist/rollup/service.d.ts +25 -7
  38. package/dist/rollup/service.d.ts.map +1 -1
  39. package/dist/rollup/types.d.ts +18 -0
  40. package/dist/rollup/types.d.ts.map +1 -1
  41. package/dist/rollup/write.d.ts +2 -0
  42. package/dist/rollup/write.d.ts.map +1 -1
  43. package/dist/rollup.d.ts +3 -2
  44. package/dist/rollup.d.ts.map +1 -1
  45. package/dist/rollup.js +2 -2
  46. package/dist/smrt-knowledge.json +700 -34
  47. package/package.json +7 -7
  48. package/dist/attendance.js.map +0 -1
  49. package/dist/chunks/models-zU8Um7c2.js +0 -176
  50. package/dist/chunks/models-zU8Um7c2.js.map +0 -1
  51. package/dist/chunks/rollup-U1IO_CMA.js.map +0 -1
  52. package/dist/chunks/service-time-entry-CBv-FF-W.js +0 -227
  53. package/dist/chunks/service-time-entry-CBv-FF-W.js.map +0 -1
@@ -0,0 +1,1246 @@
1
+ import { i as ServiceTimeEntryCollection, o as parseTimesheetJson } from "./service-time-entry-t7P-bkOQ.js";
2
+ import { c as AttendanceSegmentCollection, d as persistAutoClose, f as persistCorrection, i as AttendancePunchCollection, l as deleteCorrection, n as AttendanceBreakCollection, o as AttendanceReplayCollection, r as AttendancePunch, s as AttendanceSegment, t as AttendanceBreak, u as isCorrectionWrite } from "./models-0-YD5F_o.js";
3
+ import { SmrtCollection, SmrtObject, crossPackageRef, field, foreignKey, isEmbeddedDatabase, isPostgresDatabase, smrt, withEmbeddedWriteTransaction } from "@happyvertical/smrt-core";
4
+ import { TenantScoped, getTenantId, tenantId, withTenant } from "@happyvertical/smrt-tenancy";
5
+ //#region src/attendance/correction-models.ts
6
+ var __defProp = Object.defineProperty;
7
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
8
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
9
+ enumerable: true,
10
+ configurable: true,
11
+ writable: true,
12
+ value
13
+ }) : obj[key] = value;
14
+ var __decorateClass = (decorators, target, key, kind) => {
15
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
16
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
17
+ if (kind && result) __defProp(target, key, result);
18
+ return result;
19
+ };
20
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
21
+ var ATTENDANCE_CORRECTION_KINDS = [
22
+ "punch.corrected",
23
+ "punch.added",
24
+ "break.added",
25
+ "break.changed",
26
+ "break.removed",
27
+ "segment.added",
28
+ "segment.changed",
29
+ "segment.removed"
30
+ ];
31
+ var IMMUTABLE_COLUMNS = [
32
+ "tenant_id",
33
+ "subject_profile_id",
34
+ "actor_profile_id",
35
+ "kind",
36
+ "punch_id",
37
+ "target_id",
38
+ "before_state",
39
+ "after_state",
40
+ "reason"
41
+ ];
42
+ var AttendanceCorrection = class extends SmrtObject {
43
+ tenantId = "";
44
+ subjectProfileId = "";
45
+ actorProfileId = "";
46
+ kind = "punch.corrected";
47
+ punchId = "";
48
+ /** The break or segment id for those kinds; the punch id for punch kinds. */
49
+ targetId = "";
50
+ beforeState = "null";
51
+ afterState = "null";
52
+ reason = "";
53
+ acknowledgementRequired = false;
54
+ acknowledgedAt = null;
55
+ acknowledgedByProfileId = null;
56
+ /** The values before the change, or null for an addition. */
57
+ getBefore() {
58
+ return parseTimesheetJson(this.beforeState, null);
59
+ }
60
+ /** The values after the change, or null for a removal. */
61
+ getAfter() {
62
+ return parseTimesheetJson(this.afterState, null);
63
+ }
64
+ async save() {
65
+ if (!isCorrectionWrite(this)) throw new Error("Attendance corrections are writable only through AttendanceCorrectionService.");
66
+ if (!this.tenantId || !this.subjectProfileId || !this.actorProfileId || !this.punchId || !this.targetId || !ATTENDANCE_CORRECTION_KINDS.includes(this.kind) || typeof this.reason !== "string" || !this.reason.trim() || typeof this.acknowledgementRequired !== "boolean") throw new Error("Invalid attendance correction.");
67
+ for (const json of [this.beforeState, this.afterState]) JSON.parse(json);
68
+ if (!!this.acknowledgedAt !== !!this.acknowledgedByProfileId) throw new Error("Acknowledgement needs both a time and a profile.");
69
+ if (this.acknowledgedAt) {
70
+ if (!this.acknowledgementRequired) throw new Error("This correction does not require acknowledgement.");
71
+ if (this.acknowledgedByProfileId !== this.subjectProfileId) throw new Error("Only the subject can acknowledge a correction.");
72
+ }
73
+ const prior = this.id ? await this.db.get(this.tableName, { id: this.id }) : null;
74
+ if (prior) {
75
+ const row = this;
76
+ const camel = (column) => column.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
77
+ if (IMMUTABLE_COLUMNS.some((column) => prior[column] !== row[camel(column)]) || Boolean(prior.acknowledgement_required) !== this.acknowledgementRequired || prior.acknowledged_at && (new Date(prior.acknowledged_at).getTime() !== this.acknowledgedAt?.getTime() || prior.acknowledged_by_profile_id !== this.acknowledgedByProfileId)) throw new Error("Attendance corrections are append-only; only a first acknowledgement may change.");
78
+ }
79
+ this.id ??= crypto.randomUUID();
80
+ return super.save();
81
+ }
82
+ async delete() {
83
+ throw new Error("Attendance correction history cannot be deleted.");
84
+ }
85
+ };
86
+ __publicField(AttendanceCorrection, "__smrtPackage__", "@happyvertical/smrt-timesheets");
87
+ __decorateClass([tenantId()], AttendanceCorrection.prototype, "tenantId", 2);
88
+ __decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile")], AttendanceCorrection.prototype, "subjectProfileId", 2);
89
+ __decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile")], AttendanceCorrection.prototype, "actorProfileId", 2);
90
+ __decorateClass([field({ type: "text" })], AttendanceCorrection.prototype, "kind", 2);
91
+ __decorateClass([foreignKey(AttendancePunch)], AttendanceCorrection.prototype, "punchId", 2);
92
+ __decorateClass([field({ type: "text" })], AttendanceCorrection.prototype, "beforeState", 2);
93
+ __decorateClass([field({ type: "text" })], AttendanceCorrection.prototype, "afterState", 2);
94
+ __decorateClass([field({ type: "text" })], AttendanceCorrection.prototype, "reason", 2);
95
+ __decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile", { nullable: true })], AttendanceCorrection.prototype, "acknowledgedByProfileId", 2);
96
+ AttendanceCorrection = __decorateClass([TenantScoped({ mode: "required" }), smrt({
97
+ tableName: "attendance_corrections",
98
+ sensitive: true,
99
+ indexes: [{
100
+ name: "attendance_corrections_punch_idx",
101
+ columns: ["tenantId", "punchId"]
102
+ }, {
103
+ name: "attendance_corrections_subject_idx",
104
+ columns: ["tenantId", "subjectProfileId"]
105
+ }],
106
+ api: { include: [] },
107
+ cli: { include: [] },
108
+ mcp: { include: [] }
109
+ })], AttendanceCorrection);
110
+ var AttendanceCorrectionCollection = class extends SmrtCollection {
111
+ static _itemClass = AttendanceCorrection;
112
+ };
113
+ //#endregion
114
+ //#region src/attendance/context.ts
115
+ var MAX_LABEL = 64;
116
+ function attendanceLabel(value, name) {
117
+ if (value === void 0 || value === null) return "";
118
+ if (typeof value !== "string" || value !== value.trim() || value.length > MAX_LABEL) throw new AttendanceConflict(`${name} must be a trimmed string of at most ${MAX_LABEL} characters.`, "invalid");
119
+ return value;
120
+ }
121
+ function attendanceProvenance(options) {
122
+ const source = attendanceLabel(options?.source, "source");
123
+ const device = options?.device;
124
+ if (device !== void 0 && (!device || typeof device !== "object")) throw new AttendanceConflict("device must be { type, id }.", "invalid");
125
+ const deviceRefType = attendanceLabel(device?.type, "device.type");
126
+ const deviceRefId = attendanceLabel(device?.id, "device.id");
127
+ if (!!deviceRefType !== !!deviceRefId) throw new AttendanceConflict("device.type and device.id must be provided together.", "invalid");
128
+ return {
129
+ source,
130
+ deviceRefType,
131
+ deviceRefId
132
+ };
133
+ }
134
+ var AttendanceConflict = class extends Error {
135
+ /**
136
+ * Stable machine-readable reason (see {@link AttendanceRefusalCode}); stored
137
+ * on a refused replay outcome so a device screen can say why.
138
+ */
139
+ code;
140
+ constructor(message, code = "conflict") {
141
+ super(message);
142
+ this.code = code;
143
+ }
144
+ };
145
+ function validateAttendanceWork(work) {
146
+ if (!work || typeof work.workRefType !== "string" || !work.workRefType || typeof work.workRefId !== "string" || !work.workRefId || typeof work.description !== "string") throw new AttendanceConflict("A complete work reference and description are required.");
147
+ for (const key of [
148
+ "taskRefType",
149
+ "taskRefId",
150
+ "kind"
151
+ ]) if (work[key] !== void 0 && typeof work[key] !== "string") throw new AttendanceConflict(`${key} must be a string.`);
152
+ if (!!work.taskRefType !== !!work.taskRefId) throw new AttendanceConflict("taskRefType and taskRefId must be provided together.");
153
+ }
154
+ function attendanceIdentity(db, value) {
155
+ if (!isPostgresDatabase(db)) return value;
156
+ const hex = value.replace(/[{}-]/g, "").toLowerCase();
157
+ if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error("PostgreSQL attendance identities must be UUIDs.");
158
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
159
+ }
160
+ //#endregion
161
+ //#region src/attendance/segments.ts
162
+ async function openSegment(tx, punch) {
163
+ const [segment] = await tx.segments.list({
164
+ where: {
165
+ tenantId: punch.tenantId,
166
+ punchId: punch.id,
167
+ endedAt: null
168
+ },
169
+ limit: 1
170
+ });
171
+ return segment ?? null;
172
+ }
173
+ async function listSegments(tx, punch) {
174
+ return tx.segments.list({
175
+ where: {
176
+ tenantId: punch.tenantId,
177
+ punchId: punch.id
178
+ },
179
+ orderBy: "started_at ASC"
180
+ });
181
+ }
182
+ async function closeOpenSegment(tx, punch, at, reason) {
183
+ const segment = await openSegment(tx, punch);
184
+ if (!segment) return null;
185
+ if (at < segment.startedAt) throw new AttendanceConflict("Segment cannot end before it starts.");
186
+ segment.endedAt = at;
187
+ segment.closedReason = reason;
188
+ await segment.save();
189
+ return segment;
190
+ }
191
+ async function beginSegment(tx, punch, at, work) {
192
+ validateAttendanceWork(work);
193
+ if (punch.endedAt) throw new AttendanceConflict("Punch is closed.");
194
+ if ((await tx.breaks.list({ where: {
195
+ tenantId: punch.tenantId,
196
+ punchId: punch.id
197
+ } })).some((b) => !b.endedAt)) throw new AttendanceConflict("Cannot work during an open break.", "during_break");
198
+ if (await openSegment(tx, punch)) throw new AttendanceConflict("A work segment is already open.", "work_open");
199
+ return tx.segments.create({
200
+ tenantId: punch.tenantId,
201
+ punchId: punch.id ?? void 0,
202
+ workRefType: work.workRefType,
203
+ workRefId: work.workRefId,
204
+ taskRefType: work.taskRefType ?? "",
205
+ taskRefId: work.taskRefId ?? "",
206
+ kind: work.kind ?? "",
207
+ description: work.description,
208
+ startedAt: at,
209
+ _insertOnly: true
210
+ });
211
+ }
212
+ async function replaceSegment(tx, punch, at, work) {
213
+ validateAttendanceWork(work);
214
+ if (!await closeOpenSegment(tx, punch, at, "switch")) throw new AttendanceConflict("No open work segment to switch.", "no_open_work");
215
+ return beginSegment(tx, punch, at, work);
216
+ }
217
+ async function endSegment(tx, punch, at) {
218
+ const segment = await closeOpenSegment(tx, punch, at, "stop");
219
+ if (!segment) throw new AttendanceConflict("No open work segment.", "no_open_work");
220
+ return segment;
221
+ }
222
+ async function continueSegment(tx, punch, at) {
223
+ const all = await listSegments(tx, punch);
224
+ const last = all[all.length - 1];
225
+ const breaks = await tx.breaks.list({
226
+ where: {
227
+ tenantId: punch.tenantId,
228
+ punchId: punch.id
229
+ },
230
+ orderBy: "started_at DESC",
231
+ limit: 1
232
+ });
233
+ if (last?.closedReason !== "break" || !last.endedAt || last.endedAt.getTime() !== breaks[0]?.startedAt.getTime()) throw new AttendanceConflict("No work interrupted by a break to resume.", "no_resumable_work");
234
+ return beginSegment(tx, punch, at, last);
235
+ }
236
+ //#endregion
237
+ //#region src/attendance/service.ts
238
+ function sameAttribution(metadata, work) {
239
+ const stored = parseTimesheetJson(metadata, {});
240
+ return (stored.taskRefType ?? "") === (work.taskRefType ?? "") && (stored.taskRefId ?? "") === (work.taskRefId ?? "") && (stored.kind ?? "") === (work.kind ?? "");
241
+ }
242
+ var TAP_ACTIONS = [
243
+ "punchIn",
244
+ "punchOut",
245
+ "startBreak",
246
+ "endBreak",
247
+ "startWork",
248
+ "switchWork",
249
+ "stopWork",
250
+ "resumeWork"
251
+ ];
252
+ var AttendanceService = class {
253
+ constructor(db, actor) {
254
+ this.db = db;
255
+ this.actor = actor;
256
+ if (!actor.tenantId || !actor.profileId) throw new Error("Attendance requires a trusted tenant/profile actor.");
257
+ this.actor = {
258
+ tenantId: this.identity(actor.tenantId),
259
+ profileId: this.identity(actor.profileId)
260
+ };
261
+ }
262
+ db;
263
+ actor;
264
+ identity(value) {
265
+ return attendanceIdentity(this.db, value);
266
+ }
267
+ /**
268
+ * Run `operation` in the actor's serialized attendance transaction. The
269
+ * context's collections share the executor, so corrections and replay can
270
+ * compose several attendance mutations (with the segment helpers) atomically.
271
+ * Results that are punches or segments are re-read after commit.
272
+ */
273
+ async withTransaction(operation) {
274
+ return this.transact(operation);
275
+ }
276
+ async transact(operation) {
277
+ const current = getTenantId();
278
+ if (current && this.identity(current) !== this.actor.tenantId) throw new Error("Attendance actor differs from active tenant.");
279
+ return withTenant({ tenantId: this.actor.tenantId }, async () => {
280
+ const result = await withEmbeddedWriteTransaction(this.db, isEmbeddedDatabase(this.db), async (db) => {
281
+ if (isPostgresDatabase(db)) await db.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", JSON.stringify([
282
+ "smrt.attendance",
283
+ this.actor.tenantId,
284
+ this.actor.profileId
285
+ ]));
286
+ return operation({
287
+ db,
288
+ punches: await AttendancePunchCollection.create({ db }),
289
+ breaks: await AttendanceBreakCollection.create({ db }),
290
+ segments: await AttendanceSegmentCollection.create({ db }),
291
+ replays: await AttendanceReplayCollection.create({ db })
292
+ });
293
+ }, true);
294
+ if (result instanceof AttendancePunch) {
295
+ const [fresh] = await (await AttendancePunchCollection.create({ db: this.db })).list({
296
+ where: {
297
+ ...this.actor,
298
+ id: result.id
299
+ },
300
+ limit: 1
301
+ });
302
+ if (!fresh) throw new Error("Committed attendance punch disappeared.");
303
+ return fresh;
304
+ }
305
+ if (result instanceof AttendanceSegment) {
306
+ const [fresh] = await (await AttendanceSegmentCollection.create({ db: this.db })).list({
307
+ where: {
308
+ tenantId: this.actor.tenantId,
309
+ id: result.id
310
+ },
311
+ limit: 1
312
+ });
313
+ if (!fresh) throw new Error("Committed attendance segment disappeared.");
314
+ return fresh;
315
+ }
316
+ return result;
317
+ });
318
+ }
319
+ date(at) {
320
+ if (!(at instanceof Date) || !Number.isFinite(at.getTime())) throw new AttendanceConflict("Attendance timestamps must be valid dates.");
321
+ return at;
322
+ }
323
+ async open(tx) {
324
+ const [punch] = await tx.punches.list({
325
+ where: {
326
+ ...this.actor,
327
+ endedAt: null
328
+ },
329
+ limit: 1
330
+ });
331
+ if (!punch) throw new AttendanceConflict("No open punch.", "no_open_punch");
332
+ return punch;
333
+ }
334
+ async chronological(tx, at) {
335
+ const open = await tx.punches.list({
336
+ where: {
337
+ ...this.actor,
338
+ endedAt: null
339
+ },
340
+ limit: 1
341
+ });
342
+ const rows = open.length ? open : await tx.punches.list({
343
+ where: { ...this.actor },
344
+ orderBy: "ended_at DESC",
345
+ limit: 1
346
+ });
347
+ if (!rows.length) return;
348
+ const punch = rows[0];
349
+ const breaks = await tx.breaks.list({ where: {
350
+ tenantId: this.actor.tenantId,
351
+ punchId: punch.id
352
+ } });
353
+ const segments = await tx.segments.list({ where: {
354
+ tenantId: this.actor.tenantId,
355
+ punchId: punch.id
356
+ } });
357
+ const latest = Math.max(punch.startedAt.getTime(), ...segments.flatMap((g) => [g.startedAt.getTime(), g.endedAt?.getTime() ?? 0]), punch.endedAt?.getTime() ?? 0, ...breaks.flatMap((b) => [b.startedAt.getTime(), b.endedAt?.getTime() ?? 0]));
358
+ if (at.getTime() < latest) throw new AttendanceConflict("Tap predates committed attendance; manual review required.", "predates_committed");
359
+ }
360
+ /** Open one punch; the database unique slot also protects direct model writers. */
361
+ async punchIn(at = /* @__PURE__ */ new Date(), options) {
362
+ const meta = attendanceProvenance(options);
363
+ return this.transact((tx) => this.transition(tx, "punchIn", this.date(at), false, void 0, false, {
364
+ ...meta,
365
+ kind: ""
366
+ }));
367
+ }
368
+ /** Close the punch and any active break; optionally create linked draft evidence. */
369
+ async punchOut(at, work) {
370
+ return this.transact((tx) => this.transition(tx, "punchOut", this.date(at), false, work));
371
+ }
372
+ /** Begin a break; paid defaults false and must be explicitly configured by the caller. */
373
+ async startBreak(at, paid = false, options) {
374
+ const kind = attendanceLabel(options?.kind, "kind");
375
+ return this.transact((tx) => this.transition(tx, "startBreak", this.date(at), paid, void 0, false, {
376
+ ...attendanceProvenance(),
377
+ kind
378
+ }));
379
+ }
380
+ /**
381
+ * End the current break. With `{ resumeWork: true }` the work interrupted by
382
+ * `startBreak` resumes at the same instant, atomically with the break end.
383
+ */
384
+ async endBreak(at, options) {
385
+ return this.transact((tx) => this.transition(tx, "endBreak", this.date(at), false, void 0, options?.resumeWork));
386
+ }
387
+ /** Open a work segment in the open punch; refuses during a break or while one is open. */
388
+ async startWork(at, work) {
389
+ return this.transact(async (tx) => {
390
+ validateAttendanceWork(work);
391
+ await this.chronological(tx, this.date(at));
392
+ return beginSegment(tx, await this.open(tx), at, work);
393
+ });
394
+ }
395
+ /** Atomically close the open segment and open the next one at `at`. */
396
+ async switchWork(at, work) {
397
+ return this.transact(async (tx) => {
398
+ validateAttendanceWork(work);
399
+ await this.chronological(tx, this.date(at));
400
+ return replaceSegment(tx, await this.open(tx), at, work);
401
+ });
402
+ }
403
+ /** Close the open segment without starting another. */
404
+ async stopWork(at) {
405
+ return this.transact(async (tx) => {
406
+ await this.chronological(tx, this.date(at));
407
+ return endSegment(tx, await this.open(tx), at);
408
+ });
409
+ }
410
+ /** Reopen the work a break interrupted (the latest segment must have closed on `startBreak`). */
411
+ async resumeWork(at) {
412
+ return this.transact(async (tx) => {
413
+ await this.chronological(tx, this.date(at));
414
+ return continueSegment(tx, await this.open(tx), at);
415
+ });
416
+ }
417
+ /** The open work segment of the actor's open punch, or null. */
418
+ async currentWork() {
419
+ return this.transact(async (tx) => {
420
+ const [punch] = await tx.punches.list({
421
+ where: {
422
+ ...this.actor,
423
+ endedAt: null
424
+ },
425
+ limit: 1
426
+ });
427
+ return punch ? openSegment(tx, punch) : null;
428
+ });
429
+ }
430
+ /** Work segments of one of the actor's punches, oldest first. */
431
+ async segments(punchId) {
432
+ return this.transact(async (tx) => {
433
+ const [punch] = await tx.punches.list({
434
+ where: {
435
+ ...this.actor,
436
+ id: punchId
437
+ },
438
+ limit: 1
439
+ });
440
+ if (!punch) throw new AttendanceConflict("Punch not found for actor.");
441
+ return listSegments(tx, punch);
442
+ });
443
+ }
444
+ async transition(tx, action, at, paid = false, work, resumeWork = false, meta = {
445
+ ...attendanceProvenance(),
446
+ kind: ""
447
+ }) {
448
+ await this.chronological(tx, at);
449
+ if (action === "punchIn") {
450
+ if ((await tx.punches.list({
451
+ where: {
452
+ ...this.actor,
453
+ endedAt: null
454
+ },
455
+ limit: 1
456
+ })).length) throw new AttendanceConflict("A punch is already open.", "punch_open");
457
+ return tx.punches.create({
458
+ ...this.actor,
459
+ startedAt: at,
460
+ source: meta.source,
461
+ deviceRefType: meta.deviceRefType,
462
+ deviceRefId: meta.deviceRefId,
463
+ _insertOnly: true
464
+ });
465
+ }
466
+ const punch = await this.open(tx);
467
+ const breaks = await tx.breaks.list({ where: {
468
+ tenantId: this.actor.tenantId,
469
+ punchId: punch.id
470
+ } });
471
+ const active = breaks.find((b) => !b.endedAt);
472
+ if (action === "startBreak") {
473
+ if (active) throw new AttendanceConflict("A break is already open.", "break_open");
474
+ if (typeof paid !== "boolean") throw new AttendanceConflict("paid must be boolean.");
475
+ await closeOpenSegment(tx, punch, at, "break");
476
+ await tx.breaks.create({
477
+ tenantId: this.actor.tenantId,
478
+ punchId: punch.id ?? void 0,
479
+ startedAt: at,
480
+ paid,
481
+ kind: meta.kind,
482
+ _insertOnly: true
483
+ });
484
+ return punch;
485
+ }
486
+ if (action !== "endBreak" && action !== "punchOut") throw new AttendanceConflict("Unknown attendance action.");
487
+ if (action === "endBreak" && !active) throw new AttendanceConflict("No open break.", "no_open_break");
488
+ if (action === "punchOut" && work) {
489
+ this.validateWork(work);
490
+ const unpaidMilliseconds2 = breaks.filter((b) => !b.paid).reduce((sum, b) => sum + (b.endedAt ?? at).getTime() - b.startedAt.getTime(), 0);
491
+ if (Math.round((at.getTime() - punch.startedAt.getTime() - unpaidMilliseconds2) / 1e3) <= 0) throw new AttendanceConflict("Only closed punches with positive duration can produce service evidence.");
492
+ }
493
+ if (active) {
494
+ active.endedAt = at;
495
+ await active.save();
496
+ }
497
+ if (action === "endBreak") {
498
+ if (resumeWork) await continueSegment(tx, punch, at);
499
+ return punch;
500
+ }
501
+ await closeOpenSegment(tx, punch, at, "punchOut");
502
+ punch.endedAt = at;
503
+ const unpaidMilliseconds = breaks.filter((b) => !b.paid).reduce((sum, b) => sum + b.endedAt.getTime() - b.startedAt.getTime(), 0);
504
+ punch.unpaidBreakSeconds = Math.round(unpaidMilliseconds / 1e3);
505
+ punch.durationSeconds = Math.round((at.getTime() - punch.startedAt.getTime() - unpaidMilliseconds) / 1e3);
506
+ await punch.save();
507
+ if (work) await this.link(tx, punch, work);
508
+ return punch;
509
+ }
510
+ /** Run one replayed tap on `tx`; work actions share the punch changes' transaction. */
511
+ async apply(tx, tap, at) {
512
+ const work = () => {
513
+ if (!tap.work) throw new AttendanceConflict("Work is required.", "invalid");
514
+ return tap.work;
515
+ };
516
+ let segment;
517
+ switch (tap.action) {
518
+ case "startWork":
519
+ await this.chronological(tx, at);
520
+ segment = await beginSegment(tx, await this.open(tx), at, work());
521
+ break;
522
+ case "switchWork":
523
+ await this.chronological(tx, at);
524
+ segment = await replaceSegment(tx, await this.open(tx), at, work());
525
+ break;
526
+ case "stopWork":
527
+ await this.chronological(tx, at);
528
+ segment = await endSegment(tx, await this.open(tx), at);
529
+ break;
530
+ case "resumeWork":
531
+ await this.chronological(tx, at);
532
+ segment = await continueSegment(tx, await this.open(tx), at);
533
+ break;
534
+ default: return { punch: await this.transition(tx, tap.action, at, tap.paid, tap.work, false, {
535
+ ...attendanceProvenance({
536
+ source: tap.source,
537
+ device: tap.device
538
+ }),
539
+ kind: attendanceLabel(tap.kind, "kind")
540
+ }) };
541
+ }
542
+ const [punch] = await tx.punches.list({
543
+ where: {
544
+ ...this.actor,
545
+ id: segment.punchId
546
+ },
547
+ limit: 1
548
+ });
549
+ return {
550
+ punch,
551
+ segment
552
+ };
553
+ }
554
+ validateWork(work) {
555
+ validateAttendanceWork(work);
556
+ }
557
+ async link(tx, punch, work) {
558
+ this.validateWork(work);
559
+ if (!punch.endedAt || punch.durationSeconds <= 0) throw new AttendanceConflict("Only closed punches with positive duration can produce service evidence.");
560
+ const entries = await ServiceTimeEntryCollection.create({ db: tx.db });
561
+ if (punch.serviceTimeEntryId) {
562
+ const [existing] = await entries.list({
563
+ where: {
564
+ id: punch.serviceTimeEntryId,
565
+ tenantId: this.actor.tenantId
566
+ },
567
+ limit: 1
568
+ });
569
+ if (!existing || existing.workRefType !== work.workRefType || existing.workRefId !== work.workRefId || existing.description !== work.description || !sameAttribution(existing.metadata, work)) throw new AttendanceConflict("Punch already linked to different work.");
570
+ return;
571
+ }
572
+ const entry = await entries.create({
573
+ tenantId: this.actor.tenantId,
574
+ participantKind: "human",
575
+ participantProfileId: this.actor.profileId,
576
+ workRefType: work.workRefType,
577
+ workRefId: work.workRefId,
578
+ description: work.description,
579
+ source: "timer",
580
+ startedAt: punch.startedAt,
581
+ endedAt: punch.endedAt,
582
+ durationSeconds: punch.durationSeconds,
583
+ status: "draft",
584
+ metadata: JSON.stringify({
585
+ attendancePunchId: punch.id,
586
+ ...work.taskRefType ? {
587
+ taskRefType: work.taskRefType,
588
+ taskRefId: work.taskRefId
589
+ } : {},
590
+ ...work.kind ? { kind: work.kind } : {}
591
+ }),
592
+ _insertOnly: true
593
+ });
594
+ if (!entry.id) throw new Error("Saved service entry has no id.");
595
+ punch.serviceTimeEntryId = entry.id;
596
+ await punch.save();
597
+ }
598
+ /** Idempotently link a closed, owned punch to draft service evidence. */
599
+ async linkServiceTimeEntry(punchId, work) {
600
+ return this.transact(async (tx) => {
601
+ const [punch] = await tx.punches.list({
602
+ where: {
603
+ ...this.actor,
604
+ id: punchId
605
+ },
606
+ limit: 1
607
+ });
608
+ if (!punch) throw new AttendanceConflict("Punch not found for actor.");
609
+ await this.link(tx, punch, work);
610
+ return punch;
611
+ });
612
+ }
613
+ /**
614
+ * Close the open punch once its deadline has passed, flagging it for review.
615
+ * `deadline` is either a fixed maximum open duration in seconds (closing at
616
+ * `startedAt + seconds`, reason `maxDuration`) or a resolver called with the
617
+ * open punch that returns its deadline (a `Date`, or `{ deadline, reason }`),
618
+ * or null to leave it open. The consumer owns time zones and shop-day rules.
619
+ * The open work segment and break close at the deadline; returns the closed
620
+ * punch, or null when nothing was due.
621
+ */
622
+ async autoClose(now, deadline) {
623
+ this.date(now);
624
+ if (typeof deadline !== "function" && (!Number.isSafeInteger(deadline) || deadline <= 0)) throw new AttendanceConflict("maxOpenSeconds must be a positive integer.");
625
+ return this.transact(async (tx) => {
626
+ const [punch] = await tx.punches.list({
627
+ where: {
628
+ ...this.actor,
629
+ endedAt: null
630
+ },
631
+ limit: 1
632
+ });
633
+ if (!punch) return null;
634
+ let at;
635
+ let reason = "maxDuration";
636
+ if (typeof deadline === "function") {
637
+ const resolved = await deadline(punch);
638
+ if (resolved === null || resolved === void 0) return null;
639
+ const decision = resolved instanceof Date ? { deadline: resolved } : resolved;
640
+ at = decision.deadline;
641
+ if (!(at instanceof Date) || !Number.isFinite(at.getTime())) throw new AttendanceConflict("Auto-close deadline is invalid.");
642
+ if (at < punch.startedAt) throw new AttendanceConflict("Auto-close deadline predates the punch.");
643
+ reason = decision.reason ?? "deadline";
644
+ if (typeof reason !== "string" || !reason.trim() || reason.length > 200) throw new AttendanceConflict("Auto-close reason must be 1 to 200 characters.");
645
+ } else at = new Date(punch.startedAt.getTime() + deadline * 1e3);
646
+ if (at > now) return null;
647
+ const closed = await this.transition(tx, "punchOut", at);
648
+ closed.reviewRequired = true;
649
+ closed.autoCloseReason = reason;
650
+ await persistAutoClose(closed);
651
+ return closed;
652
+ });
653
+ }
654
+ /**
655
+ * Replay device timestamps in order (ties use clientId). Committed attendance
656
+ * wins: stale/state-conflicting taps get durable refusals; identical retries
657
+ * return the same outcome, changed payloads using a key are rejected. A
658
+ * database failure rolls back its tap and receipt and remains retryable.
659
+ */
660
+ async replay(taps, options = {}) {
661
+ for (const tap of taps) {
662
+ if (!tap || typeof tap.at !== "string" || typeof tap.clientId !== "string" || !tap.clientId || !TAP_ACTIONS.includes(tap.action)) throw new AttendanceConflict("Malformed attendance tap.");
663
+ this.date(new Date(tap.at));
664
+ if (tap.work !== void 0) this.validateWork(tap.work);
665
+ if ((tap.action === "startWork" || tap.action === "switchWork") && tap.work === void 0) throw new AttendanceConflict("Malformed attendance tap.");
666
+ if (tap.paid !== void 0 && typeof tap.paid !== "boolean") throw new AttendanceConflict("paid must be boolean.");
667
+ attendanceProvenance({
668
+ source: tap.source,
669
+ device: tap.device
670
+ });
671
+ attendanceLabel(tap.kind, "kind");
672
+ }
673
+ const ordered = [...taps].sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || (a.clientId < b.clientId ? -1 : a.clientId > b.clientId ? 1 : 0));
674
+ const outcomes = [];
675
+ for (const tap of ordered) outcomes.push(await this.transact(async (tx) => {
676
+ const replayKey = JSON.stringify([
677
+ this.actor.tenantId,
678
+ this.actor.profileId,
679
+ tap.clientId
680
+ ]);
681
+ const content = JSON.stringify([
682
+ tap.action,
683
+ new Date(tap.at).toISOString(),
684
+ tap.paid ?? false,
685
+ tap.work?.workRefType ?? null,
686
+ tap.work?.workRefId ?? null,
687
+ tap.work?.description ?? null,
688
+ ...tap.work?.taskRefType || tap.work?.kind ? [
689
+ tap.work.taskRefType ?? null,
690
+ tap.work.taskRefId ?? null,
691
+ tap.work.kind ?? null
692
+ ] : [],
693
+ ...tap.source || tap.device || tap.kind ? [{
694
+ source: tap.source ?? "",
695
+ deviceType: tap.device?.type ?? "",
696
+ deviceId: tap.device?.id ?? "",
697
+ kind: tap.kind ?? ""
698
+ }] : []
699
+ ]);
700
+ const [receipt] = await tx.replays.list({
701
+ where: {
702
+ tenantId: this.actor.tenantId,
703
+ replayKey
704
+ },
705
+ limit: 1
706
+ });
707
+ if (receipt) {
708
+ if (receipt.content !== content) throw new AttendanceConflict("Client id reused with different content.");
709
+ const outcome2 = receipt.getOutcome();
710
+ if (outcome2.clientId !== tap.clientId) throw new Error("Stored attendance receipt belongs to another client id.");
711
+ return outcome2;
712
+ }
713
+ const deviceAt = new Date(tap.at);
714
+ let at = deviceAt;
715
+ let review = false;
716
+ if (options.resolveAt) {
717
+ const serverNow = this.date(options.now?.() ?? /* @__PURE__ */ new Date());
718
+ const resolved = await options.resolveAt({
719
+ tap,
720
+ deviceAt,
721
+ serverNow,
722
+ waitedMs: serverNow.getTime() - deviceAt.getTime(),
723
+ actor: { ...this.actor }
724
+ });
725
+ if (!resolved || !(resolved.at instanceof Date) || !Number.isFinite(resolved.at.getTime())) throw new Error("resolveAt must return a valid date.");
726
+ at = resolved.at;
727
+ review = resolved.reviewRequired === true;
728
+ }
729
+ let outcome;
730
+ try {
731
+ outcome = await withEmbeddedWriteTransaction(tx.db, isEmbeddedDatabase(tx.db), async (db) => {
732
+ const inner = {
733
+ db,
734
+ punches: await AttendancePunchCollection.create({ db }),
735
+ breaks: await AttendanceBreakCollection.create({ db }),
736
+ segments: await AttendanceSegmentCollection.create({ db }),
737
+ replays: tx.replays
738
+ };
739
+ const { punch, segment } = await this.apply(inner, tap, at);
740
+ punch.recordedOffline = true;
741
+ punch.reviewRequired = true;
742
+ await punch.save();
743
+ if (segment && review) {
744
+ segment.reviewRequired = true;
745
+ await segment.save();
746
+ }
747
+ return {
748
+ clientId: tap.clientId,
749
+ punchId: punch.id,
750
+ ...segment ? { segmentId: segment.id } : {},
751
+ ...at.getTime() !== deviceAt.getTime() ? { resolvedAt: at.toISOString() } : {}
752
+ };
753
+ }, true);
754
+ } catch (error) {
755
+ if (!(error instanceof AttendanceConflict)) throw error;
756
+ const custom = options.refusal?.(tap, error) || void 0;
757
+ outcome = {
758
+ clientId: tap.clientId,
759
+ error: custom?.message || error.message,
760
+ code: custom?.code || error.code
761
+ };
762
+ }
763
+ await tx.replays.create({
764
+ tenantId: this.actor.tenantId,
765
+ replayKey,
766
+ content,
767
+ outcome: JSON.stringify(outcome),
768
+ _insertOnly: true
769
+ });
770
+ return outcome;
771
+ }));
772
+ return outcomes;
773
+ }
774
+ };
775
+ //#endregion
776
+ //#region src/attendance/correction-service.ts
777
+ var iso = (d) => d ? d.toISOString() : null;
778
+ var punchState = (p) => ({
779
+ startedAt: iso(p.startedAt),
780
+ endedAt: iso(p.endedAt),
781
+ durationSeconds: p.durationSeconds,
782
+ unpaidBreakSeconds: p.unpaidBreakSeconds,
783
+ reviewRequired: p.reviewRequired,
784
+ ...p.source ? { source: p.source } : {},
785
+ ...p.deviceRefType ? {
786
+ deviceRefType: p.deviceRefType,
787
+ deviceRefId: p.deviceRefId
788
+ } : {}
789
+ });
790
+ var breakState = (b) => ({
791
+ startedAt: iso(b.startedAt),
792
+ endedAt: iso(b.endedAt),
793
+ paid: b.paid,
794
+ ...b.kind ? { kind: b.kind } : {}
795
+ });
796
+ var segmentState = (s) => ({
797
+ startedAt: iso(s.startedAt),
798
+ endedAt: iso(s.endedAt),
799
+ workRefType: s.workRefType,
800
+ workRefId: s.workRefId,
801
+ taskRefType: s.taskRefType,
802
+ taskRefId: s.taskRefId,
803
+ kind: s.kind,
804
+ description: s.description,
805
+ durationSeconds: s.durationSeconds,
806
+ closedReason: s.closedReason
807
+ });
808
+ function instant(value, name) {
809
+ if (!(value instanceof Date) || !Number.isFinite(value.getTime())) throw new AttendanceConflict(`${name} must be a valid date.`);
810
+ return value;
811
+ }
812
+ function overlapping(a, b) {
813
+ return a.start < b.end && b.start < a.end;
814
+ }
815
+ function validateLayout(layout) {
816
+ const inside = (r) => r.endedAt.getTime() >= r.startedAt.getTime() && r.startedAt.getTime() >= layout.startsAt && r.endedAt.getTime() <= layout.endsAt;
817
+ if (!layout.breaks.every(inside)) throw new AttendanceConflict("A break must be an interval inside its punch.");
818
+ if (!layout.segments.every(inside)) throw new AttendanceConflict("A work segment must be an interval inside its punch.");
819
+ const span = (r) => ({
820
+ start: r.startedAt.getTime(),
821
+ end: r.endedAt.getTime()
822
+ });
823
+ const sets = [layout.breaks, layout.segments];
824
+ for (const set of sets) for (let i = 0; i < set.length; i++) for (let j = i + 1; j < set.length; j++) if (overlapping(span(set[i]), span(set[j]))) throw new AttendanceConflict(set === layout.breaks ? "Breaks must not overlap." : "Work segments must not overlap.");
825
+ for (const b of layout.breaks) for (const s of layout.segments) if (overlapping(span(b), span(s))) throw new AttendanceConflict("A work segment must not overlap a break.");
826
+ }
827
+ var AttendanceCorrectionService = class {
828
+ constructor(db, actor, options = {}) {
829
+ this.db = db;
830
+ this.options = options;
831
+ if (!actor?.tenantId || !actor.profileId) throw new Error("Corrections require a trusted tenant/profile actor.");
832
+ this.actor = {
833
+ tenantId: attendanceIdentity(db, actor.tenantId),
834
+ profileId: attendanceIdentity(db, actor.profileId)
835
+ };
836
+ }
837
+ db;
838
+ options;
839
+ actor;
840
+ /** Change a closed punch's start and/or end. A reviewed punch no longer needs review. */
841
+ async correctPunch(input) {
842
+ return this.run(input, async (tx, subject, record) => {
843
+ const punch = await this.closedPunch(tx, subject, input.punchId);
844
+ const before = punchState(punch);
845
+ const startedAt = input.startedAt === void 0 ? punch.startedAt : instant(input.startedAt, "startedAt");
846
+ const endedAt = input.endedAt === void 0 ? punch.endedAt : instant(input.endedAt, "endedAt");
847
+ if (endedAt <= startedAt) throw new AttendanceConflict("A punch must end after it starts.");
848
+ const children = await this.children(tx, punch);
849
+ validateLayout(this.layout(startedAt, endedAt, children));
850
+ for (const other of await tx.punches.list({ where: {
851
+ tenantId: subject.tenantId,
852
+ profileId: subject.profileId
853
+ } })) if (other.id !== punch.id && overlapping({
854
+ start: startedAt.getTime(),
855
+ end: endedAt.getTime()
856
+ }, {
857
+ start: other.startedAt.getTime(),
858
+ end: other.endedAt?.getTime() ?? Number.POSITIVE_INFINITY
859
+ })) throw new AttendanceConflict("Punches must not overlap.");
860
+ await this.guard(tx, subject, [{
861
+ startsAt: punch.startedAt,
862
+ endsAt: punch.endedAt
863
+ }, {
864
+ startsAt: startedAt,
865
+ endsAt: endedAt
866
+ }]);
867
+ const entry = await this.editableEntry(tx, punch);
868
+ punch.startedAt = startedAt;
869
+ punch.endedAt = endedAt;
870
+ punch.reviewRequired = false;
871
+ await this.settle(tx, punch, children.breaks, entry);
872
+ return record("punch.corrected", punch, punch.id, before, punchState(punch));
873
+ });
874
+ }
875
+ /** Record a missed punch retroactively, as an already-closed interval. */
876
+ async addPunch(input) {
877
+ return this.run(input, async (tx, subject, record) => {
878
+ const startedAt = instant(input.startedAt, "startedAt");
879
+ const endedAt = instant(input.endedAt, "endedAt");
880
+ const meta = attendanceProvenance(input);
881
+ if (endedAt <= startedAt) throw new AttendanceConflict("A punch must end after it starts.");
882
+ for (const other of await tx.punches.list({ where: {
883
+ tenantId: subject.tenantId,
884
+ profileId: subject.profileId
885
+ } })) if (overlapping({
886
+ start: startedAt.getTime(),
887
+ end: endedAt.getTime()
888
+ }, {
889
+ start: other.startedAt.getTime(),
890
+ end: other.endedAt?.getTime() ?? Number.POSITIVE_INFINITY
891
+ })) throw new AttendanceConflict("Punches must not overlap.");
892
+ await this.guard(tx, subject, [{
893
+ startsAt: startedAt,
894
+ endsAt: endedAt
895
+ }]);
896
+ const punch = await tx.punches.create({
897
+ tenantId: subject.tenantId,
898
+ profileId: subject.profileId,
899
+ startedAt,
900
+ endedAt,
901
+ durationSeconds: Math.round((endedAt.getTime() - startedAt.getTime()) / 1e3),
902
+ ...meta,
903
+ _insertOnly: true
904
+ });
905
+ return record("punch.added", punch, punch.id, null, punchState(punch));
906
+ });
907
+ }
908
+ /** Add a break to a closed punch, or change one (times and/or paid). */
909
+ async saveBreak(input) {
910
+ return this.run(input, async (tx, subject, record) => {
911
+ const punch = await this.closedPunch(tx, subject, input.punchId);
912
+ const children = await this.children(tx, punch);
913
+ const existing = input.breakId ? children.breaks.find((b) => b.id === input.breakId) : void 0;
914
+ if (input.breakId && !existing) throw new AttendanceConflict("Break not found in that punch.");
915
+ if (!existing && (input.startedAt === void 0 || input.endedAt === void 0)) throw new AttendanceConflict("A new break needs startedAt and endedAt.");
916
+ if (input.paid !== void 0 && typeof input.paid !== "boolean") throw new AttendanceConflict("paid must be boolean.");
917
+ const next = {
918
+ startedAt: instant(input.startedAt ?? existing?.startedAt, "startedAt"),
919
+ endedAt: instant(input.endedAt ?? existing?.endedAt ?? void 0, "endedAt"),
920
+ paid: input.paid ?? existing?.paid ?? false,
921
+ kind: input.kind === void 0 ? existing?.kind ?? "" : attendanceLabel(input.kind, "kind")
922
+ };
923
+ const rest = children.breaks.filter((b) => b !== existing);
924
+ validateLayout(this.layout(punch.startedAt, punch.endedAt, {
925
+ breaks: [...rest, next],
926
+ segments: children.segments
927
+ }));
928
+ await this.guard(tx, subject, [{
929
+ startsAt: punch.startedAt,
930
+ endsAt: punch.endedAt
931
+ }]);
932
+ const entry = await this.editableEntry(tx, punch);
933
+ const before = existing ? breakState(existing) : null;
934
+ let target;
935
+ if (existing) {
936
+ existing.startedAt = next.startedAt;
937
+ existing.endedAt = next.endedAt;
938
+ existing.paid = next.paid;
939
+ existing.kind = next.kind;
940
+ await persistCorrection(existing);
941
+ target = existing;
942
+ } else {
943
+ target = await new AttendanceBreak({
944
+ db: tx.db,
945
+ id: crypto.randomUUID(),
946
+ _skipLoad: true
947
+ }).initialize();
948
+ Object.assign(target, {
949
+ tenantId: subject.tenantId,
950
+ punchId: punch.id ?? void 0,
951
+ ...next
952
+ });
953
+ await persistCorrection(target);
954
+ }
955
+ await this.settle(tx, punch, [...rest, target], entry);
956
+ return record(existing ? "break.changed" : "break.added", punch, target.id, before, breakState(target));
957
+ });
958
+ }
959
+ /** Remove a break from a closed punch. */
960
+ async removeBreak(input) {
961
+ return this.run(input, async (tx, subject, record) => {
962
+ const [target] = await tx.breaks.list({
963
+ where: {
964
+ tenantId: subject.tenantId,
965
+ id: input.breakId
966
+ },
967
+ limit: 1
968
+ });
969
+ if (!target) throw new AttendanceConflict("Break not found.");
970
+ const punch = await this.closedPunch(tx, subject, target.punchId);
971
+ const children = await this.children(tx, punch);
972
+ await this.guard(tx, subject, [{
973
+ startsAt: punch.startedAt,
974
+ endsAt: punch.endedAt
975
+ }]);
976
+ const entry = await this.editableEntry(tx, punch);
977
+ const before = breakState(target);
978
+ await deleteCorrection(target);
979
+ await this.settle(tx, punch, children.breaks.filter((b) => b.id !== target.id), entry);
980
+ return record("break.removed", punch, target.id, before, null);
981
+ });
982
+ }
983
+ /** Add a work segment to a closed punch, or change one (times and/or work attribution). */
984
+ async saveSegment(input) {
985
+ return this.run(input, async (tx, subject, record) => {
986
+ const punch = await this.closedPunch(tx, subject, input.punchId);
987
+ const children = await this.children(tx, punch);
988
+ const existing = input.segmentId ? children.segments.find((s) => s.id === input.segmentId) : void 0;
989
+ if (input.segmentId && !existing) throw new AttendanceConflict("Work segment not found in that punch.");
990
+ if (!existing && (input.startedAt === void 0 || input.endedAt === void 0 || !input.work)) throw new AttendanceConflict("A new work segment needs startedAt, endedAt and work.");
991
+ if (input.work) validateAttendanceWork(input.work);
992
+ const next = {
993
+ startedAt: instant(input.startedAt ?? existing?.startedAt, "startedAt"),
994
+ endedAt: instant(input.endedAt ?? existing?.endedAt ?? void 0, "endedAt")
995
+ };
996
+ const rest = children.segments.filter((s) => s !== existing);
997
+ validateLayout(this.layout(punch.startedAt, punch.endedAt, {
998
+ breaks: children.breaks,
999
+ segments: [...rest, next]
1000
+ }));
1001
+ await this.guard(tx, subject, [{
1002
+ startsAt: punch.startedAt,
1003
+ endsAt: punch.endedAt
1004
+ }]);
1005
+ const before = existing ? segmentState(existing) : null;
1006
+ let target;
1007
+ if (existing) {
1008
+ existing.startedAt = next.startedAt;
1009
+ existing.endedAt = next.endedAt;
1010
+ if (input.work) this.attribute(existing, input.work);
1011
+ await persistCorrection(existing);
1012
+ target = existing;
1013
+ } else {
1014
+ target = await new AttendanceSegment({
1015
+ db: tx.db,
1016
+ id: crypto.randomUUID(),
1017
+ _skipLoad: true
1018
+ }).initialize();
1019
+ Object.assign(target, {
1020
+ tenantId: subject.tenantId,
1021
+ punchId: punch.id ?? void 0,
1022
+ workRefType: input.work.workRefType,
1023
+ workRefId: input.work.workRefId,
1024
+ taskRefType: input.work.taskRefType ?? "",
1025
+ taskRefId: input.work.taskRefId ?? "",
1026
+ kind: input.work.kind ?? "",
1027
+ description: input.work.description,
1028
+ ...next,
1029
+ closedReason: "stop"
1030
+ });
1031
+ await persistCorrection(target);
1032
+ }
1033
+ return record(existing ? "segment.changed" : "segment.added", punch, target.id, before, segmentState(target));
1034
+ });
1035
+ }
1036
+ /** Remove a work segment from a closed punch. */
1037
+ async removeSegment(input) {
1038
+ return this.run(input, async (tx, subject, record) => {
1039
+ const [target] = await tx.segments.list({
1040
+ where: {
1041
+ tenantId: subject.tenantId,
1042
+ id: input.segmentId
1043
+ },
1044
+ limit: 1
1045
+ });
1046
+ if (!target) throw new AttendanceConflict("Work segment not found.");
1047
+ const punch = await this.closedPunch(tx, subject, target.punchId);
1048
+ await this.guard(tx, subject, [{
1049
+ startsAt: punch.startedAt,
1050
+ endsAt: punch.endedAt
1051
+ }]);
1052
+ const before = segmentState(target);
1053
+ await deleteCorrection(target);
1054
+ return record("segment.removed", punch, target.id, before, null);
1055
+ });
1056
+ }
1057
+ /**
1058
+ * The subject confirms they have seen a correction that required it. Only
1059
+ * the subject (this service's actor) may; it can be done once.
1060
+ */
1061
+ async acknowledge(correctionId, at = /* @__PURE__ */ new Date()) {
1062
+ instant(at, "at");
1063
+ const id = await this.attendance(this.actor.profileId).withTransaction(async (tx) => {
1064
+ const [row] = await (await AttendanceCorrectionCollection.create({ db: tx.db })).list({
1065
+ where: {
1066
+ tenantId: this.actor.tenantId,
1067
+ id: correctionId
1068
+ },
1069
+ limit: 1
1070
+ });
1071
+ if (!row) throw new AttendanceConflict("Correction not found.");
1072
+ if (row.subjectProfileId !== this.actor.profileId) throw new AttendanceConflict("Only the subject can acknowledge a correction.");
1073
+ if (!row.acknowledgementRequired) throw new AttendanceConflict("This correction does not require acknowledgement.");
1074
+ if (row.acknowledgedAt) throw new AttendanceConflict("Correction already acknowledged.");
1075
+ row.acknowledgedAt = at;
1076
+ row.acknowledgedByProfileId = this.actor.profileId;
1077
+ await persistCorrection(row);
1078
+ return row.id;
1079
+ });
1080
+ return this.read(id);
1081
+ }
1082
+ /** Corrections to one punch, oldest first. */
1083
+ async listForPunch(punchId) {
1084
+ return this.attendance(this.actor.profileId).withTransaction(async (tx) => await (await AttendanceCorrectionCollection.create({ db: tx.db })).list({
1085
+ where: {
1086
+ tenantId: this.actor.tenantId,
1087
+ punchId
1088
+ },
1089
+ orderBy: "created_at ASC"
1090
+ }));
1091
+ }
1092
+ /** Corrections of the actor's own time that still await their acknowledgement. */
1093
+ async pendingAcknowledgements() {
1094
+ return this.attendance(this.actor.profileId).withTransaction(async (tx) => (await (await AttendanceCorrectionCollection.create({ db: tx.db })).list({
1095
+ where: {
1096
+ tenantId: this.actor.tenantId,
1097
+ subjectProfileId: this.actor.profileId
1098
+ },
1099
+ orderBy: "created_at ASC"
1100
+ })).filter((row) => row.acknowledgementRequired && !row.acknowledgedAt));
1101
+ }
1102
+ attendance(subjectProfileId) {
1103
+ return new AttendanceService(this.db, {
1104
+ tenantId: this.actor.tenantId,
1105
+ profileId: subjectProfileId
1106
+ });
1107
+ }
1108
+ async read(id) {
1109
+ return this.attendance(this.actor.profileId).withTransaction(async (tx) => {
1110
+ const [row] = await (await AttendanceCorrectionCollection.create({ db: tx.db })).list({
1111
+ where: {
1112
+ tenantId: this.actor.tenantId,
1113
+ id
1114
+ },
1115
+ limit: 1
1116
+ });
1117
+ if (!row) throw new Error("Committed attendance correction disappeared.");
1118
+ return row;
1119
+ });
1120
+ }
1121
+ async run(request, operation) {
1122
+ const reason = typeof request.reason === "string" ? request.reason.trim() : "";
1123
+ if (!reason) throw new AttendanceConflict("A correction needs a reason.");
1124
+ if (request.acknowledgementRequired !== void 0 && typeof request.acknowledgementRequired !== "boolean") throw new AttendanceConflict("acknowledgementRequired must be boolean.");
1125
+ if (!request.subjectProfileId) throw new AttendanceConflict("A correction needs a subject.");
1126
+ const subject = {
1127
+ tenantId: this.actor.tenantId,
1128
+ profileId: attendanceIdentity(this.db, request.subjectProfileId)
1129
+ };
1130
+ const id = await this.attendance(subject.profileId).withTransaction(async (tx) => {
1131
+ await AttendanceCorrectionCollection.create({ db: tx.db });
1132
+ return operation(tx, subject, async (kind, punch, targetId, before, after) => {
1133
+ const row = await new AttendanceCorrection({
1134
+ db: tx.db,
1135
+ id: crypto.randomUUID(),
1136
+ _skipLoad: true
1137
+ }).initialize();
1138
+ Object.assign(row, {
1139
+ tenantId: subject.tenantId,
1140
+ subjectProfileId: subject.profileId,
1141
+ actorProfileId: this.actor.profileId,
1142
+ kind,
1143
+ punchId: punch.id ?? void 0,
1144
+ targetId,
1145
+ beforeState: JSON.stringify(before),
1146
+ afterState: JSON.stringify(after),
1147
+ reason,
1148
+ acknowledgementRequired: request.acknowledgementRequired ?? false
1149
+ });
1150
+ await persistCorrection(row);
1151
+ return row.id;
1152
+ });
1153
+ });
1154
+ return this.read(id);
1155
+ }
1156
+ async closedPunch(tx, subject, punchId) {
1157
+ const [punch] = await tx.punches.list({
1158
+ where: {
1159
+ tenantId: subject.tenantId,
1160
+ profileId: subject.profileId,
1161
+ id: punchId
1162
+ },
1163
+ limit: 1
1164
+ });
1165
+ if (!punch) throw new AttendanceConflict("Punch not found for subject.");
1166
+ if (!punch.endedAt) throw new AttendanceConflict("Only closed punches are corrected; use AttendanceService for an open one.");
1167
+ return punch;
1168
+ }
1169
+ async children(tx, punch) {
1170
+ const where = {
1171
+ tenantId: punch.tenantId,
1172
+ punchId: punch.id
1173
+ };
1174
+ const breaks = await tx.breaks.list({
1175
+ where,
1176
+ orderBy: "started_at ASC"
1177
+ });
1178
+ const segments = await tx.segments.list({
1179
+ where,
1180
+ orderBy: "started_at ASC"
1181
+ });
1182
+ if (breaks.some((b) => !b.endedAt) || segments.some((s) => !s.endedAt)) throw new AttendanceConflict("Closed punch has an open break or segment.");
1183
+ return {
1184
+ breaks,
1185
+ segments
1186
+ };
1187
+ }
1188
+ layout(startedAt, endedAt, children) {
1189
+ return {
1190
+ startsAt: startedAt.getTime(),
1191
+ endsAt: endedAt.getTime(),
1192
+ breaks: children.breaks.map((b) => ({
1193
+ startedAt: b.startedAt,
1194
+ endedAt: b.endedAt,
1195
+ paid: b.paid
1196
+ })),
1197
+ segments: children.segments.map((s) => ({
1198
+ startedAt: s.startedAt,
1199
+ endedAt: s.endedAt
1200
+ }))
1201
+ };
1202
+ }
1203
+ attribute(segment, work) {
1204
+ segment.workRefType = work.workRefType;
1205
+ segment.workRefId = work.workRefId;
1206
+ segment.taskRefType = work.taskRefType ?? "";
1207
+ segment.taskRefId = work.taskRefId ?? "";
1208
+ segment.kind = work.kind ?? "";
1209
+ segment.description = work.description;
1210
+ }
1211
+ async guard(tx, subject, intervals) {
1212
+ const refusal = await this.options.guard?.(tx, subject, intervals);
1213
+ if (refusal) throw new AttendanceConflict(refusal);
1214
+ }
1215
+ /** The punch's still-draft evidence entry, refusing when it is no longer editable. */
1216
+ async editableEntry(tx, punch) {
1217
+ if (!punch.serviceTimeEntryId) return null;
1218
+ const [entry] = await (await ServiceTimeEntryCollection.create({ db: tx.db })).list({
1219
+ where: {
1220
+ id: punch.serviceTimeEntryId,
1221
+ tenantId: punch.tenantId
1222
+ },
1223
+ limit: 1
1224
+ });
1225
+ if (!entry) return null;
1226
+ if (entry.status !== "draft") throw new AttendanceConflict(`Linked service evidence is ${entry.status}; correct it with ServiceEvidenceService first.`);
1227
+ return entry;
1228
+ }
1229
+ /** Recompute the punch's durations from its breaks and persist it and its draft entry. */
1230
+ async settle(tx, punch, breaks, entry) {
1231
+ const unpaidMs = breaks.filter((b) => !b.paid).reduce((sum, b) => sum + b.endedAt.getTime() - b.startedAt.getTime(), 0);
1232
+ punch.unpaidBreakSeconds = Math.round(unpaidMs / 1e3);
1233
+ punch.durationSeconds = Math.round((punch.endedAt.getTime() - punch.startedAt.getTime() - unpaidMs) / 1e3);
1234
+ await persistCorrection(punch);
1235
+ if (entry) {
1236
+ entry.startedAt = punch.startedAt;
1237
+ entry.endedAt = punch.endedAt;
1238
+ entry.durationSeconds = punch.durationSeconds;
1239
+ await entry.save();
1240
+ }
1241
+ }
1242
+ };
1243
+ //#endregion
1244
+ export { continueSegment as a, openSegment as c, validateAttendanceWork as d, ATTENDANCE_CORRECTION_KINDS as f, closeOpenSegment as i, replaceSegment as l, AttendanceCorrectionCollection as m, AttendanceService as n, endSegment as o, AttendanceCorrection as p, beginSegment as r, listSegments as s, AttendanceCorrectionService as t, AttendanceConflict as u };
1245
+
1246
+ //# sourceMappingURL=attendance-DGVy-iHI.js.map