@kernhq/module-hr 0.2.0 → 0.3.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 (56) hide show
  1. package/dist/contract/approvals.d.ts +281 -0
  2. package/dist/contract/approvals.d.ts.map +1 -0
  3. package/dist/contract/approvals.js +127 -0
  4. package/dist/contract/approvals.js.map +1 -0
  5. package/dist/contract/capabilities.d.ts +1 -1
  6. package/dist/contract/capabilities.d.ts.map +1 -1
  7. package/dist/contract/capabilities.js +44 -0
  8. package/dist/contract/capabilities.js.map +1 -1
  9. package/dist/contract/events.d.ts +42 -0
  10. package/dist/contract/events.d.ts.map +1 -1
  11. package/dist/contract/events.js +42 -0
  12. package/dist/contract/events.js.map +1 -1
  13. package/dist/contract/index.d.ts +2 -0
  14. package/dist/contract/index.d.ts.map +1 -1
  15. package/dist/contract/index.js +2 -0
  16. package/dist/contract/index.js.map +1 -1
  17. package/dist/contract/leave.d.ts +168 -0
  18. package/dist/contract/leave.d.ts.map +1 -0
  19. package/dist/contract/leave.js +150 -0
  20. package/dist/contract/leave.js.map +1 -0
  21. package/dist/contract/permissions.d.ts +57 -0
  22. package/dist/contract/permissions.d.ts.map +1 -1
  23. package/dist/contract/permissions.js +69 -0
  24. package/dist/contract/permissions.js.map +1 -1
  25. package/dist/contract/router.d.ts +1446 -0
  26. package/dist/contract/router.d.ts.map +1 -1
  27. package/dist/contract/router.js +235 -0
  28. package/dist/contract/router.js.map +1 -1
  29. package/dist/server/router.d.ts +1646 -58
  30. package/dist/server/router.d.ts.map +1 -1
  31. package/dist/server/router.js +812 -2
  32. package/dist/server/router.js.map +1 -1
  33. package/dist/server/schema.d.ts +2084 -1
  34. package/dist/server/schema.d.ts.map +1 -1
  35. package/dist/server/schema.js +209 -0
  36. package/dist/server/schema.js.map +1 -1
  37. package/dist/server/services/approvals.d.ts +163 -0
  38. package/dist/server/services/approvals.d.ts.map +1 -0
  39. package/dist/server/services/approvals.js +325 -0
  40. package/dist/server/services/approvals.js.map +1 -0
  41. package/dist/server/services/ledger.d.ts +135 -0
  42. package/dist/server/services/ledger.d.ts.map +1 -0
  43. package/dist/server/services/ledger.js +178 -0
  44. package/dist/server/services/ledger.js.map +1 -0
  45. package/dist/server/services/people.d.ts +3 -3
  46. package/migrations/0002_leave.sql +237 -0
  47. package/migrations/meta/0002_snapshot.json +3009 -0
  48. package/migrations/meta/_journal.json +7 -0
  49. package/package.json +1 -1
  50. package/src/contract/approvals.ts +149 -0
  51. package/src/contract/capabilities.ts +44 -0
  52. package/src/contract/events.ts +57 -0
  53. package/src/contract/index.ts +2 -0
  54. package/src/contract/leave.ts +171 -0
  55. package/src/contract/permissions.ts +71 -0
  56. package/src/contract/router.ts +285 -0
@@ -0,0 +1,178 @@
1
+ import { KernError, uuidv7 } from '@kernhq/kernel';
2
+ import { and, eq, inArray, sql } from 'drizzle-orm';
3
+ import { leaveBalanceCursor, leaveLedger, leaveRequestDays, leaveTypes } from '../schema.js';
4
+ /** A minute count in whatever unit the type displays. Hours to one place, days to two. */
5
+ export const toUnit = (minutes, unit) => unit === 'hour' ? Math.round((minutes / 60) * 10) / 10 : Math.round((minutes / (60 * 8)) * 100) / 100;
6
+ /** Minutes in one working day. A constant here; a policy in Phase 4, per contract. */
7
+ export const MINUTES_PER_DAY = 8 * 60;
8
+ export class LedgerService {
9
+ /**
10
+ * Balances for one person, per leave type.
11
+ *
12
+ * Summed from the ledger rather than read from the cursor: the cursor exists to be locked, and a
13
+ * cache that is also the source of truth is a cache that eventually disagrees with it. The sum is
14
+ * one indexed aggregate over a person's own rows, which is small.
15
+ */
16
+ async balances(tx, workspaceId, personId, periodYear) {
17
+ const types = await tx
18
+ .select()
19
+ .from(leaveTypes)
20
+ .where(and(eq(leaveTypes.workspaceId, workspaceId), sql `${leaveTypes.archivedAt} is null`));
21
+ const sums = await tx
22
+ .select({
23
+ leaveTypeId: leaveLedger.leaveTypeId,
24
+ total: sql `coalesce(sum(${leaveLedger.amountMinutes}), 0)::int`,
25
+ })
26
+ .from(leaveLedger)
27
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.personId, personId), eq(leaveLedger.periodYear, periodYear)))
28
+ .groupBy(leaveLedger.leaveTypeId);
29
+ const byType = new Map(sums.map((r) => [r.leaveTypeId, Number(r.total)]));
30
+ // Pending requests are not spent, but they are not available either — showing a balance that
31
+ // ignores them is how somebody books the same day twice and only finds out at approval.
32
+ const pending = await tx
33
+ .select({
34
+ leaveTypeId: sql `lr.leave_type_id`,
35
+ status: sql `lr.status`,
36
+ minutes: sql `coalesce(sum(lr.minutes), 0)::int`,
37
+ })
38
+ .from(sql `${leaveRequestDays} d join mod_hr.leave_requests lr on lr.id = d.request_id`)
39
+ .where(sql `d.workspace_id = ${workspaceId} and d.person_id = ${personId}
40
+ and lr.status in ('pending','approved')`)
41
+ .groupBy(sql `lr.leave_type_id, lr.status`);
42
+ const pendingBy = new Map();
43
+ const bookedBy = new Map();
44
+ for (const row of pending) {
45
+ const target = row.status === 'approved' ? bookedBy : pendingBy;
46
+ target.set(row.leaveTypeId, (target.get(row.leaveTypeId) ?? 0) + Number(row.minutes));
47
+ }
48
+ return types.map((type) => {
49
+ const balanceMinutes = byType.get(type.id) ?? 0;
50
+ const pendingMinutes = pendingBy.get(type.id) ?? 0;
51
+ return {
52
+ personId,
53
+ leaveTypeId: type.id,
54
+ leaveTypeName: type.name,
55
+ unit: type.unit,
56
+ periodYear,
57
+ balanceMinutes,
58
+ bookedMinutes: bookedBy.get(type.id) ?? 0,
59
+ pendingMinutes,
60
+ availableMinutes: balanceMinutes - pendingMinutes,
61
+ balance: toUnit(balanceMinutes, type.unit),
62
+ available: toUnit(balanceMinutes - pendingMinutes, type.unit),
63
+ };
64
+ });
65
+ }
66
+ /**
67
+ * Take the lock for one person and leave type, creating the cursor row if it is missing.
68
+ *
69
+ * Everything that *spends* balance must call this first, inside the same transaction. It returns
70
+ * the balance as of the moment the lock was acquired, which is the only balance safe to decide on.
71
+ */
72
+ async lockAndRead(tx, workspaceId, personId, leaveTypeId, periodYear) {
73
+ await tx
74
+ .insert(leaveBalanceCursor)
75
+ .values({ id: uuidv7(), workspaceId, personId, leaveTypeId, periodYear })
76
+ .onConflictDoNothing();
77
+ await tx.execute(sql `
78
+ select 1 from ${leaveBalanceCursor}
79
+ where workspace_id = ${workspaceId}
80
+ and person_id = ${personId}
81
+ and leave_type_id = ${leaveTypeId}
82
+ and period_year = ${periodYear}
83
+ for update
84
+ `);
85
+ const [row] = await tx
86
+ .select({ total: sql `coalesce(sum(${leaveLedger.amountMinutes}), 0)::int` })
87
+ .from(leaveLedger)
88
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.personId, personId), eq(leaveLedger.leaveTypeId, leaveTypeId), eq(leaveLedger.periodYear, periodYear)));
89
+ return Number(row?.total ?? 0);
90
+ }
91
+ /** Append an entry. The only way anything enters the ledger. */
92
+ async append(tx, workspaceId, entry) {
93
+ const [row] = await tx
94
+ .insert(leaveLedger)
95
+ .values({ id: uuidv7(), workspaceId, ...entry })
96
+ .returning();
97
+ await tx
98
+ .update(leaveBalanceCursor)
99
+ .set({
100
+ cachedBalanceMinutes: sql `${leaveBalanceCursor.cachedBalanceMinutes} + ${entry.amountMinutes}`,
101
+ asOfEntryId: row.id,
102
+ version: sql `${leaveBalanceCursor.version} + 1`,
103
+ updatedAt: new Date(),
104
+ })
105
+ .where(and(eq(leaveBalanceCursor.workspaceId, workspaceId), eq(leaveBalanceCursor.personId, entry.personId), eq(leaveBalanceCursor.leaveTypeId, entry.leaveTypeId), eq(leaveBalanceCursor.periodYear, entry.periodYear)));
106
+ return row;
107
+ }
108
+ /**
109
+ * Undo an entry by writing its opposite.
110
+ *
111
+ * Never a delete and never an update. A cancelled request has to leave both the consumption and
112
+ * the reversal visible, because "she booked it and then cancelled" and "she never booked it" are
113
+ * different facts and only one of them is true.
114
+ */
115
+ async reverse(tx, workspaceId, entryId, reason, actorId, on) {
116
+ const [original] = await tx
117
+ .select()
118
+ .from(leaveLedger)
119
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.id, entryId)))
120
+ .limit(1);
121
+ if (!original)
122
+ throw KernError.notFound('Ledger entry');
123
+ const [already] = await tx
124
+ .select({ id: leaveLedger.id })
125
+ .from(leaveLedger)
126
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.reversesEntryId, entryId)))
127
+ .limit(1);
128
+ // Reversing twice would credit the balance twice. The check is here rather than in a constraint
129
+ // because a partial unique index on a nullable column is easy to get subtly wrong, and this
130
+ // path is always inside a transaction that already holds the cursor lock.
131
+ if (already)
132
+ throw KernError.conflict('That entry has already been reversed');
133
+ return this.append(tx, workspaceId, {
134
+ personId: original.personId,
135
+ leaveTypeId: original.leaveTypeId,
136
+ kind: 'reversal',
137
+ amountMinutes: -original.amountMinutes,
138
+ effectiveOn: on,
139
+ periodYear: original.periodYear,
140
+ requestId: original.requestId,
141
+ reversesEntryId: original.id,
142
+ reason,
143
+ createdBy: actorId,
144
+ });
145
+ }
146
+ /** Every entry a request produced, for cancelling it. */
147
+ async entriesFor(tx, workspaceId, requestId) {
148
+ return tx
149
+ .select()
150
+ .from(leaveLedger)
151
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.requestId, requestId)));
152
+ }
153
+ /** Rebuild every cursor from the ledger. Cheap insurance after a bulk import or a bad migration. */
154
+ async rebuildCursors(tx, workspaceId, personIds) {
155
+ if (!personIds.length)
156
+ return;
157
+ await tx.execute(sql `
158
+ update ${leaveBalanceCursor} c
159
+ set cached_balance_minutes = coalesce(s.total, 0),
160
+ version = c.version + 1,
161
+ updated_at = now()
162
+ from (
163
+ select person_id, leave_type_id, period_year, sum(amount_minutes)::int as total
164
+ from ${leaveLedger}
165
+ where workspace_id = ${workspaceId}
166
+ and person_id in (${sql.join(personIds.map((p) => sql `${p}`), sql `, `)})
167
+ group by 1, 2, 3
168
+ ) s
169
+ where c.workspace_id = ${workspaceId}
170
+ and c.person_id = s.person_id
171
+ and c.leave_type_id = s.leave_type_id
172
+ and c.period_year = s.period_year
173
+ `);
174
+ }
175
+ }
176
+ export const yearOf = (isoDate) => Number(isoDate.slice(0, 4));
177
+ export { inArray };
178
+ //# sourceMappingURL=ledger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ledger.js","sourceRoot":"","sources":["../../../src/server/services/ledger.ts"],"names":[],"mappings":"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"}
@@ -22,7 +22,6 @@ export declare class PeopleService {
22
22
  createdAt: string;
23
23
  updatedAt: string;
24
24
  id: string;
25
- workspaceId: string;
26
25
  userId: string | null;
27
26
  employeeNo: string | null;
28
27
  displayName: string;
@@ -33,6 +32,7 @@ export declare class PeopleService {
33
32
  hiredOn: string | null;
34
33
  terminatedOn: string | null;
35
34
  timezone: string | null;
35
+ workspaceId: string;
36
36
  };
37
37
  static toEmployment(row: EmploymentRow): {
38
38
  employmentType: EmploymentType;
@@ -100,10 +100,10 @@ export declare class PeopleService {
100
100
  workspaceId: string;
101
101
  personId: string;
102
102
  effectiveFrom: string;
103
- officeId: string;
104
- isPrimary: boolean;
105
103
  effectiveTo: string | null;
106
104
  reason: string | null;
105
+ officeId: string;
106
+ isPrimary: boolean;
107
107
  };
108
108
  today: () => string;
109
109
  }
@@ -0,0 +1,237 @@
1
+ CREATE TABLE "mod_hr"."approval_chains" (
2
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
3
+ "workspace_id" uuid NOT NULL,
4
+ "name" text NOT NULL,
5
+ "subject_type" text NOT NULL,
6
+ "spec" jsonb NOT NULL,
7
+ "is_default" boolean DEFAULT false NOT NULL,
8
+ "archived_at" timestamp with time zone,
9
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
10
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
11
+ );
12
+ --> statement-breakpoint
13
+ CREATE TABLE "mod_hr"."approval_decisions" (
14
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
15
+ "workspace_id" uuid NOT NULL,
16
+ "step_id" uuid NOT NULL,
17
+ "approver_id" uuid NOT NULL,
18
+ "on_behalf_of_id" uuid,
19
+ "decision" text NOT NULL,
20
+ "comment" text,
21
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
22
+ );
23
+ --> statement-breakpoint
24
+ CREATE TABLE "mod_hr"."approval_requests" (
25
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
26
+ "workspace_id" uuid NOT NULL,
27
+ "subject_type" text NOT NULL,
28
+ "subject_id" uuid NOT NULL,
29
+ "summary" text DEFAULT '' NOT NULL,
30
+ "chain" jsonb NOT NULL,
31
+ "status" text DEFAULT 'pending' NOT NULL,
32
+ "current_step" integer DEFAULT 0 NOT NULL,
33
+ "requested_by" uuid,
34
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
35
+ "decided_at" timestamp with time zone,
36
+ "version" integer DEFAULT 0 NOT NULL
37
+ );
38
+ --> statement-breakpoint
39
+ CREATE TABLE "mod_hr"."approval_steps" (
40
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
41
+ "workspace_id" uuid NOT NULL,
42
+ "request_id" uuid NOT NULL,
43
+ "step_index" integer NOT NULL,
44
+ "name" text DEFAULT '' NOT NULL,
45
+ "mode" text DEFAULT 'any' NOT NULL,
46
+ "min_approvals" integer DEFAULT 1 NOT NULL,
47
+ "approver_ids" uuid[] DEFAULT '{}'::uuid[] NOT NULL,
48
+ "status" text DEFAULT 'pending' NOT NULL,
49
+ "due_at" timestamp with time zone,
50
+ "escalated_at" timestamp with time zone
51
+ );
52
+ --> statement-breakpoint
53
+ CREATE TABLE "mod_hr"."delegations" (
54
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
55
+ "workspace_id" uuid NOT NULL,
56
+ "from_person_id" uuid NOT NULL,
57
+ "to_person_id" uuid NOT NULL,
58
+ "subject_type" text,
59
+ "starts_on" date NOT NULL,
60
+ "ends_on" date NOT NULL,
61
+ "reason" text,
62
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
63
+ );
64
+ --> statement-breakpoint
65
+ CREATE TABLE "mod_hr"."leave_balance_cursor" (
66
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
67
+ "workspace_id" uuid NOT NULL,
68
+ "person_id" uuid NOT NULL,
69
+ "leave_type_id" uuid NOT NULL,
70
+ "period_year" integer NOT NULL,
71
+ "cached_balance_minutes" integer DEFAULT 0 NOT NULL,
72
+ "as_of_entry_id" uuid,
73
+ "version" integer DEFAULT 0 NOT NULL,
74
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
75
+ );
76
+ --> statement-breakpoint
77
+ CREATE TABLE "mod_hr"."leave_ledger" (
78
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
79
+ "workspace_id" uuid NOT NULL,
80
+ "person_id" uuid NOT NULL,
81
+ "leave_type_id" uuid NOT NULL,
82
+ "kind" text NOT NULL,
83
+ "amount_minutes" integer NOT NULL,
84
+ "effective_on" date NOT NULL,
85
+ "period_year" integer NOT NULL,
86
+ "request_id" uuid,
87
+ "reverses_entry_id" uuid,
88
+ "policy_hash" text,
89
+ "reason" text,
90
+ "created_by" uuid,
91
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
92
+ );
93
+ --> statement-breakpoint
94
+ CREATE TABLE "mod_hr"."leave_request_days" (
95
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
96
+ "workspace_id" uuid NOT NULL,
97
+ "request_id" uuid NOT NULL,
98
+ "person_id" uuid NOT NULL,
99
+ "date" date NOT NULL,
100
+ "fraction" numeric(3, 2) DEFAULT '1' NOT NULL,
101
+ "counted" boolean DEFAULT true NOT NULL,
102
+ "status" text DEFAULT 'pending' NOT NULL
103
+ );
104
+ --> statement-breakpoint
105
+ CREATE TABLE "mod_hr"."leave_requests" (
106
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
107
+ "workspace_id" uuid NOT NULL,
108
+ "person_id" uuid NOT NULL,
109
+ "leave_type_id" uuid NOT NULL,
110
+ "starts_on" date NOT NULL,
111
+ "ends_on" date NOT NULL,
112
+ "start_part" text DEFAULT 'full' NOT NULL,
113
+ "end_part" text DEFAULT 'full' NOT NULL,
114
+ "hours" numeric(5, 2),
115
+ "working_days" numeric(6, 2) DEFAULT '0' NOT NULL,
116
+ "minutes" integer DEFAULT 0 NOT NULL,
117
+ "status" text DEFAULT 'pending' NOT NULL,
118
+ "reason" text,
119
+ "document_file_id" uuid,
120
+ "approval_request_id" uuid,
121
+ "idempotency_key" text,
122
+ "decided_at" timestamp with time zone,
123
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
124
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
125
+ );
126
+ --> statement-breakpoint
127
+ CREATE TABLE "mod_hr"."leave_types" (
128
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
129
+ "workspace_id" uuid NOT NULL,
130
+ "key" text NOT NULL,
131
+ "name" text NOT NULL,
132
+ "paid" boolean DEFAULT true NOT NULL,
133
+ "unit" text DEFAULT 'day' NOT NULL,
134
+ "color" text,
135
+ "icon" text,
136
+ "requires_document_after_days" integer,
137
+ "counts_working_days_only" boolean DEFAULT true NOT NULL,
138
+ "allow_negative" boolean DEFAULT false NOT NULL,
139
+ "max_negative_minutes" integer DEFAULT 0 NOT NULL,
140
+ "order" integer DEFAULT 0 NOT NULL,
141
+ "archived_at" timestamp with time zone,
142
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
143
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
144
+ );
145
+ --> statement-breakpoint
146
+ CREATE INDEX "hr_approval_chains_idx" ON "mod_hr"."approval_chains" USING btree ("workspace_id","subject_type","archived_at");--> statement-breakpoint
147
+ CREATE UNIQUE INDEX "hr_approval_decisions_uq" ON "mod_hr"."approval_decisions" USING btree ("step_id","approver_id");--> statement-breakpoint
148
+ CREATE INDEX "hr_approval_requests_subject_idx" ON "mod_hr"."approval_requests" USING btree ("workspace_id","subject_type","subject_id");--> statement-breakpoint
149
+ CREATE INDEX "hr_approval_requests_status_idx" ON "mod_hr"."approval_requests" USING btree ("workspace_id","status");--> statement-breakpoint
150
+ CREATE UNIQUE INDEX "hr_approval_steps_uq" ON "mod_hr"."approval_steps" USING btree ("request_id","step_index");--> statement-breakpoint
151
+ CREATE INDEX "hr_approval_steps_due_idx" ON "mod_hr"."approval_steps" USING btree ("workspace_id","status","due_at");--> statement-breakpoint
152
+ CREATE INDEX "hr_delegations_idx" ON "mod_hr"."delegations" USING btree ("workspace_id","to_person_id","starts_on");--> statement-breakpoint
153
+ CREATE UNIQUE INDEX "hr_balance_cursor_uq" ON "mod_hr"."leave_balance_cursor" USING btree ("workspace_id","person_id","leave_type_id","period_year");--> statement-breakpoint
154
+ CREATE INDEX "hr_ledger_person_idx" ON "mod_hr"."leave_ledger" USING btree ("workspace_id","person_id","leave_type_id","effective_on");--> statement-breakpoint
155
+ CREATE INDEX "hr_ledger_request_idx" ON "mod_hr"."leave_ledger" USING btree ("workspace_id","request_id");--> statement-breakpoint
156
+ CREATE INDEX "hr_ledger_year_idx" ON "mod_hr"."leave_ledger" USING btree ("workspace_id","period_year");--> statement-breakpoint
157
+ CREATE INDEX "hr_leave_days_person_idx" ON "mod_hr"."leave_request_days" USING btree ("workspace_id","person_id","date");--> statement-breakpoint
158
+ CREATE INDEX "hr_leave_days_request_idx" ON "mod_hr"."leave_request_days" USING btree ("request_id");--> statement-breakpoint
159
+ CREATE INDEX "hr_leave_requests_person_idx" ON "mod_hr"."leave_requests" USING btree ("workspace_id","person_id","starts_on");--> statement-breakpoint
160
+ CREATE INDEX "hr_leave_requests_status_idx" ON "mod_hr"."leave_requests" USING btree ("workspace_id","status","starts_on");--> statement-breakpoint
161
+ CREATE UNIQUE INDEX "hr_leave_requests_idem_uq" ON "mod_hr"."leave_requests" USING btree ("workspace_id","idempotency_key");--> statement-breakpoint
162
+ CREATE UNIQUE INDEX "hr_leave_types_ws_key_uq" ON "mod_hr"."leave_types" USING btree ("workspace_id","key");--> statement-breakpoint
163
+ -- ---------------------------------------------------------------------------------------------
164
+ -- Row-level security on the new tenant tables.
165
+ alter table "mod_hr"."leave_types" enable row level security;--> statement-breakpoint
166
+ alter table "mod_hr"."leave_types" force row level security;--> statement-breakpoint
167
+ create policy "leave_types_ws_isolation" on "mod_hr"."leave_types"
168
+ using (workspace_id::text = current_setting('app.workspace_id', true))
169
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
170
+
171
+ alter table "mod_hr"."leave_ledger" enable row level security;--> statement-breakpoint
172
+ alter table "mod_hr"."leave_ledger" force row level security;--> statement-breakpoint
173
+ create policy "leave_ledger_ws_isolation" on "mod_hr"."leave_ledger"
174
+ using (workspace_id::text = current_setting('app.workspace_id', true))
175
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
176
+
177
+ alter table "mod_hr"."leave_balance_cursor" enable row level security;--> statement-breakpoint
178
+ alter table "mod_hr"."leave_balance_cursor" force row level security;--> statement-breakpoint
179
+ create policy "leave_balance_cursor_ws_isolation" on "mod_hr"."leave_balance_cursor"
180
+ using (workspace_id::text = current_setting('app.workspace_id', true))
181
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
182
+
183
+ alter table "mod_hr"."leave_requests" enable row level security;--> statement-breakpoint
184
+ alter table "mod_hr"."leave_requests" force row level security;--> statement-breakpoint
185
+ create policy "leave_requests_ws_isolation" on "mod_hr"."leave_requests"
186
+ using (workspace_id::text = current_setting('app.workspace_id', true))
187
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
188
+
189
+ alter table "mod_hr"."leave_request_days" enable row level security;--> statement-breakpoint
190
+ alter table "mod_hr"."leave_request_days" force row level security;--> statement-breakpoint
191
+ create policy "leave_request_days_ws_isolation" on "mod_hr"."leave_request_days"
192
+ using (workspace_id::text = current_setting('app.workspace_id', true))
193
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
194
+
195
+ alter table "mod_hr"."approval_chains" enable row level security;--> statement-breakpoint
196
+ alter table "mod_hr"."approval_chains" force row level security;--> statement-breakpoint
197
+ create policy "approval_chains_ws_isolation" on "mod_hr"."approval_chains"
198
+ using (workspace_id::text = current_setting('app.workspace_id', true))
199
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
200
+
201
+ alter table "mod_hr"."approval_requests" enable row level security;--> statement-breakpoint
202
+ alter table "mod_hr"."approval_requests" force row level security;--> statement-breakpoint
203
+ create policy "approval_requests_ws_isolation" on "mod_hr"."approval_requests"
204
+ using (workspace_id::text = current_setting('app.workspace_id', true))
205
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
206
+
207
+ alter table "mod_hr"."approval_steps" enable row level security;--> statement-breakpoint
208
+ alter table "mod_hr"."approval_steps" force row level security;--> statement-breakpoint
209
+ create policy "approval_steps_ws_isolation" on "mod_hr"."approval_steps"
210
+ using (workspace_id::text = current_setting('app.workspace_id', true))
211
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
212
+
213
+ alter table "mod_hr"."approval_decisions" enable row level security;--> statement-breakpoint
214
+ alter table "mod_hr"."approval_decisions" force row level security;--> statement-breakpoint
215
+ create policy "approval_decisions_ws_isolation" on "mod_hr"."approval_decisions"
216
+ using (workspace_id::text = current_setting('app.workspace_id', true))
217
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
218
+
219
+ alter table "mod_hr"."delegations" enable row level security;--> statement-breakpoint
220
+ alter table "mod_hr"."delegations" force row level security;--> statement-breakpoint
221
+ create policy "delegations_ws_isolation" on "mod_hr"."delegations"
222
+ using (workspace_id::text = current_setting('app.workspace_id', true))
223
+ with check (workspace_id::text = current_setting('app.workspace_id', true));--> statement-breakpoint
224
+
225
+ -- ---------------------------------------------------------------------------------------------
226
+ -- The invariant that keeps a balance honest under concurrency.
227
+ --
228
+ -- Two overlapping requests can both read the same balance, both see enough for the last day, and
229
+ -- both succeed — leaving somebody minus a day and nobody able to say which request caused it. The
230
+ -- cursor lock serialises the *spend*; this index refuses the *overlap*, so a person cannot hold two
231
+ -- live requests covering one date whatever the application layer believes.
232
+ --
233
+ -- Partial on purpose: a cancelled or rejected request must not block rebooking the same day, and a
234
+ -- weekend inside a range is not counted so it does not conflict with anything either.
235
+ create unique index "hr_leave_days_no_double_booking"
236
+ on "mod_hr"."leave_request_days" (workspace_id, person_id, date)
237
+ where counted and status in ('pending', 'approved');