@kernhq/module-hr 0.3.0 → 0.5.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.
- package/dist/contract/attendance.d.ts +240 -0
- package/dist/contract/attendance.d.ts.map +1 -0
- package/dist/contract/attendance.js +154 -0
- package/dist/contract/attendance.js.map +1 -0
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +36 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +18 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +18 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/permissions.d.ts +43 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +50 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +996 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +165 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/policy/time.d.ts +48 -0
- package/dist/policy/time.d.ts.map +1 -0
- package/dist/policy/time.js +144 -0
- package/dist/policy/time.js.map +1 -0
- package/dist/policy/working-time.d.ts +98 -0
- package/dist/policy/working-time.d.ts.map +1 -0
- package/dist/policy/working-time.js +190 -0
- package/dist/policy/working-time.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +16 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +166 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1087 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +521 -3
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +1261 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +116 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/attendance.d.ts +150 -0
- package/dist/server/services/attendance.d.ts.map +1 -0
- package/dist/server/services/attendance.js +239 -0
- package/dist/server/services/attendance.js.map +1 -0
- package/dist/server/services/ledger.d.ts +2 -2
- package/migrations/0003_attendance.sql +206 -0
- package/migrations/meta/0003_snapshot.json +3681 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/index.ts +18 -0
- package/src/contract/attendance.ts +180 -0
- package/src/contract/capabilities.ts +36 -0
- package/src/contract/events.ts +24 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/permissions.ts +51 -0
- package/src/contract/router.ts +203 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { KernError, uuidv7 } from '@kernhq/kernel';
|
|
2
|
+
import { and, asc, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm';
|
|
3
|
+
import { weekdayOf } from '../../policy/calendar.js';
|
|
4
|
+
import { businessDateFor, computeDay, } from '../../policy/working-time.js';
|
|
5
|
+
import { attendanceDays, leaveRequestDays, punches, scheduleAssignments, schedules } from '../schema.js';
|
|
6
|
+
import { inForceOn } from './db.js';
|
|
7
|
+
/** A schedule with no shifts: somebody with no assignment still clocks in, they just owe no hours. */
|
|
8
|
+
export const NO_SCHEDULE = {
|
|
9
|
+
shiftFor: () => null,
|
|
10
|
+
rounding: { stepMinutes: 0, direction: 'nearest' },
|
|
11
|
+
autoClockOutAfterMinutes: null,
|
|
12
|
+
scheduleId: null,
|
|
13
|
+
};
|
|
14
|
+
export class AttendanceService {
|
|
15
|
+
/** The schedule in force for a person on a date, as something the pure layer can use. */
|
|
16
|
+
async scheduleFor(tx, workspaceId, personId, on) {
|
|
17
|
+
const [assignment] = await tx
|
|
18
|
+
.select()
|
|
19
|
+
.from(scheduleAssignments)
|
|
20
|
+
.where(and(eq(scheduleAssignments.workspaceId, workspaceId), eq(scheduleAssignments.personId, personId), inForceOn(scheduleAssignments.effectiveFrom, scheduleAssignments.effectiveTo, on)))
|
|
21
|
+
.limit(1);
|
|
22
|
+
if (!assignment)
|
|
23
|
+
return NO_SCHEDULE;
|
|
24
|
+
const [schedule] = await tx
|
|
25
|
+
.select()
|
|
26
|
+
.from(schedules)
|
|
27
|
+
.where(and(eq(schedules.workspaceId, workspaceId), eq(schedules.id, assignment.scheduleId)))
|
|
28
|
+
.limit(1);
|
|
29
|
+
if (!schedule)
|
|
30
|
+
return NO_SCHEDULE;
|
|
31
|
+
const week = (schedule.week ?? {});
|
|
32
|
+
return {
|
|
33
|
+
scheduleId: schedule.id,
|
|
34
|
+
shiftFor: (date) => {
|
|
35
|
+
const day = week[weekdayOf(date)];
|
|
36
|
+
if (!day)
|
|
37
|
+
return null;
|
|
38
|
+
return {
|
|
39
|
+
start: day.start,
|
|
40
|
+
end: day.end,
|
|
41
|
+
breakMinutes: day.breakMinutes ?? 0,
|
|
42
|
+
graceInMinutes: schedule.graceInMinutes,
|
|
43
|
+
graceOutMinutes: schedule.graceOutMinutes,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
rounding: {
|
|
47
|
+
stepMinutes: schedule.roundingStepMinutes,
|
|
48
|
+
direction: schedule.roundingDirection,
|
|
49
|
+
},
|
|
50
|
+
autoClockOutAfterMinutes: schedule.autoClockOutAfterMinutes,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Record a punch.
|
|
55
|
+
*
|
|
56
|
+
* `at` is the server's clock, always. `clientReportedAt` is kept beside it with the measured skew
|
|
57
|
+
* so an offline sync can be told from a device whose clock is wrong — and a claim beyond the
|
|
58
|
+
* threshold is marked `disputed` rather than silently accepted or silently dropped.
|
|
59
|
+
*/
|
|
60
|
+
async record(tx, workspaceId, input, schedule) {
|
|
61
|
+
const serverNow = new Date();
|
|
62
|
+
const at = input.claimedAt ?? serverNow;
|
|
63
|
+
const clientAt = input.clientReportedAt ? new Date(input.clientReportedAt) : null;
|
|
64
|
+
const skewMs = clientAt ? clientAt.getTime() - serverNow.getTime() : null;
|
|
65
|
+
// A claim more than an hour out is not a slow network; it is a clock somebody should look at.
|
|
66
|
+
const trust = input.claimedAt ? (Math.abs(skewMs ?? 0) > 3600_000 ? 'disputed' : 'claimed') : 'trusted';
|
|
67
|
+
const businessDate = businessDateFor(at.getTime(), input.timezone, schedule.shiftFor(new Date(at.getTime()).toISOString().slice(0, 10)));
|
|
68
|
+
const [row] = await tx
|
|
69
|
+
.insert(punches)
|
|
70
|
+
.values({
|
|
71
|
+
id: uuidv7(),
|
|
72
|
+
workspaceId,
|
|
73
|
+
personId: input.personId,
|
|
74
|
+
direction: input.direction,
|
|
75
|
+
at,
|
|
76
|
+
clientReportedAt: clientAt,
|
|
77
|
+
skewMs,
|
|
78
|
+
businessDate,
|
|
79
|
+
timezone: input.timezone,
|
|
80
|
+
method: input.method,
|
|
81
|
+
officeId: input.officeId ?? null,
|
|
82
|
+
geo: input.geo ?? null,
|
|
83
|
+
trust,
|
|
84
|
+
idempotencyKey: input.idempotencyKey ?? null,
|
|
85
|
+
note: input.note ?? null,
|
|
86
|
+
})
|
|
87
|
+
.returning();
|
|
88
|
+
return row;
|
|
89
|
+
}
|
|
90
|
+
/** Live punches for a person on a business date, oldest first. Voided rows are excluded. */
|
|
91
|
+
async punchesOn(tx, workspaceId, personId, businessDate) {
|
|
92
|
+
return tx
|
|
93
|
+
.select()
|
|
94
|
+
.from(punches)
|
|
95
|
+
.where(and(eq(punches.workspaceId, workspaceId), eq(punches.personId, personId), eq(punches.businessDate, businessDate), isNull(punches.voidedByPunchId)))
|
|
96
|
+
.orderBy(asc(punches.at));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Rebuild one day from its punches.
|
|
100
|
+
*
|
|
101
|
+
* Idempotent by construction, which is what makes it safe to call on every punch, from a nightly
|
|
102
|
+
* sweep, and from a support request without thinking about it. A locked day is left alone and
|
|
103
|
+
* reported, never silently skipped — a recomputation that quietly declines to touch a closed
|
|
104
|
+
* month looks identical to one that had nothing to do.
|
|
105
|
+
*/
|
|
106
|
+
async recomputeDay(tx, workspaceId, personId, businessDate, timezone, schedule) {
|
|
107
|
+
const [existing] = await tx
|
|
108
|
+
.select({ locked: attendanceDays.locked })
|
|
109
|
+
.from(attendanceDays)
|
|
110
|
+
.where(and(eq(attendanceDays.workspaceId, workspaceId), eq(attendanceDays.personId, personId), eq(attendanceDays.businessDate, businessDate)))
|
|
111
|
+
.limit(1);
|
|
112
|
+
if (existing?.locked)
|
|
113
|
+
return { locked: true };
|
|
114
|
+
const rows = await this.punchesOn(tx, workspaceId, personId, businessDate);
|
|
115
|
+
const punchInputs = rows.map((r) => ({
|
|
116
|
+
at: r.at.getTime(),
|
|
117
|
+
direction: r.direction,
|
|
118
|
+
}));
|
|
119
|
+
// Approved leave excuses the day: somebody on holiday is not absent, and a sheet that says
|
|
120
|
+
// otherwise generates a disciplinary conversation about a day HR themselves approved.
|
|
121
|
+
const [onLeave] = await tx
|
|
122
|
+
.select({ requestId: leaveRequestDays.requestId })
|
|
123
|
+
.from(leaveRequestDays)
|
|
124
|
+
.where(and(eq(leaveRequestDays.workspaceId, workspaceId), eq(leaveRequestDays.personId, personId), eq(leaveRequestDays.date, businessDate), eq(leaveRequestDays.counted, true), eq(leaveRequestDays.status, 'approved')))
|
|
125
|
+
.limit(1);
|
|
126
|
+
const computed = computeDay({
|
|
127
|
+
businessDate,
|
|
128
|
+
timeZone: timezone,
|
|
129
|
+
shift: schedule.shiftFor(businessDate),
|
|
130
|
+
punches: punchInputs,
|
|
131
|
+
rounding: schedule.rounding,
|
|
132
|
+
excused: !!onLeave,
|
|
133
|
+
});
|
|
134
|
+
const status = onLeave && computed.workedMinutes === 0 ? 'leave' : computed.status;
|
|
135
|
+
await tx
|
|
136
|
+
.insert(attendanceDays)
|
|
137
|
+
.values({
|
|
138
|
+
id: uuidv7(),
|
|
139
|
+
workspaceId,
|
|
140
|
+
personId,
|
|
141
|
+
businessDate,
|
|
142
|
+
scheduledMinutes: computed.scheduledMinutes,
|
|
143
|
+
workedMinutes: computed.workedMinutes,
|
|
144
|
+
breakMinutes: computed.breakMinutes,
|
|
145
|
+
overtimeMinutes: computed.overtimeMinutes,
|
|
146
|
+
lateMinutes: computed.lateMinutes,
|
|
147
|
+
earlyLeaveMinutes: computed.earlyLeaveMinutes,
|
|
148
|
+
status,
|
|
149
|
+
leaveRequestId: onLeave?.requestId ?? null,
|
|
150
|
+
anomalies: computed.anomalies,
|
|
151
|
+
firstIn: computed.firstIn ? new Date(computed.firstIn) : null,
|
|
152
|
+
lastOut: computed.lastOut ? new Date(computed.lastOut) : null,
|
|
153
|
+
policyHash: hashSchedule(schedule),
|
|
154
|
+
computedAt: new Date(),
|
|
155
|
+
})
|
|
156
|
+
.onConflictDoUpdate({
|
|
157
|
+
target: [attendanceDays.workspaceId, attendanceDays.personId, attendanceDays.businessDate],
|
|
158
|
+
set: {
|
|
159
|
+
scheduledMinutes: computed.scheduledMinutes,
|
|
160
|
+
workedMinutes: computed.workedMinutes,
|
|
161
|
+
breakMinutes: computed.breakMinutes,
|
|
162
|
+
overtimeMinutes: computed.overtimeMinutes,
|
|
163
|
+
lateMinutes: computed.lateMinutes,
|
|
164
|
+
earlyLeaveMinutes: computed.earlyLeaveMinutes,
|
|
165
|
+
status,
|
|
166
|
+
leaveRequestId: onLeave?.requestId ?? null,
|
|
167
|
+
anomalies: computed.anomalies,
|
|
168
|
+
firstIn: computed.firstIn ? new Date(computed.firstIn) : null,
|
|
169
|
+
lastOut: computed.lastOut ? new Date(computed.lastOut) : null,
|
|
170
|
+
policyHash: hashSchedule(schedule),
|
|
171
|
+
computedAt: new Date(),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
return { locked: false };
|
|
175
|
+
}
|
|
176
|
+
/** Which business dates a person has punches on, in a range. Drives a bulk recompute. */
|
|
177
|
+
async datesWithPunches(tx, workspaceId, personId, from, to) {
|
|
178
|
+
const rows = await tx
|
|
179
|
+
.selectDistinct({ businessDate: punches.businessDate })
|
|
180
|
+
.from(punches)
|
|
181
|
+
.where(and(eq(punches.workspaceId, workspaceId), eq(punches.personId, personId), gte(punches.businessDate, from), lte(punches.businessDate, to)));
|
|
182
|
+
return rows.map((r) => r.businessDate).sort();
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Void a punch by writing a correcting row that points at it.
|
|
186
|
+
*
|
|
187
|
+
* The original stays exactly as it was. Two rows is the point: "this was recorded and then
|
|
188
|
+
* corrected" and "this was never recorded" are different facts, and only one of them is true.
|
|
189
|
+
*/
|
|
190
|
+
async voidPunch(tx, workspaceId, punchId, reason, actorPersonId) {
|
|
191
|
+
const [original] = await tx
|
|
192
|
+
.select()
|
|
193
|
+
.from(punches)
|
|
194
|
+
.where(and(eq(punches.workspaceId, workspaceId), eq(punches.id, punchId)))
|
|
195
|
+
.limit(1);
|
|
196
|
+
if (!original)
|
|
197
|
+
throw KernError.notFound('Punch');
|
|
198
|
+
if (original.voidedByPunchId)
|
|
199
|
+
throw KernError.conflict('That punch is already voided');
|
|
200
|
+
const [correction] = await tx
|
|
201
|
+
.insert(punches)
|
|
202
|
+
.values({
|
|
203
|
+
id: uuidv7(),
|
|
204
|
+
workspaceId,
|
|
205
|
+
personId: original.personId,
|
|
206
|
+
direction: original.direction,
|
|
207
|
+
at: original.at,
|
|
208
|
+
businessDate: original.businessDate,
|
|
209
|
+
timezone: original.timezone,
|
|
210
|
+
method: 'manual',
|
|
211
|
+
trust: 'trusted',
|
|
212
|
+
note: `Voids ${punchId}: ${reason}`,
|
|
213
|
+
})
|
|
214
|
+
.returning();
|
|
215
|
+
await tx
|
|
216
|
+
.update(punches)
|
|
217
|
+
.set({ voidedByPunchId: correction.id })
|
|
218
|
+
.where(and(eq(punches.id, punchId), eq(punches.businessDate, original.businessDate)));
|
|
219
|
+
// The correcting row is itself voided: it exists to carry the reason and to point at what it
|
|
220
|
+
// replaced, not to be counted as a punch.
|
|
221
|
+
await tx
|
|
222
|
+
.update(punches)
|
|
223
|
+
.set({ voidedByPunchId: correction.id })
|
|
224
|
+
.where(and(eq(punches.id, correction.id), eq(punches.businessDate, original.businessDate)));
|
|
225
|
+
void actorPersonId;
|
|
226
|
+
return { original, correction: correction };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* A short stamp of what produced a derived row.
|
|
231
|
+
*
|
|
232
|
+
* Not cryptographic — it only has to change when the schedule does, so a recomputation can tell a
|
|
233
|
+
* stale row from a current one without re-deriving it.
|
|
234
|
+
*/
|
|
235
|
+
function hashSchedule(schedule) {
|
|
236
|
+
return `${schedule.scheduleId ?? 'none'}:${schedule.rounding.stepMinutes}:${schedule.rounding.direction}`;
|
|
237
|
+
}
|
|
238
|
+
export { inArray, sql };
|
|
239
|
+
//# sourceMappingURL=attendance.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attendance.js","sourceRoot":"","sources":["../../../src/server/services/attendance.ts"],"names":[],"mappings":"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"}
|
|
@@ -79,12 +79,12 @@ export declare class LedgerService {
|
|
|
79
79
|
reason: string | null;
|
|
80
80
|
kind: string;
|
|
81
81
|
requestId: string | null;
|
|
82
|
+
policyHash: string | null;
|
|
82
83
|
leaveTypeId: string;
|
|
83
84
|
effectiveOn: string;
|
|
84
85
|
amountMinutes: number;
|
|
85
86
|
periodYear: number;
|
|
86
87
|
reversesEntryId: string | null;
|
|
87
|
-
policyHash: string | null;
|
|
88
88
|
createdBy: string | null;
|
|
89
89
|
}>;
|
|
90
90
|
/**
|
|
@@ -102,12 +102,12 @@ export declare class LedgerService {
|
|
|
102
102
|
reason: string | null;
|
|
103
103
|
kind: string;
|
|
104
104
|
requestId: string | null;
|
|
105
|
+
policyHash: string | null;
|
|
105
106
|
leaveTypeId: string;
|
|
106
107
|
effectiveOn: string;
|
|
107
108
|
amountMinutes: number;
|
|
108
109
|
periodYear: number;
|
|
109
110
|
reversesEntryId: string | null;
|
|
110
|
-
policyHash: string | null;
|
|
111
111
|
createdBy: string | null;
|
|
112
112
|
}>;
|
|
113
113
|
/** Every entry a request produced, for cancelling it. */
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
CREATE TABLE "mod_hr"."attendance_days" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
|
|
3
|
+
"workspace_id" uuid NOT NULL,
|
|
4
|
+
"person_id" uuid NOT NULL,
|
|
5
|
+
"business_date" date NOT NULL,
|
|
6
|
+
"scheduled_minutes" integer DEFAULT 0 NOT NULL,
|
|
7
|
+
"worked_minutes" integer DEFAULT 0 NOT NULL,
|
|
8
|
+
"break_minutes" integer DEFAULT 0 NOT NULL,
|
|
9
|
+
"overtime_minutes" integer DEFAULT 0 NOT NULL,
|
|
10
|
+
"late_minutes" integer DEFAULT 0 NOT NULL,
|
|
11
|
+
"early_leave_minutes" integer DEFAULT 0 NOT NULL,
|
|
12
|
+
"status" text DEFAULT 'absent' NOT NULL,
|
|
13
|
+
"leave_request_id" uuid,
|
|
14
|
+
"anomalies" text[] DEFAULT '{}'::text[] NOT NULL,
|
|
15
|
+
"first_in" timestamp with time zone,
|
|
16
|
+
"last_out" timestamp with time zone,
|
|
17
|
+
"policy_hash" text,
|
|
18
|
+
"locked" boolean DEFAULT false NOT NULL,
|
|
19
|
+
"computed_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
20
|
+
);
|
|
21
|
+
--> statement-breakpoint
|
|
22
|
+
CREATE TABLE "mod_hr"."punches" (
|
|
23
|
+
"id" uuid DEFAULT uuidv7() NOT NULL,
|
|
24
|
+
"workspace_id" uuid NOT NULL,
|
|
25
|
+
"person_id" uuid NOT NULL,
|
|
26
|
+
"direction" text NOT NULL,
|
|
27
|
+
"at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
28
|
+
"client_reported_at" timestamp with time zone,
|
|
29
|
+
"skew_ms" integer,
|
|
30
|
+
"business_date" date NOT NULL,
|
|
31
|
+
"timezone" text DEFAULT 'UTC' NOT NULL,
|
|
32
|
+
"method" text DEFAULT 'web' NOT NULL,
|
|
33
|
+
"office_id" uuid,
|
|
34
|
+
"device_id" uuid,
|
|
35
|
+
"geo" jsonb,
|
|
36
|
+
"trust" text DEFAULT 'trusted' NOT NULL,
|
|
37
|
+
"voided_by_punch_id" uuid,
|
|
38
|
+
"idempotency_key" text,
|
|
39
|
+
"note" text,
|
|
40
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
41
|
+
) PARTITION BY RANGE ("business_date");
|
|
42
|
+
--> statement-breakpoint
|
|
43
|
+
-- Partitioned, because five hundred people punching four times a day is half a million rows a year
|
|
44
|
+
-- and retrofitting partitioning onto a live table of those is a migration nobody wants. drizzle-kit
|
|
45
|
+
-- cannot express PARTITION BY, so this block is hand-maintained: **after `pnpm db:generate`,
|
|
46
|
+
-- re-add the `PARTITION BY`, the primary key and the partitions below.**
|
|
47
|
+
--
|
|
48
|
+
-- A partitioned table's primary key must contain the partition column, which is why it is
|
|
49
|
+
-- (id, business_date) rather than (id). Same for the idempotency index: without business_date in it
|
|
50
|
+
-- Postgres refuses to create it at all.
|
|
51
|
+
ALTER TABLE "mod_hr"."punches" ADD PRIMARY KEY ("id", "business_date");
|
|
52
|
+
--> statement-breakpoint
|
|
53
|
+
CREATE UNIQUE INDEX "hr_punches_idem_uq"
|
|
54
|
+
ON "mod_hr"."punches" ("workspace_id", "idempotency_key", "business_date")
|
|
55
|
+
WHERE "idempotency_key" IS NOT NULL;
|
|
56
|
+
--> statement-breakpoint
|
|
57
|
+
-- A DEFAULT partition catches anything outside the ranges below, so a missing monthly partition
|
|
58
|
+
-- degrades to a slow insert rather than a failed punch. Attendance that refuses to record because
|
|
59
|
+
-- a maintenance job did not run is worse than attendance that records into the wrong file.
|
|
60
|
+
CREATE TABLE IF NOT EXISTS "mod_hr"."punches_default"
|
|
61
|
+
PARTITION OF "mod_hr"."punches" DEFAULT;
|
|
62
|
+
--> statement-breakpoint
|
|
63
|
+
ALTER TABLE "mod_hr"."punches_default" ENABLE ROW LEVEL SECURITY;
|
|
64
|
+
--> statement-breakpoint
|
|
65
|
+
ALTER TABLE "mod_hr"."punches_default" FORCE ROW LEVEL SECURITY;
|
|
66
|
+
--> statement-breakpoint
|
|
67
|
+
CREATE POLICY "punches_ws_isolation" ON "mod_hr"."punches_default"
|
|
68
|
+
USING (workspace_id::text = current_setting('app.workspace_id', true))
|
|
69
|
+
WITH CHECK (workspace_id::text = current_setting('app.workspace_id', true));
|
|
70
|
+
--> statement-breakpoint
|
|
71
|
+
-- Creating a partition is not just `CREATE TABLE ... PARTITION OF`.
|
|
72
|
+
--
|
|
73
|
+
-- A policy on a partitioned parent applies when you query **through the parent**. Query a partition
|
|
74
|
+
-- directly and its own row-level security setting applies — and a fresh partition has none. Any
|
|
75
|
+
-- deployment that runs `GRANT SELECT ON ALL TABLES IN SCHEMA mod_hr` therefore hands out an
|
|
76
|
+
-- unfiltered view of one month's punches, silently, to every role that grant touched.
|
|
77
|
+
--
|
|
78
|
+
-- So partition creation goes through this function, which secures what it creates. The monthly job
|
|
79
|
+
-- calls the same function, which is the only way it cannot forget.
|
|
80
|
+
CREATE OR REPLACE FUNCTION "mod_hr".ensure_punch_partition(month_start date)
|
|
81
|
+
RETURNS text
|
|
82
|
+
LANGUAGE plpgsql
|
|
83
|
+
AS $fn$
|
|
84
|
+
DECLARE
|
|
85
|
+
part_name text := 'punches_' || to_char(month_start, 'YYYY_MM');
|
|
86
|
+
BEGIN
|
|
87
|
+
EXECUTE format(
|
|
88
|
+
'CREATE TABLE IF NOT EXISTS %I.%I PARTITION OF %I.%I FOR VALUES FROM (%L) TO (%L)',
|
|
89
|
+
'mod_hr', part_name, 'mod_hr', 'punches', month_start, (month_start + interval '1 month')::date
|
|
90
|
+
);
|
|
91
|
+
EXECUTE format('ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY', 'mod_hr', part_name);
|
|
92
|
+
EXECUTE format('ALTER TABLE %I.%I FORCE ROW LEVEL SECURITY', 'mod_hr', part_name);
|
|
93
|
+
-- `IF NOT EXISTS` on CREATE POLICY needs Postgres 18; the catalogue check works everywhere and
|
|
94
|
+
-- keeps this function safe to call repeatedly, which the monthly job relies on.
|
|
95
|
+
IF NOT EXISTS (
|
|
96
|
+
SELECT 1 FROM pg_policies
|
|
97
|
+
WHERE schemaname = 'mod_hr' AND tablename = part_name AND policyname = 'punches_ws_isolation'
|
|
98
|
+
) THEN
|
|
99
|
+
EXECUTE format(
|
|
100
|
+
'CREATE POLICY "punches_ws_isolation" ON %I.%I
|
|
101
|
+
USING (workspace_id::text = current_setting(''app.workspace_id'', true))
|
|
102
|
+
WITH CHECK (workspace_id::text = current_setting(''app.workspace_id'', true))',
|
|
103
|
+
'mod_hr', part_name
|
|
104
|
+
);
|
|
105
|
+
END IF;
|
|
106
|
+
RETURN part_name;
|
|
107
|
+
END
|
|
108
|
+
$fn$;
|
|
109
|
+
--> statement-breakpoint
|
|
110
|
+
-- Seeds a window either side of today so a fresh instance can record punches immediately.
|
|
111
|
+
-- `hr.ensure-partitions` keeps it rolling.
|
|
112
|
+
DO $$
|
|
113
|
+
DECLARE
|
|
114
|
+
m date := (date_trunc('month', now()) - interval '3 months')::date;
|
|
115
|
+
stop date := (date_trunc('month', now()) + interval '12 months')::date;
|
|
116
|
+
BEGIN
|
|
117
|
+
WHILE m < stop LOOP
|
|
118
|
+
PERFORM "mod_hr".ensure_punch_partition(m);
|
|
119
|
+
m := (m + interval '1 month')::date;
|
|
120
|
+
END LOOP;
|
|
121
|
+
END $$;
|
|
122
|
+
--> statement-breakpoint
|
|
123
|
+
CREATE TABLE "mod_hr"."regularizations" (
|
|
124
|
+
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
|
|
125
|
+
"workspace_id" uuid NOT NULL,
|
|
126
|
+
"person_id" uuid NOT NULL,
|
|
127
|
+
"business_date" date NOT NULL,
|
|
128
|
+
"punch_id" uuid,
|
|
129
|
+
"proposed" jsonb NOT NULL,
|
|
130
|
+
"reason" text NOT NULL,
|
|
131
|
+
"status" text DEFAULT 'pending' NOT NULL,
|
|
132
|
+
"approval_request_id" uuid,
|
|
133
|
+
"applied_at" timestamp with time zone,
|
|
134
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
135
|
+
);
|
|
136
|
+
--> statement-breakpoint
|
|
137
|
+
CREATE TABLE "mod_hr"."schedule_assignments" (
|
|
138
|
+
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
|
|
139
|
+
"workspace_id" uuid NOT NULL,
|
|
140
|
+
"person_id" uuid NOT NULL,
|
|
141
|
+
"schedule_id" uuid NOT NULL,
|
|
142
|
+
"effective_from" date NOT NULL,
|
|
143
|
+
"effective_to" date,
|
|
144
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
145
|
+
);
|
|
146
|
+
--> statement-breakpoint
|
|
147
|
+
CREATE TABLE "mod_hr"."schedules" (
|
|
148
|
+
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
|
|
149
|
+
"workspace_id" uuid NOT NULL,
|
|
150
|
+
"name" text NOT NULL,
|
|
151
|
+
"kind" text DEFAULT 'fixed' NOT NULL,
|
|
152
|
+
"week" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
153
|
+
"tz_mode" text DEFAULT 'office' NOT NULL,
|
|
154
|
+
"tz" text,
|
|
155
|
+
"grace_in_minutes" integer DEFAULT 0 NOT NULL,
|
|
156
|
+
"grace_out_minutes" integer DEFAULT 0 NOT NULL,
|
|
157
|
+
"rounding_step_minutes" integer DEFAULT 0 NOT NULL,
|
|
158
|
+
"rounding_direction" text DEFAULT 'nearest' NOT NULL,
|
|
159
|
+
"auto_clock_out_after_minutes" integer,
|
|
160
|
+
"archived_at" timestamp with time zone,
|
|
161
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
162
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
163
|
+
);
|
|
164
|
+
--> statement-breakpoint
|
|
165
|
+
CREATE UNIQUE INDEX "hr_attendance_days_uq" ON "mod_hr"."attendance_days" USING btree ("workspace_id","person_id","business_date");--> statement-breakpoint
|
|
166
|
+
CREATE INDEX "hr_attendance_days_date_idx" ON "mod_hr"."attendance_days" USING btree ("workspace_id","business_date","status");--> statement-breakpoint
|
|
167
|
+
CREATE INDEX "hr_punches_person_idx" ON "mod_hr"."punches" USING btree ("workspace_id","person_id","business_date");--> statement-breakpoint
|
|
168
|
+
CREATE INDEX "hr_regularizations_idx" ON "mod_hr"."regularizations" USING btree ("workspace_id","person_id","business_date");--> statement-breakpoint
|
|
169
|
+
CREATE INDEX "hr_schedule_assign_idx" ON "mod_hr"."schedule_assignments" USING btree ("workspace_id","person_id","effective_from");--> statement-breakpoint
|
|
170
|
+
CREATE INDEX "hr_schedules_ws_idx" ON "mod_hr"."schedules" USING btree ("workspace_id","archived_at");--> statement-breakpoint
|
|
171
|
+
-- ---------------------------------------------------------------------------------------------
|
|
172
|
+
-- Row-level security on the attendance tables.
|
|
173
|
+
--
|
|
174
|
+
-- A policy on a partitioned parent applies to every partition, including ones created later — which
|
|
175
|
+
-- is what makes the rolling partition job safe. The test asserts a freshly created partition
|
|
176
|
+
-- returns nothing without `app.workspace_id`, because "it inherits" is the kind of thing that is
|
|
177
|
+
-- true until a Postgres upgrade says otherwise.
|
|
178
|
+
alter table "mod_hr"."schedules" enable row level security;--> statement-breakpoint
|
|
179
|
+
alter table "mod_hr"."schedules" force row level security;--> statement-breakpoint
|
|
180
|
+
create policy "schedules_ws_isolation" on "mod_hr"."schedules"
|
|
181
|
+
using (workspace_id::text = current_setting('app.workspace_id', true))
|
|
182
|
+
with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
|
|
183
|
+
|
|
184
|
+
alter table "mod_hr"."schedule_assignments" enable row level security;--> statement-breakpoint
|
|
185
|
+
alter table "mod_hr"."schedule_assignments" force row level security;--> statement-breakpoint
|
|
186
|
+
create policy "schedule_assignments_ws_isolation" on "mod_hr"."schedule_assignments"
|
|
187
|
+
using (workspace_id::text = current_setting('app.workspace_id', true))
|
|
188
|
+
with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
|
|
189
|
+
|
|
190
|
+
alter table "mod_hr"."punches" enable row level security;--> statement-breakpoint
|
|
191
|
+
alter table "mod_hr"."punches" force row level security;--> statement-breakpoint
|
|
192
|
+
create policy "punches_ws_isolation" on "mod_hr"."punches"
|
|
193
|
+
using (workspace_id::text = current_setting('app.workspace_id', true))
|
|
194
|
+
with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
|
|
195
|
+
|
|
196
|
+
alter table "mod_hr"."attendance_days" enable row level security;--> statement-breakpoint
|
|
197
|
+
alter table "mod_hr"."attendance_days" force row level security;--> statement-breakpoint
|
|
198
|
+
create policy "attendance_days_ws_isolation" on "mod_hr"."attendance_days"
|
|
199
|
+
using (workspace_id::text = current_setting('app.workspace_id', true))
|
|
200
|
+
with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
|
|
201
|
+
|
|
202
|
+
alter table "mod_hr"."regularizations" enable row level security;--> statement-breakpoint
|
|
203
|
+
alter table "mod_hr"."regularizations" force row level security;--> statement-breakpoint
|
|
204
|
+
create policy "regularizations_ws_isolation" on "mod_hr"."regularizations"
|
|
205
|
+
using (workspace_id::text = current_setting('app.workspace_id', true))
|
|
206
|
+
with check (workspace_id::text = current_setting('app.workspace_id', true));
|