@kernhq/module-hr 0.2.0 → 0.4.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 (82) 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/attendance.d.ts +240 -0
  6. package/dist/contract/attendance.d.ts.map +1 -0
  7. package/dist/contract/attendance.js +154 -0
  8. package/dist/contract/attendance.js.map +1 -0
  9. package/dist/contract/capabilities.d.ts +1 -1
  10. package/dist/contract/capabilities.d.ts.map +1 -1
  11. package/dist/contract/capabilities.js +80 -0
  12. package/dist/contract/capabilities.js.map +1 -1
  13. package/dist/contract/events.d.ts +60 -0
  14. package/dist/contract/events.d.ts.map +1 -1
  15. package/dist/contract/events.js +60 -0
  16. package/dist/contract/events.js.map +1 -1
  17. package/dist/contract/index.d.ts +3 -0
  18. package/dist/contract/index.d.ts.map +1 -1
  19. package/dist/contract/index.js +3 -0
  20. package/dist/contract/index.js.map +1 -1
  21. package/dist/contract/leave.d.ts +168 -0
  22. package/dist/contract/leave.d.ts.map +1 -0
  23. package/dist/contract/leave.js +150 -0
  24. package/dist/contract/leave.js.map +1 -0
  25. package/dist/contract/permissions.d.ts +100 -0
  26. package/dist/contract/permissions.d.ts.map +1 -1
  27. package/dist/contract/permissions.js +119 -0
  28. package/dist/contract/permissions.js.map +1 -1
  29. package/dist/contract/router.d.ts +2440 -0
  30. package/dist/contract/router.d.ts.map +1 -1
  31. package/dist/contract/router.js +393 -0
  32. package/dist/contract/router.js.map +1 -1
  33. package/dist/policy/time.d.ts +48 -0
  34. package/dist/policy/time.d.ts.map +1 -0
  35. package/dist/policy/time.js +144 -0
  36. package/dist/policy/time.js.map +1 -0
  37. package/dist/policy/working-time.d.ts +98 -0
  38. package/dist/policy/working-time.d.ts.map +1 -0
  39. package/dist/policy/working-time.js +190 -0
  40. package/dist/policy/working-time.js.map +1 -0
  41. package/dist/server/index.d.ts.map +1 -1
  42. package/dist/server/index.js +2 -0
  43. package/dist/server/index.js.map +1 -1
  44. package/dist/server/jobs.d.ts +16 -0
  45. package/dist/server/jobs.d.ts.map +1 -0
  46. package/dist/server/jobs.js +166 -0
  47. package/dist/server/jobs.js.map +1 -0
  48. package/dist/server/router.d.ts +2731 -58
  49. package/dist/server/router.d.ts.map +1 -1
  50. package/dist/server/router.js +1306 -3
  51. package/dist/server/router.js.map +1 -1
  52. package/dist/server/schema.d.ts +3344 -1
  53. package/dist/server/schema.d.ts.map +1 -1
  54. package/dist/server/schema.js +325 -0
  55. package/dist/server/schema.js.map +1 -1
  56. package/dist/server/services/approvals.d.ts +163 -0
  57. package/dist/server/services/approvals.d.ts.map +1 -0
  58. package/dist/server/services/approvals.js +325 -0
  59. package/dist/server/services/approvals.js.map +1 -0
  60. package/dist/server/services/attendance.d.ts +150 -0
  61. package/dist/server/services/attendance.d.ts.map +1 -0
  62. package/dist/server/services/attendance.js +239 -0
  63. package/dist/server/services/attendance.js.map +1 -0
  64. package/dist/server/services/ledger.d.ts +135 -0
  65. package/dist/server/services/ledger.d.ts.map +1 -0
  66. package/dist/server/services/ledger.js +178 -0
  67. package/dist/server/services/ledger.js.map +1 -0
  68. package/dist/server/services/people.d.ts +3 -3
  69. package/migrations/0002_leave.sql +237 -0
  70. package/migrations/0003_attendance.sql +206 -0
  71. package/migrations/meta/0002_snapshot.json +3009 -0
  72. package/migrations/meta/0003_snapshot.json +3681 -0
  73. package/migrations/meta/_journal.json +14 -0
  74. package/package.json +1 -1
  75. package/src/contract/approvals.ts +149 -0
  76. package/src/contract/attendance.ts +180 -0
  77. package/src/contract/capabilities.ts +80 -0
  78. package/src/contract/events.ts +81 -0
  79. package/src/contract/index.ts +3 -0
  80. package/src/contract/leave.ts +171 -0
  81. package/src/contract/permissions.ts +122 -0
  82. package/src/contract/router.ts +481 -0
@@ -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');
@@ -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));