@happyvertical/smrt-timesheets 0.53.1 → 0.53.3
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 +32 -21
- package/README.md +124 -58
- package/agents/period-rollup.md +130 -0
- package/dist/attendance/models.d.ts +59 -0
- package/dist/attendance/models.d.ts.map +1 -0
- package/dist/attendance/service.d.ts +64 -0
- package/dist/attendance/service.d.ts.map +1 -0
- package/dist/attendance.d.ts +5 -0
- package/dist/attendance.d.ts.map +1 -0
- package/dist/attendance.js +323 -0
- package/dist/attendance.js.map +1 -0
- package/dist/chunks/attendance-Dg_5VkMj.js +60 -0
- package/dist/chunks/attendance-Dg_5VkMj.js.map +1 -0
- package/dist/chunks/models-DRj8BOuG.js +166 -0
- package/dist/chunks/models-DRj8BOuG.js.map +1 -0
- package/dist/chunks/rollup-C_dHTdJs.js +425 -0
- package/dist/chunks/rollup-C_dHTdJs.js.map +1 -0
- package/dist/chunks/service-time-entry-BeOsL7Uv.js +190 -0
- package/dist/chunks/service-time-entry-BeOsL7Uv.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -205
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +2553 -652
- package/dist/rollup/attendance.d.ts +8 -0
- package/dist/rollup/attendance.d.ts.map +1 -0
- package/dist/rollup/calculation.d.ts +7 -0
- package/dist/rollup/calculation.d.ts.map +1 -0
- package/dist/rollup/models.d.ts +47 -0
- package/dist/rollup/models.d.ts.map +1 -0
- package/dist/rollup/service.d.ts +37 -0
- package/dist/rollup/service.d.ts.map +1 -0
- package/dist/rollup/types.d.ts +38 -0
- package/dist/rollup/types.d.ts.map +1 -0
- package/dist/rollup/write.d.ts +4 -0
- package/dist/rollup/write.d.ts.map +1 -0
- package/dist/rollup.d.ts +5 -0
- package/dist/rollup.d.ts.map +1 -0
- package/dist/rollup.js +2 -0
- package/dist/smrt-knowledge.json +1319 -433
- package/package.json +19 -10
- package/dist/svelte/components/__tests__/DurationDisplay.test.js +0 -57
- package/dist/svelte/components/__tests__/TimeEntryApprovalQueue.test.js +0 -114
- package/dist/svelte/components/__tests__/TimeEntryCard.test.js +0 -45
- package/dist/svelte/components/__tests__/TimeEntryList.test.js +0 -50
- package/dist/svelte/components/__tests__/TimeSummary.test.js +0 -39
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { r as ServiceTimeEntryCollection } from "./chunks/service-time-entry-BeOsL7Uv.js";
|
|
2
|
+
import { a as AttendanceReplay, i as AttendancePunchCollection, n as AttendanceBreakCollection, o as AttendanceReplayCollection, r as AttendancePunch, t as AttendanceBreak } from "./chunks/models-DRj8BOuG.js";
|
|
3
|
+
import { isEmbeddedDatabase, isPostgresDatabase, withEmbeddedWriteTransaction } from "@happyvertical/smrt-core";
|
|
4
|
+
import { getTenantId, withTenant } from "@happyvertical/smrt-tenancy";
|
|
5
|
+
//#region src/attendance/service.ts
|
|
6
|
+
var AttendanceConflict = class extends Error {};
|
|
7
|
+
var AttendanceService = class {
|
|
8
|
+
constructor(db, actor) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
this.actor = actor;
|
|
11
|
+
if (!actor.tenantId || !actor.profileId) throw new Error("Attendance requires a trusted tenant/profile actor.");
|
|
12
|
+
this.actor = {
|
|
13
|
+
tenantId: this.identity(actor.tenantId),
|
|
14
|
+
profileId: this.identity(actor.profileId)
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
db;
|
|
18
|
+
actor;
|
|
19
|
+
identity(value) {
|
|
20
|
+
if (!isPostgresDatabase(this.db)) return value;
|
|
21
|
+
const hex = value.replace(/[{}-]/g, "").toLowerCase();
|
|
22
|
+
if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error("PostgreSQL attendance identities must be UUIDs.");
|
|
23
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
24
|
+
}
|
|
25
|
+
async transact(operation) {
|
|
26
|
+
const current = getTenantId();
|
|
27
|
+
if (current && this.identity(current) !== this.actor.tenantId) throw new Error("Attendance actor differs from active tenant.");
|
|
28
|
+
return withTenant({ tenantId: this.actor.tenantId }, async () => {
|
|
29
|
+
const result = await withEmbeddedWriteTransaction(this.db, isEmbeddedDatabase(this.db), async (db) => {
|
|
30
|
+
if (isPostgresDatabase(db)) await db.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", JSON.stringify([
|
|
31
|
+
"smrt.attendance",
|
|
32
|
+
this.actor.tenantId,
|
|
33
|
+
this.actor.profileId
|
|
34
|
+
]));
|
|
35
|
+
return operation({
|
|
36
|
+
db,
|
|
37
|
+
punches: await AttendancePunchCollection.create({ db }),
|
|
38
|
+
breaks: await AttendanceBreakCollection.create({ db }),
|
|
39
|
+
replays: await AttendanceReplayCollection.create({ db })
|
|
40
|
+
});
|
|
41
|
+
}, true);
|
|
42
|
+
if (result instanceof AttendancePunch) {
|
|
43
|
+
const [fresh] = await (await AttendancePunchCollection.create({ db: this.db })).list({
|
|
44
|
+
where: {
|
|
45
|
+
...this.actor,
|
|
46
|
+
id: result.id
|
|
47
|
+
},
|
|
48
|
+
limit: 1
|
|
49
|
+
});
|
|
50
|
+
if (!fresh) throw new Error("Committed attendance punch disappeared.");
|
|
51
|
+
return fresh;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
date(at) {
|
|
57
|
+
if (!(at instanceof Date) || !Number.isFinite(at.getTime())) throw new AttendanceConflict("Attendance timestamps must be valid dates.");
|
|
58
|
+
return at;
|
|
59
|
+
}
|
|
60
|
+
async open(tx) {
|
|
61
|
+
const [punch] = await tx.punches.list({
|
|
62
|
+
where: {
|
|
63
|
+
...this.actor,
|
|
64
|
+
endedAt: null
|
|
65
|
+
},
|
|
66
|
+
limit: 1
|
|
67
|
+
});
|
|
68
|
+
if (!punch) throw new AttendanceConflict("No open punch.");
|
|
69
|
+
return punch;
|
|
70
|
+
}
|
|
71
|
+
async chronological(tx, at) {
|
|
72
|
+
const open = await tx.punches.list({
|
|
73
|
+
where: {
|
|
74
|
+
...this.actor,
|
|
75
|
+
endedAt: null
|
|
76
|
+
},
|
|
77
|
+
limit: 1
|
|
78
|
+
});
|
|
79
|
+
const rows = open.length ? open : await tx.punches.list({
|
|
80
|
+
where: { ...this.actor },
|
|
81
|
+
orderBy: "ended_at DESC",
|
|
82
|
+
limit: 1
|
|
83
|
+
});
|
|
84
|
+
if (!rows.length) return;
|
|
85
|
+
const punch = rows[0];
|
|
86
|
+
const breaks = await tx.breaks.list({ where: {
|
|
87
|
+
tenantId: this.actor.tenantId,
|
|
88
|
+
punchId: punch.id
|
|
89
|
+
} });
|
|
90
|
+
const latest = Math.max(punch.startedAt.getTime(), punch.endedAt?.getTime() ?? 0, ...breaks.flatMap((b) => [b.startedAt.getTime(), b.endedAt?.getTime() ?? 0]));
|
|
91
|
+
if (at.getTime() < latest) throw new AttendanceConflict("Tap predates committed attendance; manual review required.");
|
|
92
|
+
}
|
|
93
|
+
/** Open one punch; the database unique slot also protects direct model writers. */
|
|
94
|
+
async punchIn(at = /* @__PURE__ */ new Date()) {
|
|
95
|
+
return this.transact((tx) => this.transition(tx, "punchIn", this.date(at)));
|
|
96
|
+
}
|
|
97
|
+
/** Close the punch and any active break; optionally create linked draft evidence. */
|
|
98
|
+
async punchOut(at, work) {
|
|
99
|
+
return this.transact((tx) => this.transition(tx, "punchOut", this.date(at), false, work));
|
|
100
|
+
}
|
|
101
|
+
/** Begin a break; paid defaults false and must be explicitly configured by the caller. */
|
|
102
|
+
async startBreak(at, paid = false) {
|
|
103
|
+
return this.transact((tx) => this.transition(tx, "startBreak", this.date(at), paid));
|
|
104
|
+
}
|
|
105
|
+
/** End the current break. */
|
|
106
|
+
async endBreak(at) {
|
|
107
|
+
return this.transact((tx) => this.transition(tx, "endBreak", this.date(at)));
|
|
108
|
+
}
|
|
109
|
+
async transition(tx, action, at, paid = false, work) {
|
|
110
|
+
await this.chronological(tx, at);
|
|
111
|
+
if (action === "punchIn") {
|
|
112
|
+
if ((await tx.punches.list({
|
|
113
|
+
where: {
|
|
114
|
+
...this.actor,
|
|
115
|
+
endedAt: null
|
|
116
|
+
},
|
|
117
|
+
limit: 1
|
|
118
|
+
})).length) throw new AttendanceConflict("A punch is already open.");
|
|
119
|
+
return tx.punches.create({
|
|
120
|
+
...this.actor,
|
|
121
|
+
startedAt: at,
|
|
122
|
+
_insertOnly: true
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const punch = await this.open(tx);
|
|
126
|
+
const breaks = await tx.breaks.list({ where: {
|
|
127
|
+
tenantId: this.actor.tenantId,
|
|
128
|
+
punchId: punch.id
|
|
129
|
+
} });
|
|
130
|
+
const active = breaks.find((b) => !b.endedAt);
|
|
131
|
+
if (action === "startBreak") {
|
|
132
|
+
if (active) throw new AttendanceConflict("A break is already open.");
|
|
133
|
+
if (typeof paid !== "boolean") throw new AttendanceConflict("paid must be boolean.");
|
|
134
|
+
await tx.breaks.create({
|
|
135
|
+
tenantId: this.actor.tenantId,
|
|
136
|
+
punchId: punch.id ?? void 0,
|
|
137
|
+
startedAt: at,
|
|
138
|
+
paid,
|
|
139
|
+
_insertOnly: true
|
|
140
|
+
});
|
|
141
|
+
return punch;
|
|
142
|
+
}
|
|
143
|
+
if (action !== "endBreak" && action !== "punchOut") throw new AttendanceConflict("Unknown attendance action.");
|
|
144
|
+
if (action === "endBreak" && !active) throw new AttendanceConflict("No open break.");
|
|
145
|
+
if (action === "punchOut" && work) {
|
|
146
|
+
this.validateWork(work);
|
|
147
|
+
const unpaidMilliseconds2 = breaks.filter((b) => !b.paid).reduce((sum, b) => sum + (b.endedAt ?? at).getTime() - b.startedAt.getTime(), 0);
|
|
148
|
+
if (Math.round((at.getTime() - punch.startedAt.getTime() - unpaidMilliseconds2) / 1e3) <= 0) throw new AttendanceConflict("Only closed punches with positive duration can produce service evidence.");
|
|
149
|
+
}
|
|
150
|
+
if (active) {
|
|
151
|
+
active.endedAt = at;
|
|
152
|
+
await active.save();
|
|
153
|
+
}
|
|
154
|
+
if (action === "endBreak") return punch;
|
|
155
|
+
punch.endedAt = at;
|
|
156
|
+
const unpaidMilliseconds = breaks.filter((b) => !b.paid).reduce((sum, b) => sum + b.endedAt.getTime() - b.startedAt.getTime(), 0);
|
|
157
|
+
punch.unpaidBreakSeconds = Math.round(unpaidMilliseconds / 1e3);
|
|
158
|
+
punch.durationSeconds = Math.round((at.getTime() - punch.startedAt.getTime() - unpaidMilliseconds) / 1e3);
|
|
159
|
+
await punch.save();
|
|
160
|
+
if (work) await this.link(tx, punch, work);
|
|
161
|
+
return punch;
|
|
162
|
+
}
|
|
163
|
+
validateWork(work) {
|
|
164
|
+
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.");
|
|
165
|
+
}
|
|
166
|
+
async link(tx, punch, work) {
|
|
167
|
+
this.validateWork(work);
|
|
168
|
+
if (!punch.endedAt || punch.durationSeconds <= 0) throw new AttendanceConflict("Only closed punches with positive duration can produce service evidence.");
|
|
169
|
+
const entries = await ServiceTimeEntryCollection.create({ db: tx.db });
|
|
170
|
+
if (punch.serviceTimeEntryId) {
|
|
171
|
+
const [existing] = await entries.list({
|
|
172
|
+
where: {
|
|
173
|
+
id: punch.serviceTimeEntryId,
|
|
174
|
+
tenantId: this.actor.tenantId
|
|
175
|
+
},
|
|
176
|
+
limit: 1
|
|
177
|
+
});
|
|
178
|
+
if (!existing || existing.workRefType !== work.workRefType || existing.workRefId !== work.workRefId || existing.description !== work.description) throw new AttendanceConflict("Punch already linked to different work.");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const entry = await entries.create({
|
|
182
|
+
tenantId: this.actor.tenantId,
|
|
183
|
+
participantKind: "human",
|
|
184
|
+
participantProfileId: this.actor.profileId,
|
|
185
|
+
workRefType: work.workRefType,
|
|
186
|
+
workRefId: work.workRefId,
|
|
187
|
+
description: work.description,
|
|
188
|
+
source: "timer",
|
|
189
|
+
startedAt: punch.startedAt,
|
|
190
|
+
endedAt: punch.endedAt,
|
|
191
|
+
durationSeconds: punch.durationSeconds,
|
|
192
|
+
status: "draft",
|
|
193
|
+
metadata: JSON.stringify({ attendancePunchId: punch.id }),
|
|
194
|
+
_insertOnly: true
|
|
195
|
+
});
|
|
196
|
+
if (!entry.id) throw new Error("Saved service entry has no id.");
|
|
197
|
+
punch.serviceTimeEntryId = entry.id;
|
|
198
|
+
await punch.save();
|
|
199
|
+
}
|
|
200
|
+
/** Idempotently link a closed, owned punch to draft service evidence. */
|
|
201
|
+
async linkServiceTimeEntry(punchId, work) {
|
|
202
|
+
return this.transact(async (tx) => {
|
|
203
|
+
const [punch] = await tx.punches.list({
|
|
204
|
+
where: {
|
|
205
|
+
...this.actor,
|
|
206
|
+
id: punchId
|
|
207
|
+
},
|
|
208
|
+
limit: 1
|
|
209
|
+
});
|
|
210
|
+
if (!punch) throw new AttendanceConflict("Punch not found for actor.");
|
|
211
|
+
await this.link(tx, punch, work);
|
|
212
|
+
return punch;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/** Close at startedAt + maxOpenSeconds once due, flagging the punch for review. */
|
|
216
|
+
async autoClose(now, maxOpenSeconds) {
|
|
217
|
+
this.date(now);
|
|
218
|
+
if (!Number.isSafeInteger(maxOpenSeconds) || maxOpenSeconds <= 0) throw new AttendanceConflict("maxOpenSeconds must be a positive integer.");
|
|
219
|
+
return this.transact(async (tx) => {
|
|
220
|
+
const [punch] = await tx.punches.list({
|
|
221
|
+
where: {
|
|
222
|
+
...this.actor,
|
|
223
|
+
endedAt: null
|
|
224
|
+
},
|
|
225
|
+
limit: 1
|
|
226
|
+
});
|
|
227
|
+
if (!punch) return null;
|
|
228
|
+
const deadline = new Date(punch.startedAt.getTime() + maxOpenSeconds * 1e3);
|
|
229
|
+
if (deadline > now) return null;
|
|
230
|
+
const closed = await this.transition(tx, "punchOut", deadline);
|
|
231
|
+
closed.reviewRequired = true;
|
|
232
|
+
await closed.save();
|
|
233
|
+
return closed;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Replay device timestamps in order (ties use clientId). Committed attendance
|
|
238
|
+
* wins: stale/state-conflicting taps get durable refusals; identical retries
|
|
239
|
+
* return the same outcome, changed payloads using a key are rejected. A
|
|
240
|
+
* database failure rolls back its tap and receipt and remains retryable.
|
|
241
|
+
*/
|
|
242
|
+
async replay(taps) {
|
|
243
|
+
for (const tap of taps) {
|
|
244
|
+
if (!tap || typeof tap.at !== "string" || typeof tap.clientId !== "string" || !tap.clientId || ![
|
|
245
|
+
"punchIn",
|
|
246
|
+
"punchOut",
|
|
247
|
+
"startBreak",
|
|
248
|
+
"endBreak"
|
|
249
|
+
].includes(tap.action)) throw new AttendanceConflict("Malformed attendance tap.");
|
|
250
|
+
this.date(new Date(tap.at));
|
|
251
|
+
if (tap.work !== void 0) this.validateWork(tap.work);
|
|
252
|
+
if (tap.paid !== void 0 && typeof tap.paid !== "boolean") throw new AttendanceConflict("paid must be boolean.");
|
|
253
|
+
}
|
|
254
|
+
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));
|
|
255
|
+
const outcomes = [];
|
|
256
|
+
for (const tap of ordered) outcomes.push(await this.transact(async (tx) => {
|
|
257
|
+
const replayKey = JSON.stringify([
|
|
258
|
+
this.actor.tenantId,
|
|
259
|
+
this.actor.profileId,
|
|
260
|
+
tap.clientId
|
|
261
|
+
]);
|
|
262
|
+
const content = JSON.stringify([
|
|
263
|
+
tap.action,
|
|
264
|
+
new Date(tap.at).toISOString(),
|
|
265
|
+
tap.paid ?? false,
|
|
266
|
+
tap.work?.workRefType ?? null,
|
|
267
|
+
tap.work?.workRefId ?? null,
|
|
268
|
+
tap.work?.description ?? null
|
|
269
|
+
]);
|
|
270
|
+
const [receipt] = await tx.replays.list({
|
|
271
|
+
where: {
|
|
272
|
+
tenantId: this.actor.tenantId,
|
|
273
|
+
replayKey
|
|
274
|
+
},
|
|
275
|
+
limit: 1
|
|
276
|
+
});
|
|
277
|
+
if (receipt) {
|
|
278
|
+
if (receipt.content !== content) throw new AttendanceConflict("Client id reused with different content.");
|
|
279
|
+
const outcome2 = receipt.getOutcome();
|
|
280
|
+
if (outcome2.clientId !== tap.clientId) throw new Error("Stored attendance receipt belongs to another client id.");
|
|
281
|
+
return outcome2;
|
|
282
|
+
}
|
|
283
|
+
let outcome;
|
|
284
|
+
try {
|
|
285
|
+
outcome = await withEmbeddedWriteTransaction(tx.db, isEmbeddedDatabase(tx.db), async (db) => {
|
|
286
|
+
const inner = {
|
|
287
|
+
db,
|
|
288
|
+
punches: await AttendancePunchCollection.create({ db }),
|
|
289
|
+
breaks: await AttendanceBreakCollection.create({ db }),
|
|
290
|
+
replays: tx.replays
|
|
291
|
+
};
|
|
292
|
+
const punch = await this.transition(inner, tap.action, new Date(tap.at), tap.paid, tap.work);
|
|
293
|
+
punch.recordedOffline = true;
|
|
294
|
+
punch.reviewRequired = true;
|
|
295
|
+
await punch.save();
|
|
296
|
+
return {
|
|
297
|
+
clientId: tap.clientId,
|
|
298
|
+
punchId: punch.id
|
|
299
|
+
};
|
|
300
|
+
}, true);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (!(error instanceof AttendanceConflict)) throw error;
|
|
303
|
+
outcome = {
|
|
304
|
+
clientId: tap.clientId,
|
|
305
|
+
error: error.message
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
await tx.replays.create({
|
|
309
|
+
tenantId: this.actor.tenantId,
|
|
310
|
+
replayKey,
|
|
311
|
+
content,
|
|
312
|
+
outcome: JSON.stringify(outcome),
|
|
313
|
+
_insertOnly: true
|
|
314
|
+
});
|
|
315
|
+
return outcome;
|
|
316
|
+
}));
|
|
317
|
+
return outcomes;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
//#endregion
|
|
321
|
+
export { AttendanceBreak, AttendanceBreakCollection, AttendanceConflict, AttendancePunch, AttendancePunchCollection, AttendanceReplay, AttendanceReplayCollection, AttendanceService };
|
|
322
|
+
|
|
323
|
+
//# sourceMappingURL=attendance.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attendance.js","names":["unpaidMilliseconds","outcome"],"sources":["../src/attendance/service.ts"],"sourcesContent":["import {\n isEmbeddedDatabase,\n isPostgresDatabase,\n withEmbeddedWriteTransaction,\n} from '@happyvertical/smrt-core';\nimport { getTenantId, withTenant } from '@happyvertical/smrt-tenancy';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { ServiceTimeEntryCollection } from '../models/service-time-entry.js';\nimport {\n AttendanceBreakCollection,\n AttendancePunch,\n AttendancePunchCollection,\n AttendanceReplayCollection,\n type AttendanceReplayOutcome,\n} from './models.js';\n\nexport type { AttendanceReplayOutcome } from './models.js';\n\n/** Resolve these identifiers from the authenticated session, never from tap input. */\nexport interface AttendanceActor {\n tenantId: string;\n profileId: string;\n}\n/** Explicit work attribution for a closed punch's draft evidence entry. */\nexport interface AttendanceWork {\n workRefType: string;\n workRefId: string;\n description: string;\n}\n/** Offline command with a durable client-generated identifier and device timestamp. */\nexport interface AttendanceTap {\n clientId: string;\n at: string;\n action: 'punchIn' | 'punchOut' | 'startBreak' | 'endBreak';\n paid?: boolean;\n work?: AttendanceWork;\n}\n\n/** Expected state conflict, distinct from retryable database failures. */\nexport class AttendanceConflict extends Error {}\n\ntype Context = {\n db: DatabaseInterface;\n punches: AttendancePunchCollection;\n breaks: AttendanceBreakCollection;\n replays: AttendanceReplayCollection;\n};\n\n/**\n * Attendance mutations bound to a trusted tenant/profile. Applications authorize\n * the actor before constructing this service. Each transition is atomic; all\n * participating collections use the transaction executor. PostgreSQL serializes\n * this actor with an advisory lock; embedded adapters serialize writers.\n */\nexport class AttendanceService {\n constructor(\n private readonly db: DatabaseInterface,\n private readonly actor: AttendanceActor,\n ) {\n if (!actor.tenantId || !actor.profileId)\n throw new Error('Attendance requires a trusted tenant/profile actor.');\n this.actor = {\n tenantId: this.identity(actor.tenantId),\n profileId: this.identity(actor.profileId),\n };\n }\n\n private identity(value: string): string {\n if (!isPostgresDatabase(this.db)) return value;\n const hex = value.replace(/[{}-]/g, '').toLowerCase();\n if (!/^[0-9a-f]{32}$/.test(hex))\n throw new Error('PostgreSQL attendance identities must be UUIDs.');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n }\n\n private async transact<T>(\n operation: (context: Context) => Promise<T>,\n ): Promise<T> {\n const current = getTenantId();\n if (current && this.identity(current) !== this.actor.tenantId)\n throw new Error('Attendance actor differs from active tenant.');\n return withTenant({ tenantId: this.actor.tenantId }, async () => {\n const result = await withEmbeddedWriteTransaction(\n this.db,\n isEmbeddedDatabase(this.db),\n async (db) => {\n if (isPostgresDatabase(db))\n await db.query(\n 'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',\n JSON.stringify([\n 'smrt.attendance',\n this.actor.tenantId,\n this.actor.profileId,\n ]),\n );\n return operation({\n db,\n punches: await AttendancePunchCollection.create({ db }),\n breaks: await AttendanceBreakCollection.create({ db }),\n replays: await AttendanceReplayCollection.create({ db }),\n });\n },\n true,\n );\n if (result instanceof AttendancePunch) {\n const punches = await AttendancePunchCollection.create({ db: this.db });\n const [fresh] = await punches.list({\n where: { ...this.actor, id: result.id },\n limit: 1,\n });\n if (!fresh) throw new Error('Committed attendance punch disappeared.');\n return fresh as T;\n }\n return result;\n });\n }\n\n private date(at: Date): Date {\n if (!(at instanceof Date) || !Number.isFinite(at.getTime()))\n throw new AttendanceConflict(\n 'Attendance timestamps must be valid dates.',\n );\n return at;\n }\n\n private async open(tx: Context): Promise<AttendancePunch> {\n const [punch] = await tx.punches.list({\n where: { ...this.actor, endedAt: null },\n limit: 1,\n });\n if (!punch) throw new AttendanceConflict('No open punch.');\n return punch;\n }\n\n private async chronological(tx: Context, at: Date): Promise<void> {\n const open = await tx.punches.list({\n where: { ...this.actor, endedAt: null },\n limit: 1,\n });\n const rows = open.length\n ? open\n : await tx.punches.list({\n where: { ...this.actor },\n orderBy: 'ended_at DESC',\n limit: 1,\n });\n if (!rows.length) return;\n const punch = rows[0];\n const breaks = await tx.breaks.list({\n where: { tenantId: this.actor.tenantId, punchId: punch.id },\n });\n const latest = Math.max(\n punch.startedAt.getTime(),\n punch.endedAt?.getTime() ?? 0,\n ...breaks.flatMap((b) => [\n b.startedAt.getTime(),\n b.endedAt?.getTime() ?? 0,\n ]),\n );\n if (at.getTime() < latest)\n throw new AttendanceConflict(\n 'Tap predates committed attendance; manual review required.',\n );\n }\n\n /** Open one punch; the database unique slot also protects direct model writers. */\n async punchIn(at = new Date()): Promise<AttendancePunch> {\n return this.transact((tx) => this.transition(tx, 'punchIn', this.date(at)));\n }\n /** Close the punch and any active break; optionally create linked draft evidence. */\n async punchOut(at: Date, work?: AttendanceWork): Promise<AttendancePunch> {\n return this.transact((tx) =>\n this.transition(tx, 'punchOut', this.date(at), false, work),\n );\n }\n /** Begin a break; paid defaults false and must be explicitly configured by the caller. */\n async startBreak(at: Date, paid = false): Promise<AttendancePunch> {\n return this.transact((tx) =>\n this.transition(tx, 'startBreak', this.date(at), paid),\n );\n }\n /** End the current break. */\n async endBreak(at: Date): Promise<AttendancePunch> {\n return this.transact((tx) =>\n this.transition(tx, 'endBreak', this.date(at)),\n );\n }\n\n private async transition(\n tx: Context,\n action: AttendanceTap['action'],\n at: Date,\n paid = false,\n work?: AttendanceWork,\n ): Promise<AttendancePunch> {\n await this.chronological(tx, at);\n if (action === 'punchIn') {\n if (\n (\n await tx.punches.list({\n where: { ...this.actor, endedAt: null },\n limit: 1,\n })\n ).length\n )\n throw new AttendanceConflict('A punch is already open.');\n return tx.punches.create({\n ...this.actor,\n startedAt: at,\n _insertOnly: true,\n });\n }\n const punch = await this.open(tx);\n const breaks = await tx.breaks.list({\n where: { tenantId: this.actor.tenantId, punchId: punch.id },\n });\n const active = breaks.find((b) => !b.endedAt);\n if (action === 'startBreak') {\n if (active) throw new AttendanceConflict('A break is already open.');\n if (typeof paid !== 'boolean')\n throw new AttendanceConflict('paid must be boolean.');\n await tx.breaks.create({\n tenantId: this.actor.tenantId,\n punchId: punch.id ?? undefined,\n startedAt: at,\n paid,\n _insertOnly: true,\n });\n return punch;\n }\n if (action !== 'endBreak' && action !== 'punchOut')\n throw new AttendanceConflict('Unknown attendance action.');\n if (action === 'endBreak' && !active)\n throw new AttendanceConflict('No open break.');\n if (action === 'punchOut' && work) {\n this.validateWork(work);\n const unpaidMilliseconds = breaks\n .filter((b) => !b.paid)\n .reduce(\n (sum, b) => sum + (b.endedAt ?? at).getTime() - b.startedAt.getTime(),\n 0,\n );\n if (\n Math.round(\n (at.getTime() - punch.startedAt.getTime() - unpaidMilliseconds) /\n 1000,\n ) <= 0\n )\n throw new AttendanceConflict(\n 'Only closed punches with positive duration can produce service evidence.',\n );\n }\n if (active) {\n active.endedAt = at;\n await active.save();\n }\n if (action === 'endBreak') return punch;\n punch.endedAt = at;\n const unpaidMilliseconds = breaks\n .filter((b) => !b.paid)\n .reduce(\n (sum, b) => sum + b.endedAt!.getTime() - b.startedAt.getTime(),\n 0,\n );\n punch.unpaidBreakSeconds = Math.round(unpaidMilliseconds / 1000);\n punch.durationSeconds = Math.round(\n (at.getTime() - punch.startedAt.getTime() - unpaidMilliseconds) / 1000,\n );\n await punch.save();\n if (work) await this.link(tx, punch, work);\n return punch;\n }\n\n private validateWork(work: AttendanceWork): void {\n if (\n !work ||\n typeof work.workRefType !== 'string' ||\n !work.workRefType ||\n typeof work.workRefId !== 'string' ||\n !work.workRefId ||\n typeof work.description !== 'string'\n )\n throw new AttendanceConflict(\n 'A complete work reference and description are required.',\n );\n }\n\n private async link(\n tx: Context,\n punch: AttendancePunch,\n work: AttendanceWork,\n ): Promise<void> {\n this.validateWork(work);\n if (!punch.endedAt || punch.durationSeconds <= 0)\n throw new AttendanceConflict(\n 'Only closed punches with positive duration can produce service evidence.',\n );\n const entries = await ServiceTimeEntryCollection.create({ db: tx.db });\n if (punch.serviceTimeEntryId) {\n const [existing] = await entries.list({\n where: { id: punch.serviceTimeEntryId, tenantId: this.actor.tenantId },\n limit: 1,\n });\n if (\n !existing ||\n existing.workRefType !== work.workRefType ||\n existing.workRefId !== work.workRefId ||\n existing.description !== work.description\n )\n throw new AttendanceConflict('Punch already linked to different work.');\n return;\n }\n const entry = await entries.create({\n tenantId: this.actor.tenantId,\n participantKind: 'human',\n participantProfileId: this.actor.profileId,\n workRefType: work.workRefType,\n workRefId: work.workRefId,\n description: work.description,\n source: 'timer',\n startedAt: punch.startedAt,\n endedAt: punch.endedAt,\n durationSeconds: punch.durationSeconds,\n status: 'draft',\n metadata: JSON.stringify({ attendancePunchId: punch.id }),\n _insertOnly: true,\n });\n if (!entry.id) throw new Error('Saved service entry has no id.');\n punch.serviceTimeEntryId = entry.id;\n await punch.save();\n }\n\n /** Idempotently link a closed, owned punch to draft service evidence. */\n async linkServiceTimeEntry(\n punchId: string,\n work: AttendanceWork,\n ): Promise<AttendancePunch> {\n return this.transact(async (tx) => {\n const [punch] = await tx.punches.list({\n where: { ...this.actor, id: punchId },\n limit: 1,\n });\n if (!punch) throw new AttendanceConflict('Punch not found for actor.');\n await this.link(tx, punch, work);\n return punch;\n });\n }\n\n /** Close at startedAt + maxOpenSeconds once due, flagging the punch for review. */\n async autoClose(\n now: Date,\n maxOpenSeconds: number,\n ): Promise<AttendancePunch | null> {\n this.date(now);\n if (!Number.isSafeInteger(maxOpenSeconds) || maxOpenSeconds <= 0)\n throw new AttendanceConflict(\n 'maxOpenSeconds must be a positive integer.',\n );\n return this.transact(async (tx) => {\n const [punch] = await tx.punches.list({\n where: { ...this.actor, endedAt: null },\n limit: 1,\n });\n if (!punch) return null;\n const deadline = new Date(\n punch.startedAt.getTime() + maxOpenSeconds * 1000,\n );\n if (deadline > now) return null;\n // Do not truncate a later committed break. The conflict requires manual review.\n const closed = await this.transition(tx, 'punchOut', deadline);\n closed.reviewRequired = true;\n await closed.save();\n return closed;\n });\n }\n\n /**\n * Replay device timestamps in order (ties use clientId). Committed attendance\n * wins: stale/state-conflicting taps get durable refusals; identical retries\n * return the same outcome, changed payloads using a key are rejected. A\n * database failure rolls back its tap and receipt and remains retryable.\n */\n async replay(\n taps: readonly AttendanceTap[],\n ): Promise<AttendanceReplayOutcome[]> {\n for (const tap of taps) {\n if (\n !tap ||\n typeof tap.at !== 'string' ||\n typeof tap.clientId !== 'string' ||\n !tap.clientId ||\n !['punchIn', 'punchOut', 'startBreak', 'endBreak'].includes(tap.action)\n )\n throw new AttendanceConflict('Malformed attendance tap.');\n this.date(new Date(tap.at));\n if (tap.work !== undefined) this.validateWork(tap.work);\n if (tap.paid !== undefined && typeof tap.paid !== 'boolean')\n throw new AttendanceConflict('paid must be boolean.');\n }\n const ordered = [...taps].sort(\n (a, b) =>\n Date.parse(a.at) - Date.parse(b.at) ||\n (a.clientId < b.clientId ? -1 : a.clientId > b.clientId ? 1 : 0),\n );\n const outcomes: AttendanceReplayOutcome[] = [];\n for (const tap of ordered)\n outcomes.push(\n await this.transact(async (tx) => {\n const replayKey = JSON.stringify([\n this.actor.tenantId,\n this.actor.profileId,\n tap.clientId,\n ]);\n const content = JSON.stringify([\n tap.action,\n new Date(tap.at).toISOString(),\n tap.paid ?? false,\n tap.work?.workRefType ?? null,\n tap.work?.workRefId ?? null,\n tap.work?.description ?? null,\n ]);\n const [receipt] = await tx.replays.list({\n where: { tenantId: this.actor.tenantId, replayKey },\n limit: 1,\n });\n if (receipt) {\n if (receipt.content !== content)\n throw new AttendanceConflict(\n 'Client id reused with different content.',\n );\n const outcome = receipt.getOutcome();\n if (outcome.clientId !== tap.clientId)\n throw new Error(\n 'Stored attendance receipt belongs to another client id.',\n );\n return outcome;\n }\n let outcome: AttendanceReplayOutcome;\n try {\n // Domain refusals are validated before the first write. Adapters with\n // savepoints isolate the tap; every failure remains in the outer atomic scope.\n // Any database failure escapes and rolls back the entire transaction.\n outcome = await withEmbeddedWriteTransaction(\n tx.db,\n isEmbeddedDatabase(tx.db),\n async (db) => {\n const inner = {\n db,\n punches: await AttendancePunchCollection.create({ db }),\n breaks: await AttendanceBreakCollection.create({ db }),\n replays: tx.replays,\n };\n const punch = await this.transition(\n inner,\n tap.action,\n new Date(tap.at),\n tap.paid,\n tap.work,\n );\n punch.recordedOffline = true;\n punch.reviewRequired = true;\n await punch.save();\n return { clientId: tap.clientId, punchId: punch.id! };\n },\n true,\n );\n } catch (error) {\n if (!(error instanceof AttendanceConflict)) throw error;\n outcome = { clientId: tap.clientId, error: error.message };\n }\n await tx.replays.create({\n tenantId: this.actor.tenantId,\n replayKey,\n content,\n outcome: JSON.stringify(outcome),\n _insertOnly: true,\n });\n return outcome;\n }),\n );\n return outcomes;\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { i as AttendancePunchCollection, n as AttendanceBreakCollection } from "./models-DRj8BOuG.js";
|
|
2
|
+
import { i as integer, n as clipSource, r as instant } from "./rollup-C_dHTdJs.js";
|
|
3
|
+
//#region src/rollup/attendance.ts
|
|
4
|
+
async function attendanceSources(db, actor, period) {
|
|
5
|
+
const punches = await AttendancePunchCollection.create({ db });
|
|
6
|
+
const breaks = await AttendanceBreakCollection.create({ db });
|
|
7
|
+
const sources = [];
|
|
8
|
+
const linkedEntryIds = /* @__PURE__ */ new Set();
|
|
9
|
+
for (const punch of await punches.list({ where: { ...actor } })) {
|
|
10
|
+
if (punch.serviceTimeEntryId) linkedEntryIds.add(punch.serviceTimeEntryId);
|
|
11
|
+
if (!punch.endedAt || punch.reviewRequired) continue;
|
|
12
|
+
const start = instant(punch.startedAt);
|
|
13
|
+
const end = instant(punch.endedAt);
|
|
14
|
+
if (end < start) throw new Error("Invalid attendance interval.");
|
|
15
|
+
let cursor = start;
|
|
16
|
+
let unpaidMs = 0;
|
|
17
|
+
const spans = [];
|
|
18
|
+
for (const pause of await breaks.list({
|
|
19
|
+
where: {
|
|
20
|
+
tenantId: actor.tenantId,
|
|
21
|
+
punchId: punch.id
|
|
22
|
+
},
|
|
23
|
+
orderBy: "started_at ASC"
|
|
24
|
+
})) {
|
|
25
|
+
if (!pause.endedAt) throw new Error("Closed attendance has an open break.");
|
|
26
|
+
const left = instant(pause.startedAt);
|
|
27
|
+
const right = instant(pause.endedAt);
|
|
28
|
+
if (left < cursor || right < left || right > end) throw new Error("Invalid or overlapping attendance breaks.");
|
|
29
|
+
if (!pause.paid) {
|
|
30
|
+
spans.push([cursor, left]);
|
|
31
|
+
unpaidMs += right - left;
|
|
32
|
+
} else spans.push([cursor, right]);
|
|
33
|
+
cursor = right;
|
|
34
|
+
}
|
|
35
|
+
spans.push([cursor, end]);
|
|
36
|
+
const net = Math.round((end - start - unpaidMs) / 1e3);
|
|
37
|
+
if (integer(punch.durationSeconds) !== net) throw new Error("Attendance duration does not match its breaks.");
|
|
38
|
+
let paidMs = 0;
|
|
39
|
+
for (const [left, right] of spans) {
|
|
40
|
+
const seconds = Math.round((paidMs + right - left) / 1e3) - Math.round(paidMs / 1e3);
|
|
41
|
+
paidMs += right - left;
|
|
42
|
+
const source = clipSource({
|
|
43
|
+
kind: "attendance",
|
|
44
|
+
id: String(punch.id),
|
|
45
|
+
startsAt: new Date(left).toISOString(),
|
|
46
|
+
endsAt: new Date(right).toISOString(),
|
|
47
|
+
seconds
|
|
48
|
+
}, period);
|
|
49
|
+
if (source?.seconds) sources.push(source);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
sources,
|
|
54
|
+
linkedEntryIds
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
export { attendanceSources };
|
|
59
|
+
|
|
60
|
+
//# sourceMappingURL=attendance-Dg_5VkMj.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attendance-Dg_5VkMj.js","names":[],"sources":["../../src/rollup/attendance.ts"],"sourcesContent":["import type { DatabaseInterface } from '@happyvertical/sql';\nimport {\n AttendanceBreakCollection,\n AttendancePunchCollection,\n} from '../attendance/models.js';\nimport { clipSource, instant, integer } from './calculation.js';\nimport type { TimecardActor, TimecardPeriod, TimecardSource } from './types.js';\n\n/** Optional attendance reader. Called on the rollup's transaction executor. */\nexport async function attendanceSources(\n db: DatabaseInterface,\n actor: TimecardActor,\n period: TimecardPeriod,\n): Promise<{ sources: TimecardSource[]; linkedEntryIds: Set<string> }> {\n const punches = await AttendancePunchCollection.create({ db });\n const breaks = await AttendanceBreakCollection.create({ db });\n const sources: TimecardSource[] = [];\n const linkedEntryIds = new Set<string>();\n for (const punch of await punches.list({ where: { ...actor } })) {\n // A linked punch is authoritative even when pending review: never count its\n // evidence entry behind the review gate, or outside the punch's period.\n if (punch.serviceTimeEntryId) linkedEntryIds.add(punch.serviceTimeEntryId);\n if (!punch.endedAt || punch.reviewRequired) continue;\n const start = instant(punch.startedAt);\n const end = instant(punch.endedAt);\n if (end < start) throw new Error('Invalid attendance interval.');\n let cursor = start;\n let unpaidMs = 0;\n const spans: [number, number][] = [];\n for (const pause of await breaks.list({\n where: { tenantId: actor.tenantId, punchId: punch.id },\n orderBy: 'started_at ASC',\n })) {\n if (!pause.endedAt)\n throw new Error('Closed attendance has an open break.');\n const left = instant(pause.startedAt);\n const right = instant(pause.endedAt);\n if (left < cursor || right < left || right > end)\n throw new Error('Invalid or overlapping attendance breaks.');\n if (!pause.paid) {\n spans.push([cursor, left]);\n unpaidMs += right - left;\n } else {\n spans.push([cursor, right]);\n }\n cursor = right;\n }\n spans.push([cursor, end]);\n const net = Math.round((end - start - unpaidMs) / 1000);\n if (integer(punch.durationSeconds) !== net)\n throw new Error('Attendance duration does not match its breaks.');\n // Round elapsed paid prefixes once, preserving conservation across breaks.\n let paidMs = 0;\n for (const [left, right] of spans) {\n const seconds =\n Math.round((paidMs + right - left) / 1000) - Math.round(paidMs / 1000);\n paidMs += right - left;\n const source = clipSource(\n {\n kind: 'attendance',\n id: String(punch.id),\n startsAt: new Date(left).toISOString(),\n endsAt: new Date(right).toISOString(),\n seconds,\n },\n period,\n );\n if (source?.seconds) sources.push(source);\n }\n }\n return { sources, linkedEntryIds };\n}\n"],"mappings":";;;AASA,eAAsB,kBACpB,IACA,OACA,QACqE;CACrE,MAAM,UAAU,MAAM,0BAA0B,OAAO,EAAE,GAAG,CAAC;CAC7D,MAAM,SAAS,MAAM,0BAA0B,OAAO,EAAE,GAAG,CAAC;CAC5D,MAAM,UAA4B,CAAC;CACnC,MAAM,iCAAiB,IAAI,IAAY;CACvC,KAAA,MAAW,SAAS,MAAM,QAAQ,KAAK,EAAE,OAAO,EAAE,GAAG,MAAM,EAAE,CAAC,GAAG;EAG/D,IAAI,MAAM,oBAAoB,eAAe,IAAI,MAAM,kBAAkB;EACzE,IAAI,CAAC,MAAM,WAAW,MAAM,gBAAgB;EAC5C,MAAM,QAAQ,QAAQ,MAAM,SAAS;EACrC,MAAM,MAAM,QAAQ,MAAM,OAAO;EACjC,IAAI,MAAM,OAAO,MAAM,IAAI,MAAM,8BAA8B;EAC/D,IAAI,SAAS;EACb,IAAI,WAAW;EACf,MAAM,QAA4B,CAAC;EACnC,KAAA,MAAW,SAAS,MAAM,OAAO,KAAK;GACpC,OAAO;IAAE,UAAU,MAAM;IAAU,SAAS,MAAM;GAAG;GACrD,SAAS;EACX,CAAC,GAAG;GACF,IAAI,CAAC,MAAM,SACT,MAAM,IAAI,MAAM,sCAAsC;GACxD,MAAM,OAAO,QAAQ,MAAM,SAAS;GACpC,MAAM,QAAQ,QAAQ,MAAM,OAAO;GACnC,IAAI,OAAO,UAAU,QAAQ,QAAQ,QAAQ,KAC3C,MAAM,IAAI,MAAM,2CAA2C;GAC7D,IAAI,CAAC,MAAM,MAAM;IACf,MAAM,KAAK,CAAC,QAAQ,IAAI,CAAC;IACzB,YAAY,QAAQ;GACtB,OACE,MAAM,KAAK,CAAC,QAAQ,KAAK,CAAC;GAE5B,SAAS;EACX;EACA,MAAM,KAAK,CAAC,QAAQ,GAAG,CAAC;EACxB,MAAM,MAAM,KAAK,OAAO,MAAM,QAAQ,YAAY,GAAI;EACtD,IAAI,QAAQ,MAAM,eAAe,MAAM,KACrC,MAAM,IAAI,MAAM,gDAAgD;EAElE,IAAI,SAAS;EACb,KAAA,MAAW,CAAC,MAAM,UAAU,OAAO;GACjC,MAAM,UACJ,KAAK,OAAO,SAAS,QAAQ,QAAQ,GAAI,IAAI,KAAK,MAAM,SAAS,GAAI;GACvE,UAAU,QAAQ;GAClB,MAAM,SAAS,WACb;IACE,MAAM;IACN,IAAI,OAAO,MAAM,EAAE;IACnB,UAAU,IAAI,KAAK,IAAI,CAAA,CAAE,YAAY;IACrC,QAAQ,IAAI,KAAK,KAAK,CAAA,CAAE,YAAY;IACpC;GACF,GACA,MACF;GACA,IAAI,QAAQ,SAAS,QAAQ,KAAK,MAAM;EAC1C;CACF;CACA,OAAO;EAAE;EAAS;CAAe;AACnC"}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { i as parseTimesheetJson } from "./service-time-entry-BeOsL7Uv.js";
|
|
2
|
+
import { SmrtCollection, SmrtObject, crossPackageRef, field, foreignKey, isEmbeddedDatabase, isPostgresDatabase, smrt, withEmbeddedWriteTransaction } from "@happyvertical/smrt-core";
|
|
3
|
+
import { TenantScoped, tenantId } from "@happyvertical/smrt-tenancy";
|
|
4
|
+
//#region src/attendance/models.ts
|
|
5
|
+
var __defProp = Object.defineProperty;
|
|
6
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
7
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
8
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
9
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
10
|
+
if (kind && result) __defProp(target, key, result);
|
|
11
|
+
return result;
|
|
12
|
+
};
|
|
13
|
+
async function saveWithPunchLock(model, punchId, tenant, save) {
|
|
14
|
+
return withEmbeddedWriteTransaction(model.db, isEmbeddedDatabase(model.db), (db) => model.withDatabase(db, async () => {
|
|
15
|
+
if (punchId && isPostgresDatabase(db)) await db.query("SELECT id FROM attendance_punches WHERE id = $1 AND tenant_id = $2 FOR UPDATE", punchId, tenant);
|
|
16
|
+
return save();
|
|
17
|
+
}), true);
|
|
18
|
+
}
|
|
19
|
+
var AttendancePunch = class extends SmrtObject {
|
|
20
|
+
tenantId = "";
|
|
21
|
+
profileId = "";
|
|
22
|
+
startedAt = /* @__PURE__ */ new Date();
|
|
23
|
+
endedAt = null;
|
|
24
|
+
durationSeconds = 0;
|
|
25
|
+
unpaidBreakSeconds = 0;
|
|
26
|
+
reviewRequired = false;
|
|
27
|
+
recordedOffline = false;
|
|
28
|
+
serviceTimeEntryId = null;
|
|
29
|
+
openSlot = "";
|
|
30
|
+
async save() {
|
|
31
|
+
return saveWithPunchLock(this, this.id, this.tenantId, () => this.saveConsistent());
|
|
32
|
+
}
|
|
33
|
+
async saveConsistent() {
|
|
34
|
+
if (!this.tenantId || !this.profileId) throw new Error("Attendance requires tenantId and profileId.");
|
|
35
|
+
if (!Number.isFinite(this.startedAt?.getTime()) || this.endedAt && (!Number.isFinite(this.endedAt.getTime()) || this.endedAt < this.startedAt)) throw new Error("Invalid attendance interval.");
|
|
36
|
+
if (![this.durationSeconds, this.unpaidBreakSeconds].every((value) => Number.isSafeInteger(value) && value >= 0)) throw new Error("Attendance durations must be nonnegative safe integer seconds.");
|
|
37
|
+
const prior = this.id ? await this.db.get(this.tableName, { id: this.id }) : null;
|
|
38
|
+
if (prior && (prior.tenant_id !== this.tenantId || prior.profile_id !== this.profileId || new Date(prior.started_at).getTime() !== this.startedAt.getTime())) throw new Error("Attendance ownership and start are immutable.");
|
|
39
|
+
if (prior?.ended_at && (new Date(prior.ended_at).getTime() !== this.endedAt?.getTime() || Number(prior.duration_seconds) !== this.durationSeconds || Number(prior.unpaid_break_seconds) !== this.unpaidBreakSeconds)) throw new Error("Closed attendance evidence is immutable.");
|
|
40
|
+
if (this.id && this.endedAt) {
|
|
41
|
+
if ((await this.db.list("attendance_breaks", {
|
|
42
|
+
tenant_id: this.tenantId,
|
|
43
|
+
punch_id: this.id
|
|
44
|
+
})).some((item) => !item.ended_at || new Date(item.started_at).getTime() < this.startedAt.getTime() || new Date(item.ended_at).getTime() > this.endedAt.getTime())) throw new Error("Break must be inside its tenant-owned punch.");
|
|
45
|
+
}
|
|
46
|
+
this.id ??= crypto.randomUUID();
|
|
47
|
+
this.openSlot = this.endedAt ? JSON.stringify(["closed", this.id]) : "open";
|
|
48
|
+
return super.save();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
__decorateClass([tenantId()], AttendancePunch.prototype, "tenantId", 2);
|
|
52
|
+
__decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile")], AttendancePunch.prototype, "profileId", 2);
|
|
53
|
+
__decorateClass([foreignKey("ServiceTimeEntry", { nullable: true })], AttendancePunch.prototype, "serviceTimeEntryId", 2);
|
|
54
|
+
__decorateClass([field({ required: true })], AttendancePunch.prototype, "openSlot", 2);
|
|
55
|
+
AttendancePunch = __decorateClass([TenantScoped({ mode: "required" }), smrt({
|
|
56
|
+
tableName: "attendance_punches",
|
|
57
|
+
indexes: [{
|
|
58
|
+
name: "attendance_punches_open_owner_key",
|
|
59
|
+
columns: [
|
|
60
|
+
"tenantId",
|
|
61
|
+
"profileId",
|
|
62
|
+
"openSlot"
|
|
63
|
+
],
|
|
64
|
+
unique: true
|
|
65
|
+
}],
|
|
66
|
+
api: { include: ["list", "get"] },
|
|
67
|
+
cli: { include: ["list", "get"] },
|
|
68
|
+
mcp: { include: ["list", "get"] }
|
|
69
|
+
})], AttendancePunch);
|
|
70
|
+
var AttendanceBreak = class extends SmrtObject {
|
|
71
|
+
tenantId = "";
|
|
72
|
+
punchId = "";
|
|
73
|
+
startedAt = /* @__PURE__ */ new Date();
|
|
74
|
+
endedAt = null;
|
|
75
|
+
paid = false;
|
|
76
|
+
openSlot = "";
|
|
77
|
+
async save() {
|
|
78
|
+
return saveWithPunchLock(this, this.punchId, this.tenantId, () => this.saveConsistent());
|
|
79
|
+
}
|
|
80
|
+
async saveConsistent() {
|
|
81
|
+
if (!this.tenantId || !this.punchId || typeof this.paid !== "boolean") throw new Error("Invalid attendance break.");
|
|
82
|
+
if (!Number.isFinite(this.startedAt?.getTime()) || this.endedAt && (!Number.isFinite(this.endedAt.getTime()) || this.endedAt < this.startedAt)) throw new Error("Invalid attendance break interval.");
|
|
83
|
+
const parent = await this.db.get("attendance_punches", {
|
|
84
|
+
id: this.punchId,
|
|
85
|
+
tenant_id: this.tenantId
|
|
86
|
+
});
|
|
87
|
+
if (!parent || this.startedAt.getTime() < new Date(parent.started_at).getTime() || parent.ended_at && (!this.endedAt || this.endedAt.getTime() > new Date(parent.ended_at).getTime())) throw new Error("Break must be inside its tenant-owned punch.");
|
|
88
|
+
const prior = this.id ? await this.db.get(this.tableName, { id: this.id }) : null;
|
|
89
|
+
if (prior && (prior.tenant_id !== this.tenantId || prior.punch_id !== this.punchId || new Date(prior.started_at).getTime() !== this.startedAt.getTime() || Boolean(prior.paid) !== this.paid)) throw new Error("Break ownership, start and pay policy are immutable.");
|
|
90
|
+
if (prior?.ended_at && new Date(prior.ended_at).getTime() !== this.endedAt?.getTime()) throw new Error("Closed breaks are immutable.");
|
|
91
|
+
this.id ??= crypto.randomUUID();
|
|
92
|
+
this.openSlot = this.endedAt ? JSON.stringify(["closed", this.id]) : "open";
|
|
93
|
+
return super.save();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
__decorateClass([tenantId()], AttendanceBreak.prototype, "tenantId", 2);
|
|
97
|
+
__decorateClass([foreignKey(AttendancePunch)], AttendanceBreak.prototype, "punchId", 2);
|
|
98
|
+
__decorateClass([field({ required: true })], AttendanceBreak.prototype, "openSlot", 2);
|
|
99
|
+
AttendanceBreak = __decorateClass([TenantScoped({ mode: "required" }), smrt({
|
|
100
|
+
tableName: "attendance_breaks",
|
|
101
|
+
indexes: [{
|
|
102
|
+
name: "attendance_breaks_open_punch_key",
|
|
103
|
+
columns: [
|
|
104
|
+
"tenantId",
|
|
105
|
+
"punchId",
|
|
106
|
+
"openSlot"
|
|
107
|
+
],
|
|
108
|
+
unique: true
|
|
109
|
+
}],
|
|
110
|
+
api: { include: ["list", "get"] },
|
|
111
|
+
cli: { include: ["list", "get"] },
|
|
112
|
+
mcp: { include: ["list", "get"] }
|
|
113
|
+
})], AttendanceBreak);
|
|
114
|
+
function validReplayOutcome(value) {
|
|
115
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
116
|
+
const result = value;
|
|
117
|
+
return typeof result.clientId === "string" && result.clientId.length > 0 && (typeof result.punchId === "string" && result.punchId.length > 0 && result.error === void 0 || typeof result.error === "string" && result.error.length > 0 && result.punchId === void 0);
|
|
118
|
+
}
|
|
119
|
+
var AttendanceReplay = class extends SmrtObject {
|
|
120
|
+
tenantId = "";
|
|
121
|
+
replayKey = "";
|
|
122
|
+
content = "";
|
|
123
|
+
outcome = "{}";
|
|
124
|
+
/** Read a durable receipt, failing closed rather than re-executing a corrupt tap. */
|
|
125
|
+
getOutcome() {
|
|
126
|
+
const value = parseTimesheetJson(this.outcome, null);
|
|
127
|
+
if (!validReplayOutcome(value)) throw new Error("Invalid stored attendance replay outcome.");
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
/** Store the JSON representation of a validated success or refusal. */
|
|
131
|
+
setOutcome(value) {
|
|
132
|
+
if (!validReplayOutcome(value)) throw new Error("Invalid attendance replay outcome.");
|
|
133
|
+
this.outcome = JSON.stringify(value);
|
|
134
|
+
}
|
|
135
|
+
async save() {
|
|
136
|
+
this.getOutcome();
|
|
137
|
+
return super.save();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
__decorateClass([tenantId()], AttendanceReplay.prototype, "tenantId", 2);
|
|
141
|
+
__decorateClass([field({
|
|
142
|
+
required: true,
|
|
143
|
+
unique: true
|
|
144
|
+
})], AttendanceReplay.prototype, "replayKey", 2);
|
|
145
|
+
__decorateClass([field({ type: "text" })], AttendanceReplay.prototype, "content", 2);
|
|
146
|
+
__decorateClass([field({ type: "text" })], AttendanceReplay.prototype, "outcome", 2);
|
|
147
|
+
AttendanceReplay = __decorateClass([TenantScoped({ mode: "required" }), smrt({
|
|
148
|
+
tableName: "attendance_replays",
|
|
149
|
+
sensitive: true,
|
|
150
|
+
api: { include: [] },
|
|
151
|
+
cli: { include: [] },
|
|
152
|
+
mcp: { include: [] }
|
|
153
|
+
})], AttendanceReplay);
|
|
154
|
+
var AttendancePunchCollection = class extends SmrtCollection {
|
|
155
|
+
static _itemClass = AttendancePunch;
|
|
156
|
+
};
|
|
157
|
+
var AttendanceBreakCollection = class extends SmrtCollection {
|
|
158
|
+
static _itemClass = AttendanceBreak;
|
|
159
|
+
};
|
|
160
|
+
var AttendanceReplayCollection = class extends SmrtCollection {
|
|
161
|
+
static _itemClass = AttendanceReplay;
|
|
162
|
+
};
|
|
163
|
+
//#endregion
|
|
164
|
+
export { AttendanceReplay as a, AttendancePunchCollection as i, AttendanceBreakCollection as n, AttendanceReplayCollection as o, AttendancePunch as r, AttendanceBreak as t };
|
|
165
|
+
|
|
166
|
+
//# sourceMappingURL=models-DRj8BOuG.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"models-DRj8BOuG.js","names":[],"sources":["../../src/attendance/models.ts"],"sourcesContent":["import {\n crossPackageRef,\n field,\n foreignKey,\n isEmbeddedDatabase,\n isPostgresDatabase,\n SmrtCollection,\n SmrtObject,\n smrt,\n withEmbeddedWriteTransaction,\n} from '@happyvertical/smrt-core';\nimport { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\nimport { parseTimesheetJson } from '../models/json.js';\n\n// Punch closes and break writes share one atomic parent boundary, including direct ORM saves.\nasync function saveWithPunchLock<T extends SmrtObject>(\n model: T,\n punchId: string | null | undefined,\n tenant: string,\n save: () => Promise<T>,\n): Promise<T> {\n return withEmbeddedWriteTransaction(\n model.db,\n isEmbeddedDatabase(model.db),\n (db) =>\n model.withDatabase(db, async () => {\n if (punchId && isPostgresDatabase(db))\n await db.query(\n 'SELECT id FROM attendance_punches WHERE id = $1 AND tenant_id = $2 FOR UPDATE',\n punchId,\n tenant,\n );\n return save();\n }),\n true,\n );\n}\n\n/** A tenant-scoped interval of attendance, independent of billable work. */\n@TenantScoped({ mode: 'required' })\n@smrt({\n tableName: 'attendance_punches',\n indexes: [\n {\n name: 'attendance_punches_open_owner_key',\n columns: ['tenantId', 'profileId', 'openSlot'],\n unique: true,\n },\n ],\n api: { include: ['list', 'get'] },\n cli: { include: ['list', 'get'] },\n mcp: { include: ['list', 'get'] },\n})\nexport class AttendancePunch extends SmrtObject {\n @tenantId() tenantId = '';\n @crossPackageRef('@happyvertical/smrt-profiles:Profile') profileId = '';\n startedAt: Date = new Date();\n endedAt: Date | null = null;\n durationSeconds = 0;\n unpaidBreakSeconds = 0;\n reviewRequired = false;\n recordedOffline = false;\n // Resolve the active table family, including smrt-support's same-named subtype.\n @foreignKey('ServiceTimeEntry', { nullable: true }) serviceTimeEntryId:\n | string\n | null = null;\n /** Unique while open; closed rows retain independent non-null slots on every dialect. */\n @field({ required: true }) openSlot = '';\n\n override async save(): Promise<this> {\n return saveWithPunchLock(this, this.id, this.tenantId, () =>\n this.saveConsistent(),\n );\n }\n\n private async saveConsistent(): Promise<this> {\n if (!this.tenantId || !this.profileId)\n throw new Error('Attendance requires tenantId and profileId.');\n if (\n !Number.isFinite(this.startedAt?.getTime()) ||\n (this.endedAt &&\n (!Number.isFinite(this.endedAt.getTime()) ||\n this.endedAt < this.startedAt))\n )\n throw new Error('Invalid attendance interval.');\n if (\n ![this.durationSeconds, this.unpaidBreakSeconds].every(\n (value) => Number.isSafeInteger(value) && value >= 0,\n )\n )\n throw new Error(\n 'Attendance durations must be nonnegative safe integer seconds.',\n );\n const prior = this.id\n ? await this.db.get(this.tableName, { id: this.id })\n : null;\n if (\n prior &&\n (prior.tenant_id !== this.tenantId ||\n prior.profile_id !== this.profileId ||\n new Date(prior.started_at as string).getTime() !==\n this.startedAt.getTime())\n )\n throw new Error('Attendance ownership and start are immutable.');\n if (\n prior?.ended_at &&\n (new Date(prior.ended_at as string).getTime() !==\n this.endedAt?.getTime() ||\n Number(prior.duration_seconds) !== this.durationSeconds ||\n Number(prior.unpaid_break_seconds) !== this.unpaidBreakSeconds)\n )\n throw new Error('Closed attendance evidence is immutable.');\n if (this.id && this.endedAt) {\n const breaks = await this.db.list('attendance_breaks', {\n tenant_id: this.tenantId,\n punch_id: this.id,\n });\n if (\n breaks.some(\n (item) =>\n !item.ended_at ||\n new Date(item.started_at as string).getTime() <\n this.startedAt.getTime() ||\n new Date(item.ended_at as string).getTime() >\n this.endedAt!.getTime(),\n )\n )\n throw new Error('Break must be inside its tenant-owned punch.');\n }\n this.id ??= crypto.randomUUID();\n this.openSlot = this.endedAt ? JSON.stringify(['closed', this.id]) : 'open';\n return super.save();\n }\n}\n\n/** A paid or unpaid break inside one attendance punch. */\n@TenantScoped({ mode: 'required' })\n@smrt({\n tableName: 'attendance_breaks',\n indexes: [\n {\n name: 'attendance_breaks_open_punch_key',\n columns: ['tenantId', 'punchId', 'openSlot'],\n unique: true,\n },\n ],\n api: { include: ['list', 'get'] },\n cli: { include: ['list', 'get'] },\n mcp: { include: ['list', 'get'] },\n})\nexport class AttendanceBreak extends SmrtObject {\n @tenantId() tenantId = '';\n @foreignKey(AttendancePunch) punchId = '';\n startedAt: Date = new Date();\n endedAt: Date | null = null;\n paid = false;\n @field({ required: true }) openSlot = '';\n\n override async save(): Promise<this> {\n return saveWithPunchLock(this, this.punchId, this.tenantId, () =>\n this.saveConsistent(),\n );\n }\n\n private async saveConsistent(): Promise<this> {\n if (!this.tenantId || !this.punchId || typeof this.paid !== 'boolean')\n throw new Error('Invalid attendance break.');\n if (\n !Number.isFinite(this.startedAt?.getTime()) ||\n (this.endedAt &&\n (!Number.isFinite(this.endedAt.getTime()) ||\n this.endedAt < this.startedAt))\n )\n throw new Error('Invalid attendance break interval.');\n const parent = await this.db.get('attendance_punches', {\n id: this.punchId,\n tenant_id: this.tenantId,\n });\n if (\n !parent ||\n this.startedAt.getTime() <\n new Date(parent.started_at as string).getTime() ||\n (parent.ended_at &&\n (!this.endedAt ||\n this.endedAt.getTime() >\n new Date(parent.ended_at as string).getTime()))\n )\n throw new Error('Break must be inside its tenant-owned punch.');\n const prior = this.id\n ? await this.db.get(this.tableName, { id: this.id })\n : null;\n if (\n prior &&\n (prior.tenant_id !== this.tenantId ||\n prior.punch_id !== this.punchId ||\n new Date(prior.started_at as string).getTime() !==\n this.startedAt.getTime() ||\n Boolean(prior.paid) !== this.paid)\n )\n throw new Error('Break ownership, start and pay policy are immutable.');\n if (\n prior?.ended_at &&\n new Date(prior.ended_at as string).getTime() !== this.endedAt?.getTime()\n )\n throw new Error('Closed breaks are immutable.');\n this.id ??= crypto.randomUUID();\n this.openSlot = this.endedAt ? JSON.stringify(['closed', this.id]) : 'open';\n return super.save();\n }\n}\n\n/** Durable success or conflict; conflicts do not mutate attendance. */\nexport interface AttendanceReplayOutcome {\n clientId: string;\n punchId?: string;\n error?: string;\n}\n\nfunction validReplayOutcome(value: unknown): value is AttendanceReplayOutcome {\n if (!value || typeof value !== 'object' || Array.isArray(value)) return false;\n const result = value as Record<string, unknown>;\n return (\n typeof result.clientId === 'string' &&\n result.clientId.length > 0 &&\n ((typeof result.punchId === 'string' &&\n result.punchId.length > 0 &&\n result.error === undefined) ||\n (typeof result.error === 'string' &&\n result.error.length > 0 &&\n result.punchId === undefined))\n );\n}\n\n/** Durable offline-tap outcome; never exposed through generated operations. */\n@TenantScoped({ mode: 'required' })\n@smrt({\n tableName: 'attendance_replays',\n sensitive: true,\n api: { include: [] },\n cli: { include: [] },\n mcp: { include: [] },\n})\nexport class AttendanceReplay extends SmrtObject {\n @tenantId() tenantId = '';\n @field({ required: true, unique: true }) replayKey = '';\n @field({ type: 'text' }) content = '';\n @field({ type: 'text' }) outcome = '{}';\n\n /** Read a durable receipt, failing closed rather than re-executing a corrupt tap. */\n getOutcome(): AttendanceReplayOutcome {\n const value = parseTimesheetJson<unknown>(this.outcome, null);\n if (!validReplayOutcome(value))\n throw new Error('Invalid stored attendance replay outcome.');\n return value;\n }\n\n /** Store the JSON representation of a validated success or refusal. */\n setOutcome(value: AttendanceReplayOutcome): void {\n if (!validReplayOutcome(value))\n throw new Error('Invalid attendance replay outcome.');\n this.outcome = JSON.stringify(value);\n }\n\n override async save(): Promise<this> {\n this.getOutcome();\n return super.save();\n }\n}\n\n/** Punches for attendance and period rollup. */\nexport class AttendancePunchCollection extends SmrtCollection<AttendancePunch> {\n static readonly _itemClass = AttendancePunch;\n}\n/** Break intervals for net duration and period clipping. */\nexport class AttendanceBreakCollection extends SmrtCollection<AttendanceBreak> {\n static readonly _itemClass = AttendanceBreak;\n}\n/** Internal durable replay ledger. */\nexport class AttendanceReplayCollection extends SmrtCollection<AttendanceReplay> {\n static readonly _itemClass = AttendanceReplay;\n}\n"],"mappings":"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"}
|