@kernhq/module-hr 0.19.0 → 0.20.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 (40) hide show
  1. package/dist/contract/capabilities.d.ts.map +1 -1
  2. package/dist/contract/capabilities.js +29 -0
  3. package/dist/contract/capabilities.js.map +1 -1
  4. package/dist/contract/index.d.ts +1 -0
  5. package/dist/contract/index.d.ts.map +1 -1
  6. package/dist/contract/index.js +1 -0
  7. package/dist/contract/index.js.map +1 -1
  8. package/dist/contract/permissions.d.ts +2 -2
  9. package/dist/contract/permissions.d.ts.map +1 -1
  10. package/dist/contract/permissions.js +12 -2
  11. package/dist/contract/permissions.js.map +1 -1
  12. package/dist/contract/rosters.d.ts +183 -0
  13. package/dist/contract/rosters.d.ts.map +1 -0
  14. package/dist/contract/rosters.js +138 -0
  15. package/dist/contract/rosters.js.map +1 -0
  16. package/dist/contract/router.d.ts +621 -0
  17. package/dist/contract/router.d.ts.map +1 -1
  18. package/dist/contract/router.js +153 -1
  19. package/dist/contract/router.js.map +1 -1
  20. package/dist/server/router.d.ts +927 -0
  21. package/dist/server/router.d.ts.map +1 -1
  22. package/dist/server/router.js +462 -1
  23. package/dist/server/router.js.map +1 -1
  24. package/dist/server/schema.d.ts +727 -1
  25. package/dist/server/schema.d.ts.map +1 -1
  26. package/dist/server/schema.js +105 -0
  27. package/dist/server/schema.js.map +1 -1
  28. package/dist/server/services/rosters.d.ts +166 -0
  29. package/dist/server/services/rosters.d.ts.map +1 -0
  30. package/dist/server/services/rosters.js +268 -0
  31. package/dist/server/services/rosters.js.map +1 -0
  32. package/migrations/0012_rosters.sql +163 -0
  33. package/migrations/meta/0012_snapshot.json +4847 -0
  34. package/migrations/meta/_journal.json +7 -0
  35. package/package.json +1 -1
  36. package/src/contract/capabilities.ts +29 -0
  37. package/src/contract/index.ts +1 -0
  38. package/src/contract/permissions.ts +13 -2
  39. package/src/contract/rosters.ts +156 -0
  40. package/src/contract/router.ts +183 -0
@@ -0,0 +1,268 @@
1
+ import { KernError } from '@kernhq/kernel';
2
+ import { and, eq, gte, inArray, isNull, lte, or } from 'drizzle-orm';
3
+ import { MAX_COVERAGE_DAYS, MAX_ROSTER_DAYS } from '../../contract/rosters.js';
4
+ import { datesBetween } from '../../policy/calendar.js';
5
+ import { rosterAssignments, rosterOverrides, rosterPatterns, rosterShifts } from '../schema.js';
6
+ /**
7
+ * What a person is rostered to work on a date.
8
+ *
9
+ * The arithmetic is the point of this file, and all of it is pure. A rotation is a cycle plus the
10
+ * date the cycle starts from, so what somebody works on any date at all — including dates before
11
+ * the anchor and dates years after it — is one modulus. Nothing is generated, stored, or swept:
12
+ * expanding a year of shifts per person into rows is what makes a roster impossible to change
13
+ * afterwards, because moving a crew forward by a day then means rewriting thousands of rows with no
14
+ * way left to tell which of them a human had already corrected.
15
+ *
16
+ * Only exceptions are rows. `roster_overrides` holds one day that differs, and an override that
17
+ * says "off" is a row with an empty `shift_ids` — the reason it is a row rather than a deletion is
18
+ * that "planned rest" and "nothing rosters this person" are different facts, and a screen that
19
+ * renders them the same tells somebody their absence was intended.
20
+ */
21
+ // ---------------------------------------------------------------------------------------------
22
+ // pure: the calendar arithmetic
23
+ // ---------------------------------------------------------------------------------------------
24
+ /**
25
+ * Days since 1970-01-01 for a civil date, by pure arithmetic.
26
+ *
27
+ * Not `new Date(iso).getTime() / 86400000`: that parses as UTC midnight and then everything the
28
+ * result touches is a step away from the runtime's own zone, and it is wrong by a day for anybody
29
+ * west of Greenwich the moment a caller formats it back. This is Howard Hinnant's `days_from_civil`
30
+ * — exact for every proleptic Gregorian date, with no `Date`, no zone and no daylight saving
31
+ * anywhere near it. A cycle index computed from instants would slip by one twice a year in every
32
+ * zone that observes it, which on a 4-on-4-off rotation puts a whole crew on the wrong shift.
33
+ */
34
+ export function dayNumber(date) {
35
+ const [y, m, d] = date.split('-').map(Number);
36
+ const year = y - (m <= 2 ? 1 : 0);
37
+ const era = Math.floor(year / 400);
38
+ const yoe = year - era * 400;
39
+ const doy = Math.floor((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1;
40
+ const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
41
+ return era * 146_097 + doe - 719_468;
42
+ }
43
+ /** Whole days from `from` to `to`. Negative when `to` is the earlier date. */
44
+ export const daysBetween = (from, to) => dayNumber(to) - dayNumber(from);
45
+ /**
46
+ * Which position of the cycle a date falls on.
47
+ *
48
+ * `((raw % len) + len) % len` rather than `raw % len`, because JavaScript's `%` keeps the sign of
49
+ * the dividend: a date before the anchor gives a negative index, and `days[-3]` is `undefined` —
50
+ * which reads as a rest day rather than as an error. A roster asked about last month would then
51
+ * quietly report that everybody was off.
52
+ *
53
+ * Returns -1 for a pattern with no cycle at all, which is a misconfiguration rather than a rest day
54
+ * and is reported as `none` by the caller.
55
+ */
56
+ export function cycleIndexFor(pattern, date, cycleOffset = 0) {
57
+ const len = pattern.days.length;
58
+ if (len <= 0)
59
+ return -1;
60
+ const raw = daysBetween(pattern.anchorDate, date) + cycleOffset;
61
+ return ((raw % len) + len) % len;
62
+ }
63
+ /** The shifts a rotation puts on a date. `[]` is a planned rest day; null is no cycle to read. */
64
+ export function patternShiftIdsOn(pattern, date, cycleOffset = 0) {
65
+ const index = cycleIndexFor(pattern, date, cycleOffset);
66
+ if (index < 0)
67
+ return null;
68
+ return [...(pattern.days[index] ?? [])];
69
+ }
70
+ /**
71
+ * The assignment in force on a date.
72
+ *
73
+ * The exclusion constraint makes two-in-force impossible in the database, so this is not resolving
74
+ * a conflict — it is refusing to depend on row order for the answer. `schedule_assignments` did
75
+ * depend on it for five migrations, and the symptom was a day sheet whose figures changed between
76
+ * one recomputation and the next on rows a locked payroll period had been filed against.
77
+ */
78
+ export function assignmentOn(assignments, date) {
79
+ let best = null;
80
+ for (const a of assignments) {
81
+ if (a.effectiveFrom > date)
82
+ continue;
83
+ if (a.effectiveTo !== null && a.effectiveTo < date)
84
+ continue;
85
+ if (!best || a.effectiveFrom > best.effectiveFrom)
86
+ best = a;
87
+ }
88
+ return best;
89
+ }
90
+ /**
91
+ * The roster for one person over a set of dates.
92
+ *
93
+ * Takes the dates rather than a range so a caller asking about two adjacent days — which is what
94
+ * the night-shift attribution needs — does not have to expand a range to get them.
95
+ *
96
+ * The precedence is override, then rotation, then nothing, and it is deliberate that an override
97
+ * wins even when it is empty: somebody taken off a Tuesday they were rostered for is off that
98
+ * Tuesday, and a rotation that could still speak over the top of that would undo the correction.
99
+ */
100
+ export function rosterPlan(input) {
101
+ const byDate = new Map(input.overrides.map((o) => [o.businessDate, o]));
102
+ return input.dates.map((businessDate) => {
103
+ const override = byDate.get(businessDate);
104
+ if (override)
105
+ return {
106
+ businessDate,
107
+ shiftIds: [...override.shiftIds],
108
+ source: 'override',
109
+ note: override.note,
110
+ };
111
+ const assignment = assignmentOn(input.assignments, businessDate);
112
+ const pattern = assignment ? input.patterns.get(assignment.patternId) : undefined;
113
+ const ids = pattern ? patternShiftIdsOn(pattern, businessDate, assignment?.cycleOffset ?? 0) : null;
114
+ if (ids === null)
115
+ return { businessDate, shiftIds: [], source: 'none', note: null };
116
+ return { businessDate, shiftIds: ids, source: 'pattern', note: null };
117
+ });
118
+ }
119
+ /** A rostered shift as the working-time layer wants it. The one seam between the two. */
120
+ export const toShiftSpec = (shift) => ({
121
+ start: shift.startTime,
122
+ end: shift.endTime,
123
+ breakMinutes: shift.breakMinutes,
124
+ graceInMinutes: shift.graceInMinutes,
125
+ graceOutMinutes: shift.graceOutMinutes,
126
+ });
127
+ /**
128
+ * A stable string for what a day was rostered as.
129
+ *
130
+ * `attendance_days.policy_hash` is what tells a recomputation whether a stored figure is stale, and
131
+ * `hashSchedule` keys only on the schedule id and the rounding policy. That is enough while a
132
+ * schedule is a week that only changes when the schedule row does; it is not enough once a roster
133
+ * can change one Tuesday, because the id and the rounding are identical either side of the change
134
+ * and the sheet would stay stale with nothing able to notice.
135
+ *
136
+ * So this folds in what the day was actually rostered as — the shift ids **and** their wall clocks,
137
+ * because editing a shift from 06:00 to 07:00 changes every day it appears on without changing an
138
+ * id anywhere.
139
+ */
140
+ export function rosterFingerprint(day) {
141
+ const parts = day.shifts.map((s) => `${s.id}@${s.startTime}-${s.endTime}/${s.breakMinutes}/${s.graceInMinutes}/${s.graceOutMinutes}`);
142
+ return `${day.source}:${parts.join('+')}`;
143
+ }
144
+ /** The ceiling on a coverage grid's person-days — a hundred people for a six-week rotation. */
145
+ export const MAX_COVERAGE_CELLS = 4200;
146
+ /**
147
+ * Why a range is refused, as a sentence.
148
+ *
149
+ * A roster range is cheap to expand and expensive to send, and a coverage grid multiplies by the
150
+ * population of an office. Both refusals name the number asked for as well as the ceiling, because
151
+ * "too long" without either is a message somebody has to guess their way out of.
152
+ */
153
+ export function rosterRefusal(input) {
154
+ if (input.to < input.from)
155
+ return `The end date ${input.to} is before the start date ${input.from}.`;
156
+ const days = daysBetween(input.from, input.to) + 1;
157
+ const max = input.coverage ? MAX_COVERAGE_DAYS : MAX_ROSTER_DAYS;
158
+ if (days > max)
159
+ return input.coverage
160
+ ? `A coverage grid covers at most ${max} days, and this one asks for ${days}. Ask for a shorter range.`
161
+ : `A roster covers at most ${max} days, and this one asks for ${days}. Ask for a shorter range.`;
162
+ if (input.coverage && input.population !== undefined) {
163
+ const cells = input.population * days;
164
+ if (cells > MAX_COVERAGE_CELLS)
165
+ return `${input.population} people over ${days} days is ${cells} person-days, and a coverage grid resolves at most ${MAX_COVERAGE_CELLS}. Ask for one office, or a shorter range.`;
166
+ }
167
+ return null;
168
+ }
169
+ export class RosterService {
170
+ /**
171
+ * Every shift in the workspace by id, **archived ones included**.
172
+ *
173
+ * A pattern and a stored override both point at a shift by id, so resolving only the live ones
174
+ * would silently empty out every rostered day that used a shift somebody archived last week —
175
+ * which reads as "nobody is working" rather than as "this shift is retired".
176
+ */
177
+ async shiftsById(tx, workspaceId) {
178
+ const rows = await tx.select().from(rosterShifts).where(eq(rosterShifts.workspaceId, workspaceId));
179
+ return new Map(rows.map((r) => [r.id, r]));
180
+ }
181
+ /**
182
+ * What each of `personIds` is rostered for on each of `dates`.
183
+ *
184
+ * One query per table for the whole population rather than one per person: a coverage grid over
185
+ * an office of eighty is otherwise 240 round trips for arithmetic that takes microseconds.
186
+ *
187
+ * `inArray` renders an expanded parameter list — `person_id in ($1, $2, …)`. That is deliberate
188
+ * and is not the same as binding one array: `= any($1::uuid[])` with drizzle expanding the array
189
+ * into a list produces `any(($1,$2)::uuid[])`, which Postgres rejects with 42846, "cannot cast
190
+ * type record to uuid[]".
191
+ */
192
+ async plan(tx, workspaceId, personIds, dates) {
193
+ const out = new Map();
194
+ if (!personIds.length || !dates.length)
195
+ return out;
196
+ const sorted = [...dates].sort();
197
+ const first = sorted[0];
198
+ const last = sorted[sorted.length - 1];
199
+ const ids = [...new Set(personIds)];
200
+ const assignments = await tx
201
+ .select()
202
+ .from(rosterAssignments)
203
+ .where(and(eq(rosterAssignments.workspaceId, workspaceId), inArray(rosterAssignments.personId, ids), lte(rosterAssignments.effectiveFrom, last), or(isNull(rosterAssignments.effectiveTo), gte(rosterAssignments.effectiveTo, first))));
204
+ const overrides = await tx
205
+ .select()
206
+ .from(rosterOverrides)
207
+ .where(and(eq(rosterOverrides.workspaceId, workspaceId), inArray(rosterOverrides.personId, ids), inArray(rosterOverrides.businessDate, [...new Set(sorted)])));
208
+ const patternIds = [...new Set(assignments.map((a) => a.patternId))];
209
+ const patternRows = patternIds.length
210
+ ? await tx
211
+ .select()
212
+ .from(rosterPatterns)
213
+ .where(and(eq(rosterPatterns.workspaceId, workspaceId), inArray(rosterPatterns.id, patternIds)))
214
+ : [];
215
+ const patterns = new Map(patternRows.map((p) => [p.id, { id: p.id, anchorDate: p.anchorDate, days: p.days ?? [] }]));
216
+ const shifts = await this.shiftsById(tx, workspaceId);
217
+ for (const personId of ids) {
218
+ const plan = rosterPlan({
219
+ dates,
220
+ assignments: assignments.filter((a) => a.personId === personId),
221
+ patterns,
222
+ overrides: overrides
223
+ .filter((o) => o.personId === personId)
224
+ .map((o) => ({ businessDate: o.businessDate, shiftIds: o.shiftIds ?? [], note: o.note })),
225
+ });
226
+ out.set(personId, plan.map((day) => ({
227
+ businessDate: day.businessDate,
228
+ // A shift id with no row behind it is dropped rather than rendered as a hole: the only
229
+ // way to produce one is to point a pattern at an id that was never a shift, and the
230
+ // procedures below refuse that on write.
231
+ shifts: day.shiftIds.flatMap((id) => {
232
+ const shift = shifts.get(id);
233
+ return shift ? [shift] : [];
234
+ }),
235
+ source: day.source,
236
+ note: day.note,
237
+ })));
238
+ }
239
+ return out;
240
+ }
241
+ /** One person over a contiguous range — the shape a roster screen asks for. */
242
+ async forPerson(tx, workspaceId, personId, from, to) {
243
+ const plan = await this.plan(tx, workspaceId, [personId], datesBetween(from, to));
244
+ return plan.get(personId) ?? [];
245
+ }
246
+ /**
247
+ * Refuse a pattern or an override that names a shift this workspace does not have.
248
+ *
249
+ * Checked on write rather than tolerated on read: a dangling id is invisible on the screen that
250
+ * created it and shows up weeks later as a person who is somehow rostered for nothing.
251
+ */
252
+ async assertShiftsExist(tx, workspaceId, shiftIds) {
253
+ const wanted = [...new Set(shiftIds)];
254
+ if (!wanted.length)
255
+ return;
256
+ const found = await tx
257
+ .select({ id: rosterShifts.id })
258
+ .from(rosterShifts)
259
+ .where(and(eq(rosterShifts.workspaceId, workspaceId), inArray(rosterShifts.id, wanted)));
260
+ const have = new Set(found.map((r) => r.id));
261
+ const missing = wanted.filter((id) => !have.has(id));
262
+ if (missing.length)
263
+ throw KernError.badRequest(missing.length === 1
264
+ ? `This roster names a shift this workspace does not have: ${missing[0]}.`
265
+ : `This roster names shifts this workspace does not have: ${missing.join(', ')}.`);
266
+ }
267
+ }
268
+ //# sourceMappingURL=rosters.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rosters.js","sourceRoot":"","sources":["../../../src/server/services/rosters.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,163 @@
1
+ -- Rosters: which shift a person works on a **date**.
2
+ --
3
+ -- `schedules` is a week that repeats for ever, keyed by weekday name. That is the right shape for an
4
+ -- office and cannot express a factory: 4-on-4-off has no weekly period at all, so there is no
5
+ -- `ScheduleWeek` of any length that describes one. These four tables answer the calendar instead.
6
+ --
7
+ -- * `roster_shifts` — Early, Late, Night. Named rather than inlined into every rotation because
8
+ -- coverage groups by it: "Early" in two rotations has to be one column of one grid.
9
+ -- * `roster_patterns` — a rotation, as a cycle of days and the date `days[0]` falls on. `days` is an
10
+ -- array of arrays of shift ids; an empty entry is a planned rest day and two entries is a split
11
+ -- shift. The cycle length is `jsonb_array_length(days)` and is deliberately not a second column.
12
+ -- * `roster_assignments` — a person on a rotation, effective-dated, with a `cycle_offset` that puts
13
+ -- two crews on one rotation out of phase.
14
+ -- * `roster_overrides` — one day that differs, and nothing else. The whole reason a roster is not a
15
+ -- schedule: a schedule change rewrites every day after it, and somebody covering one Tuesday needs
16
+ -- one Tuesday changed.
17
+ --
18
+ -- **Nothing is expanded into rows.** What somebody works on a date is arithmetic from the anchor,
19
+ -- the offset and the cycle. Generating a year of shifts per person is what makes a roster impossible
20
+ -- to change afterwards: moving a crew forward one day becomes a bulk rewrite of thousands of rows
21
+ -- with no way left to tell which of them a human had already corrected.
22
+ --
23
+ -- Additive throughout — four new tables and no change to anything that exists — so the image before
24
+ -- this one reads the schema unchanged and a rollback needs no dump.
25
+ --
26
+ -- Generated by drizzle-kit from `src/server/schema.ts` and then given `if not exists` on every
27
+ -- statement, plus the two things drizzle cannot express: the exclusion constraint and the RLS
28
+ -- blocks. Drizzle keys applied migrations by content hash, so editing any file in this folder
29
+ -- replays every file in it — and a module migration that throws takes down every module in the host
30
+ -- service at boot, not only this one.
31
+ CREATE TABLE IF NOT EXISTS "mod_hr"."roster_assignments" (
32
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
33
+ "workspace_id" uuid NOT NULL,
34
+ "person_id" uuid NOT NULL,
35
+ "pattern_id" uuid NOT NULL,
36
+ "effective_from" date NOT NULL,
37
+ "effective_to" date,
38
+ "cycle_offset" integer DEFAULT 0 NOT NULL,
39
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
40
+ );
41
+ --> statement-breakpoint
42
+ CREATE TABLE IF NOT EXISTS "mod_hr"."roster_overrides" (
43
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
44
+ "workspace_id" uuid NOT NULL,
45
+ "person_id" uuid NOT NULL,
46
+ "business_date" date NOT NULL,
47
+ "shift_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
48
+ "note" text,
49
+ "created_by" uuid,
50
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
51
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
52
+ );
53
+ --> statement-breakpoint
54
+ CREATE TABLE IF NOT EXISTS "mod_hr"."roster_patterns" (
55
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
56
+ "workspace_id" uuid NOT NULL,
57
+ "name" text NOT NULL,
58
+ "anchor_date" date NOT NULL,
59
+ "days" jsonb DEFAULT '[]'::jsonb NOT NULL,
60
+ "archived_at" timestamp with time zone,
61
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
62
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
63
+ );
64
+ --> statement-breakpoint
65
+ CREATE TABLE IF NOT EXISTS "mod_hr"."roster_shifts" (
66
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
67
+ "workspace_id" uuid NOT NULL,
68
+ "name" text NOT NULL,
69
+ "code" text,
70
+ "start_time" text NOT NULL,
71
+ "end_time" text NOT NULL,
72
+ "break_minutes" integer DEFAULT 0 NOT NULL,
73
+ "grace_in_minutes" integer DEFAULT 0 NOT NULL,
74
+ "grace_out_minutes" integer DEFAULT 0 NOT NULL,
75
+ "color" text,
76
+ "archived_at" timestamp with time zone,
77
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
78
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
79
+ );
80
+ --> statement-breakpoint
81
+ CREATE INDEX IF NOT EXISTS "hr_roster_assign_idx" ON "mod_hr"."roster_assignments" USING btree ("workspace_id","person_id","effective_from");--> statement-breakpoint
82
+ CREATE UNIQUE INDEX IF NOT EXISTS "hr_roster_override_uq" ON "mod_hr"."roster_overrides" USING btree ("workspace_id","person_id","business_date");--> statement-breakpoint
83
+ CREATE INDEX IF NOT EXISTS "hr_roster_patterns_ws_idx" ON "mod_hr"."roster_patterns" USING btree ("workspace_id","archived_at");--> statement-breakpoint
84
+ CREATE INDEX IF NOT EXISTS "hr_roster_shifts_ws_idx" ON "mod_hr"."roster_shifts" USING btree ("workspace_id","archived_at");--> statement-breakpoint
85
+
86
+ -- ---------------------------------------------------------------------------------------------
87
+ -- Two rotations may not be in force for one person on the same day.
88
+ --
89
+ -- The same constraint `schedule_assignments` waited five migrations for, and here from the day the
90
+ -- table exists, because the failure it prevents is not theoretical: the resolver picks the
91
+ -- assignment in force with `limit 1`, and with two rows in force it takes whichever the executor
92
+ -- hands back first. On `schedule_assignments` that showed up as `scheduled_minutes` changing
93
+ -- between one recomputation and the next on rows a locked payroll period had already been filed
94
+ -- against — see 0006, which had to repair the data before it could add the constraint. There is
95
+ -- nothing to repair here.
96
+ --
97
+ -- The `where` clause carries the same exception 0006 does: `daterange(from, to, '[]')` raises for a
98
+ -- reversed pair rather than returning an empty range, so without it a backdated assignment would
99
+ -- fail with a raw Postgres range error. Such a row is in force on no day at all — `inForceOn` needs
100
+ -- `effective_from <= d` and `effective_to >= d` at once — so it cannot be the second rotation this
101
+ -- constraint exists to prevent.
102
+ --
103
+ -- `add constraint` has no `if not exists`, hence the catalogue check. `btree_gist` is created in
104
+ -- 0000, which is what lets a uuid sit beside a range under gist; a module reaching for a gist
105
+ -- exclusion constraint without it fails on any clean database with "data type uuid has no default
106
+ -- operator class", during the module's own migration, so the service does not start.
107
+ do $$
108
+ begin
109
+ if not exists (
110
+ select 1
111
+ from pg_constraint c
112
+ join pg_class t on t.oid = c.conrelid
113
+ join pg_namespace n on n.oid = t.relnamespace
114
+ where n.nspname = 'mod_hr'
115
+ and t.relname = 'roster_assignments'
116
+ and c.conname = 'hr_roster_assign_no_overlap'
117
+ ) then
118
+ alter table "mod_hr"."roster_assignments"
119
+ add constraint "hr_roster_assign_no_overlap"
120
+ exclude using gist (
121
+ "workspace_id" with =,
122
+ "person_id" with =,
123
+ daterange("effective_from", "effective_to", '[]') with &&
124
+ ) where ("effective_to" is null or "effective_to" >= "effective_from");
125
+ end if;
126
+ end $$;--> statement-breakpoint
127
+
128
+ -- ---------------------------------------------------------------------------------------------
129
+ -- Row-level security for the four new tenant tables, in the same shape as 0001 and for the same
130
+ -- reason: the API checks membership and permission first, and this is what is left when a query
131
+ -- reaches the database another way. `create policy` has no `if not exists` at all, so each one is
132
+ -- preceded by a `drop policy if exists` — without that pair, replaying this file throws.
133
+ alter table "mod_hr"."roster_shifts" enable row level security;--> statement-breakpoint
134
+ alter table "mod_hr"."roster_shifts" force row level security;--> statement-breakpoint
135
+ drop policy if exists "roster_shifts_ws_isolation" on "mod_hr"."roster_shifts";
136
+ --> statement-breakpoint
137
+ create policy "roster_shifts_ws_isolation" on "mod_hr"."roster_shifts"
138
+ using (workspace_id::text = current_setting('app.workspace_id', true))
139
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
140
+
141
+ alter table "mod_hr"."roster_patterns" enable row level security;--> statement-breakpoint
142
+ alter table "mod_hr"."roster_patterns" force row level security;--> statement-breakpoint
143
+ drop policy if exists "roster_patterns_ws_isolation" on "mod_hr"."roster_patterns";
144
+ --> statement-breakpoint
145
+ create policy "roster_patterns_ws_isolation" on "mod_hr"."roster_patterns"
146
+ using (workspace_id::text = current_setting('app.workspace_id', true))
147
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
148
+
149
+ alter table "mod_hr"."roster_assignments" enable row level security;--> statement-breakpoint
150
+ alter table "mod_hr"."roster_assignments" force row level security;--> statement-breakpoint
151
+ drop policy if exists "roster_assignments_ws_isolation" on "mod_hr"."roster_assignments";
152
+ --> statement-breakpoint
153
+ create policy "roster_assignments_ws_isolation" on "mod_hr"."roster_assignments"
154
+ using (workspace_id::text = current_setting('app.workspace_id', true))
155
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
156
+
157
+ alter table "mod_hr"."roster_overrides" enable row level security;--> statement-breakpoint
158
+ alter table "mod_hr"."roster_overrides" force row level security;--> statement-breakpoint
159
+ drop policy if exists "roster_overrides_ws_isolation" on "mod_hr"."roster_overrides";
160
+ --> statement-breakpoint
161
+ create policy "roster_overrides_ws_isolation" on "mod_hr"."roster_overrides"
162
+ using (workspace_id::text = current_setting('app.workspace_id', true))
163
+ with check (workspace_id::text = current_setting('app.workspace_id', true));