@flowselections/admin-installatietechniek 1.0.8 → 1.0.9

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.
@@ -1,9 +1,8 @@
1
1
  import { createServerFn } from "@tanstack/react-start";
2
- import { getRequestHost } from "@tanstack/react-start/server";
3
2
  import { z } from "zod";
4
3
  import { requireSupabaseAuth } from "../integrations/supabase/auth-middleware";
5
4
  import { normalizeServerErrors } from "../lib/serverErrors";
6
- import { requireAdmin, requireUserAdmin } from "../lib/admin.server";
5
+ import { isFullAdminRoleKey, requireAdmin, requireUserAdmin, resolveAppBaseUrl, verifyAddressWithGoogle } from "../lib/admin.server";
7
6
  const featureSchema = z.object({
8
7
  id: z.string().trim().min(1).max(200),
9
8
  name: z.string().trim().min(1).max(200),
@@ -32,7 +31,6 @@ const roleIdInput = z.object({ roleId: z.string().uuid() });
32
31
  const assignRoleInput = z.object({
33
32
  userId: z.string().uuid(),
34
33
  roleId: z.string().uuid(),
35
- profileId: z.string().uuid().optional(),
36
34
  });
37
35
  const rolePermissionsSchema = z
38
36
  .array(z.object({
@@ -51,11 +49,33 @@ const updateRoleInput = z.object({
51
49
  name: z.string().trim().min(1).max(80),
52
50
  permissions: rolePermissionsSchema.default([]),
53
51
  });
52
+ const CONTACT_REQUIRED_ROLE_KEYS = ["monteur", "planner", "administratie"];
53
+ const phoneRegex = /^[0-9+()\-\s]{8,25}$/;
54
+ const optionalAddress = z.string().trim().max(200).optional().default("");
55
+ const optionalPhone = z
56
+ .string()
57
+ .trim()
58
+ .max(25)
59
+ .optional()
60
+ .default("")
61
+ .refine((v) => v === "" || phoneRegex.test(v), { message: "Ongeldig telefoonnummer" });
62
+ function assertContactForRole(roleKey, address, _phone) {
63
+ if (!roleKey || !CONTACT_REQUIRED_ROLE_KEYS.includes(roleKey))
64
+ return;
65
+ if (address.trim().length < 5)
66
+ throw new Error("Adres is verplicht voor deze rol (minimaal 5 tekens).");
67
+ }
54
68
  const inviteUserInput = z.object({
55
69
  email: z.string().trim().email().max(255),
56
70
  displayName: z.string().trim().min(1).max(100),
57
71
  roleId: z.string().uuid(),
58
- profileId: z.string().uuid().optional(),
72
+ address: optionalAddress,
73
+ phone: optionalPhone,
74
+ });
75
+ const updateUserContactInput = z.object({
76
+ userId: z.string().uuid(),
77
+ address: optionalAddress,
78
+ phone: optionalPhone,
59
79
  });
60
80
  const deleteUserInput = z.object({
61
81
  userId: z.string().uuid(),
@@ -68,7 +88,6 @@ const createTestUserInput = z.object({
68
88
  displayName: z.string().trim().min(1).max(100),
69
89
  roleId: z.string().uuid(),
70
90
  email: z.string().trim().email().max(255).optional(),
71
- profileId: z.string().uuid().optional(),
72
91
  });
73
92
  function slugify(value) {
74
93
  return (value
@@ -93,95 +112,30 @@ async function fetchRole(supabase, roleId) {
93
112
  }
94
113
  async function assertMayUseRole(supabase, roleId, isAdmin) {
95
114
  const role = await fetchRole(supabase, roleId);
96
- if (!isAdmin && role.key === "admin") {
115
+ if (!isAdmin && isFullAdminRoleKey(role.key)) {
97
116
  throw new Error("Alleen een admin kan de adminrol gebruiken.");
98
117
  }
99
118
  return role;
100
119
  }
101
- const profileScopeInput = z
102
- .object({ profileId: z.string().uuid().optional() })
103
- .optional();
104
120
  /**
105
- * Tenant + rol-/module-scope van een bedrijfsprofiel.
106
- * Zonder `profileId` is er geen scope (= overzicht: alles zichtbaar).
121
+ * Eén database = één organisatie. Bestaat er precies één tenant, dan is dat
122
+ * de tenant voor nieuwe gebruikers; anders wordt er niets gezet.
107
123
  */
108
- async function loadProfileScope(supabase, profileId) {
109
- if (!profileId)
110
- return null;
111
- const { data, error } = await supabase
112
- .from("profiles_org")
113
- .select("id, name, tenant_id, is_default")
114
- .eq("id", profileId)
115
- .maybeSingle();
124
+ async function resolveSingleTenantId(admin) {
125
+ const { data, error } = await admin.from("tenants").select("id").limit(2);
116
126
  if (error)
117
127
  throw error;
118
- if (!data)
119
- return null;
120
- const [{ data: roleRows }, { data: moduleRows }] = await Promise.all([
121
- supabase.from("profile_roles").select("role_id").eq("profile_id", data.id),
122
- supabase.from("profile_modules").select("module_id").eq("profile_id", data.id),
123
- ]);
124
- return {
125
- id: data.id,
126
- name: data.name,
127
- tenantId: (data.tenant_id ?? null),
128
- roleIds: (Array.isArray(roleRows) ? roleRows : []).map((r) => r.role_id),
129
- moduleIds: (Array.isArray(moduleRows) ? moduleRows : []).map((m) => m.module_id),
130
- };
131
- }
132
- /**
133
- * Bepaalt op welk bedrijfsprofiel deze aanvraag draait.
134
- * - Admin: vrije keuze (leeg = overzicht met alles).
135
- * - Niet-admin (administratie): altijd vastgezet op het eigen profiel,
136
- * afgeleid uit de tenant van de gebruiker of anders uit de profielen
137
- * waaraan een van zijn rollen gekoppeld is.
138
- */
139
- async function resolveEffectiveProfileId(admin, userId, requestedProfileId, isAdmin) {
140
- if (isAdmin)
141
- return { profileId: requestedProfileId, locked: false };
142
- const { data: me } = await admin
143
- .from("profiles")
144
- .select("tenant_id")
145
- .eq("id", userId)
146
- .maybeSingle();
147
- if (me?.tenant_id) {
148
- const { data: byTenant } = await admin
149
- .from("profiles_org")
150
- .select("id")
151
- .eq("tenant_id", me.tenant_id)
152
- .limit(1);
153
- const first = Array.isArray(byTenant) ? byTenant[0] : null;
154
- if (first?.id)
155
- return { profileId: first.id, locked: true };
156
- }
157
- const { data: myRoles } = await admin
158
- .from("role_assignments")
159
- .select("role_id")
160
- .eq("user_id", userId);
161
- const roleIds = (Array.isArray(myRoles) ? myRoles : []).map((r) => r.role_id);
162
- if (roleIds.length > 0) {
163
- const { data: links } = await admin
164
- .from("profile_roles")
165
- .select("profile_id")
166
- .in("role_id", roleIds)
167
- .limit(1);
168
- const link = Array.isArray(links) ? links[0] : null;
169
- if (link?.profile_id)
170
- return { profileId: link.profile_id, locked: true };
171
- }
172
- return { profileId: undefined, locked: true };
128
+ const rows = Array.isArray(data) ? data : [];
129
+ return rows.length === 1 ? rows[0].id : null;
173
130
  }
174
131
  export const getUsersWithRoles = createServerFn({ method: "POST" })
175
132
  .middleware([normalizeServerErrors, requireSupabaseAuth])
176
- .inputValidator((data) => profileScopeInput.parse(data))
177
- .handler(async ({ context, data }) => {
133
+ .handler(async ({ context }) => {
178
134
  const { isAdmin } = await requireUserAdmin(context);
179
135
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
180
- const effective = await resolveEffectiveProfileId(supabaseAdmin, context.userId, data?.profileId, isAdmin);
181
- const scope = await loadProfileScope(supabaseAdmin, effective.profileId);
182
136
  const profilesQuery = supabaseAdmin
183
137
  .from("profiles")
184
- .select("id, email, display_name, tenant_id")
138
+ .select("id, email, display_name, tenant_id, address, phone")
185
139
  .order("email", { ascending: true });
186
140
  const [{ data: profiles, error: profilesError }, { data: assignments, error: assignmentsError }, { data: roles, error: rolesError }, { data: authList, error: authError },] = await Promise.all([
187
141
  profilesQuery,
@@ -200,15 +154,15 @@ export const getUsersWithRoles = createServerFn({ method: "POST" })
200
154
  const safeProfiles = Array.isArray(profiles) ? profiles : [];
201
155
  const safeAssignments = Array.isArray(assignments) ? assignments : [];
202
156
  const allRoles = Array.isArray(roles) ? roles : [];
203
- const safeRoles = scope && scope.roleIds.length > 0
204
- ? allRoles.filter((r) => scope.roleIds.includes(r.id))
205
- : allRoles;
206
157
  const safeAuthUsers = Array.isArray(authList?.users) ? authList.users : [];
207
158
  const roleById = new Map(allRoles.map((r) => [r.id, r]));
208
159
  const confirmedById = new Map();
209
160
  const isTestById = new Map();
161
+ const authEmailById = new Map();
210
162
  for (const u of safeAuthUsers) {
211
163
  confirmedById.set(u.id, Boolean(u.email_confirmed_at));
164
+ if (u.email)
165
+ authEmailById.set(u.id, u.email);
212
166
  const meta = (u.user_metadata ?? {});
213
167
  isTestById.set(u.id, Boolean(meta.is_test_account));
214
168
  }
@@ -223,52 +177,21 @@ export const getUsersWithRoles = createServerFn({ method: "POST" })
223
177
  }
224
178
  rolesByUser.set(a.user_id, list);
225
179
  }
226
- // Binnen een profiel horen gebruikers erbij via de tenant OF via een rol
227
- // die aan het profiel gekoppeld is. Zonder profiel: iedereen.
228
- const scopedRoleIds = new Set(scope?.roleIds ?? []);
229
- const usersViaRole = new Set();
230
- if (scope && scopedRoleIds.size > 0) {
231
- for (const a of safeAssignments) {
232
- if (scopedRoleIds.has(a.role_id))
233
- usersViaRole.add(a.user_id);
234
- }
235
- }
236
- // Vastgezette gebruiker zonder gekoppeld profiel: niets tonen.
237
- const noProfileLock = effective.locked && !scope;
238
- const scopedProfiles = noProfileLock
239
- ? []
240
- : !scope
241
- ? safeProfiles
242
- : safeProfiles.filter((p) => {
243
- const viaTenant = Boolean(scope.tenantId) && p.tenant_id === scope.tenantId;
244
- return viaTenant || usersViaRole.has(p.id);
245
- });
246
180
  return {
247
- users: scopedProfiles.map((profile) => {
248
- const viaTenant = Boolean(scope?.tenantId) && profile.tenant_id === scope?.tenantId;
249
- const viaRole = usersViaRole.has(profile.id);
250
- return {
251
- id: profile.id,
252
- email: profile.email ?? "-",
253
- displayName: profile.display_name ?? "-",
254
- roles: rolesByUser.get(profile.id) ?? [],
255
- emailConfirmed: confirmedById.get(profile.id) ?? false,
256
- isTestAccount: isTestById.get(profile.id) ?? false,
257
- matchedVia: !scope
258
- ? null
259
- : viaTenant && viaRole
260
- ? "both"
261
- : viaTenant
262
- ? "tenant"
263
- : "role",
264
- };
265
- }),
266
- availableRoles: (noProfileLock ? [] : safeRoles)
267
- .filter((r) => isAdmin || r.key !== "admin")
181
+ users: safeProfiles.map((profile) => ({
182
+ id: profile.id,
183
+ email: profile.email ?? authEmailById.get(profile.id) ?? "-",
184
+ displayName: profile.display_name ?? "-",
185
+ address: typeof profile.address === "string" ? profile.address : "",
186
+ phone: typeof profile.phone === "string" ? profile.phone : "",
187
+ roles: rolesByUser.get(profile.id) ?? [],
188
+ emailConfirmed: confirmedById.get(profile.id) ?? false,
189
+ isTestAccount: isTestById.get(profile.id) ?? false,
190
+ })),
191
+ availableRoles: allRoles
192
+ .filter((r) => isAdmin || !isFullAdminRoleKey(r.key))
268
193
  .map((r) => ({ id: r.id, key: r.key, name: r.name, isProtected: r.is_protected })),
269
194
  isAdmin,
270
- profileLocked: effective.locked,
271
- activeProfile: scope ? { id: scope.id, name: scope.name, tenantId: scope.tenantId } : null,
272
195
  };
273
196
  });
274
197
  export const assignRole = createServerFn({ method: "POST" })
@@ -292,18 +215,16 @@ export const assignRole = createServerFn({ method: "POST" })
292
215
  .insert({ user_id: data.userId, role_id: data.roleId });
293
216
  if (error)
294
217
  throw error;
295
- // Binnen een actief bedrijfsprofiel: koppel de gebruiker aan de tenant van
296
- // dat profiel als die nog leeg is, zodat de koppeling vanzelf dichtgroeit.
297
- if (data.profileId) {
298
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
299
- const scope = await loadProfileScope(supabaseAdmin, data.profileId);
300
- if (scope?.tenantId) {
301
- await supabaseAdmin
302
- .from("profiles")
303
- .update({ tenant_id: scope.tenantId })
304
- .eq("id", data.userId)
305
- .is("tenant_id", null);
306
- }
218
+ // Eén organisatie per database: bestaat er precies één tenant, koppel de
219
+ // gebruiker daaraan als zijn tenant_id nog leeg is.
220
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
221
+ const tenantId = await resolveSingleTenantId(supabaseAdmin);
222
+ if (tenantId) {
223
+ await supabaseAdmin
224
+ .from("profiles")
225
+ .update({ tenant_id: tenantId })
226
+ .eq("id", data.userId)
227
+ .is("tenant_id", null);
307
228
  }
308
229
  return { ok: true };
309
230
  });
@@ -328,12 +249,8 @@ export const removeRole = createServerFn({ method: "POST" })
328
249
  /** Alle rollen met hun rechten, plus de geregistreerde modules en onderdelen. */
329
250
  export const getRoles = createServerFn({ method: "POST" })
330
251
  .middleware([normalizeServerErrors, requireSupabaseAuth])
331
- .inputValidator((data) => profileScopeInput.parse(data))
332
- .handler(async ({ context, data }) => {
333
- const { isAdmin } = await requireUserAdmin(context);
334
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
335
- const effective = await resolveEffectiveProfileId(supabaseAdmin, context.userId, data?.profileId, isAdmin);
336
- const scope = await loadProfileScope(supabaseAdmin, effective.profileId);
252
+ .handler(async ({ context }) => {
253
+ await requireUserAdmin(context);
337
254
  const [{ data: roles, error: rolesError }, { data: perms, error: permsError }, { data: registry, error: registryError }, { data: featureRows, error: featuresError },] = await Promise.all([
338
255
  context.supabase.from("roles").select("id, key, name, is_protected").order("name", { ascending: true }),
339
256
  context.supabase.from("role_permissions").select("role_id, module_id, feature_id, can_view, can_edit"),
@@ -348,23 +265,21 @@ export const getRoles = createServerFn({ method: "POST" })
348
265
  throw registryError;
349
266
  if (featuresError)
350
267
  throw featuresError;
351
- const noProfileLock = effective.locked && !scope;
352
- const allRoles = noProfileLock ? [] : Array.isArray(roles) ? roles : [];
353
- const safeRoles = scope && scope.roleIds.length > 0
354
- ? allRoles.filter((r) => scope.roleIds.includes(r.id))
355
- : allRoles;
268
+ const safeRoles = Array.isArray(roles) ? roles : [];
356
269
  const safePerms = Array.isArray(perms) ? perms : [];
357
- const allRegistry = noProfileLock ? [] : Array.isArray(registry) ? registry : [];
358
- const safeRegistry = scope && scope.moduleIds.length > 0
359
- ? allRegistry.filter((m) => scope.moduleIds.includes(m.module_id))
360
- : allRegistry;
270
+ const safeRegistry = Array.isArray(registry) ? registry : [];
361
271
  const safeFeatures = Array.isArray(featureRows) ? featureRows : [];
272
+ // Rollen waarvan de sleutel niet in de app_role-lijst voorkomt: die worden
273
+ // door de sync-trigger stil overgeslagen en werken nergens.
274
+ const { data: unmappedRows } = await context.supabase.rpc("roles_without_enum_mapping");
275
+ const unmappedKeys = new Set((Array.isArray(unmappedRows) ? unmappedRows : []).map((r) => r?.key));
362
276
  return {
363
277
  roles: safeRoles.map((r) => ({
364
278
  id: r.id,
365
279
  key: r.key,
366
280
  name: r.name,
367
281
  isProtected: r.is_protected,
282
+ unmapped: unmappedKeys.has(r.key),
368
283
  })),
369
284
  modules: safeRegistry
370
285
  .filter((m) => m.is_active)
@@ -383,8 +298,6 @@ export const getRoles = createServerFn({ method: "POST" })
383
298
  canView: Boolean(p.can_view),
384
299
  canEdit: Boolean(p.can_edit),
385
300
  })),
386
- profileLocked: effective.locked,
387
- activeProfile: scope ? { id: scope.id, name: scope.name, tenantId: scope.tenantId } : null,
388
301
  };
389
302
  });
390
303
  async function writeRolePermissions(supabase, roleId, permissions) {
@@ -663,30 +576,31 @@ export const inviteUser = createServerFn({ method: "POST" })
663
576
  .handler(async ({ context, data }) => {
664
577
  const { isAdmin } = await requireUserAdmin(context);
665
578
  await assertMayUseRole(context.supabase, data.roleId, isAdmin);
579
+ const { data: roleRow, error: roleLookupError } = await context.supabase
580
+ .from("roles")
581
+ .select("key")
582
+ .eq("id", data.roleId)
583
+ .maybeSingle();
584
+ if (roleLookupError)
585
+ throw roleLookupError;
586
+ assertContactForRole(roleRow?.key ?? null, data.address, data.phone);
587
+ const verifiedAddress = await verifyAddressWithGoogle(data.address ?? "");
666
588
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
667
- let redirectTo;
668
- try {
669
- const host = getRequestHost();
670
- if (host) {
671
- const proto = host.startsWith("localhost") ? "http" : "https";
672
- redirectTo = `${proto}://${host}/reset-wachtwoord`;
673
- }
674
- }
675
- catch {
676
- redirectTo = undefined;
677
- }
589
+ const redirectTo = `${resolveAppBaseUrl()}/reset-wachtwoord`;
678
590
  const { data: invited, error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(data.email, { data: { display_name: data.displayName }, redirectTo });
679
591
  if (inviteError || !invited?.user) {
680
592
  const msg = inviteError?.message ?? "Uitnodigen mislukt";
681
593
  throw new Error(/already|exists|registered/i.test(msg) ? "Deze gebruiker bestaat al." : msg);
682
594
  }
683
595
  const userId = invited.user.id;
684
- const scope = await loadProfileScope(supabaseAdmin, data.profileId);
596
+ const tenantId = await resolveSingleTenantId(supabaseAdmin);
685
597
  const { error: profileError } = await supabaseAdmin.from("profiles").upsert({
686
598
  id: userId,
687
599
  email: data.email,
688
600
  display_name: data.displayName,
689
- ...(scope?.tenantId ? { tenant_id: scope.tenantId } : {}),
601
+ address: verifiedAddress ? verifiedAddress : null,
602
+ phone: data.phone ? data.phone.trim() : null,
603
+ ...(tenantId ? { tenant_id: tenantId } : {}),
690
604
  }, { onConflict: "id" });
691
605
  if (profileError)
692
606
  throw profileError;
@@ -713,7 +627,7 @@ export const deleteUser = createServerFn({ method: "POST" })
713
627
  if (targetError)
714
628
  throw targetError;
715
629
  const safeTargetRoles = Array.isArray(targetRoles) ? targetRoles : [];
716
- if (safeTargetRoles.some((r) => r?.roles?.key === "admin")) {
630
+ if (safeTargetRoles.some((r) => isFullAdminRoleKey(r?.roles?.key))) {
717
631
  throw new Error("Alleen een admin kan een adminaccount verwijderen.");
718
632
  }
719
633
  }
@@ -736,7 +650,7 @@ export const updateUserName = createServerFn({ method: "POST" })
736
650
  if (targetError)
737
651
  throw targetError;
738
652
  const safeTargetRoles = Array.isArray(targetRoles) ? targetRoles : [];
739
- if (safeTargetRoles.some((r) => r?.roles?.key === "admin")) {
653
+ if (safeTargetRoles.some((r) => isFullAdminRoleKey(r?.roles?.key))) {
740
654
  throw new Error("Alleen een admin kan een adminaccount aanpassen.");
741
655
  }
742
656
  }
@@ -754,6 +668,36 @@ export const updateUserName = createServerFn({ method: "POST" })
754
668
  throw error;
755
669
  return { ok: true };
756
670
  });
671
+ export const updateUserContact = createServerFn({ method: "POST" })
672
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
673
+ .inputValidator((data) => updateUserContactInput.parse(data))
674
+ .handler(async ({ context, data }) => {
675
+ await requireUserAdmin(context);
676
+ const { data: targetRoles, error: targetError } = await context.supabase
677
+ .from("role_assignments")
678
+ .select("roles(key)")
679
+ .eq("user_id", data.userId);
680
+ if (targetError)
681
+ throw targetError;
682
+ const safeTargetRoles = Array.isArray(targetRoles) ? targetRoles : [];
683
+ const keys = safeTargetRoles
684
+ .map((r) => r?.roles?.key)
685
+ .filter((k) => typeof k === "string");
686
+ const requiredKey = keys.find((k) => CONTACT_REQUIRED_ROLE_KEYS.includes(k)) ?? null;
687
+ assertContactForRole(requiredKey, data.address, data.phone);
688
+ const verifiedAddress = await verifyAddressWithGoogle(data.address ?? "");
689
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
690
+ const { error } = await supabaseAdmin
691
+ .from("profiles")
692
+ .update({
693
+ address: verifiedAddress ? verifiedAddress : null,
694
+ phone: data.phone ? data.phone.trim() : null,
695
+ })
696
+ .eq("id", data.userId);
697
+ if (error)
698
+ throw error;
699
+ return { ok: true };
700
+ });
757
701
  function generatePassword(length = 16) {
758
702
  const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%*";
759
703
  const bytes = new Uint8Array(length);
@@ -783,12 +727,12 @@ export const createTestUser = createServerFn({ method: "POST" })
783
727
  throw new Error(/already|exists|registered/i.test(msg) ? "Deze gebruiker bestaat al." : msg);
784
728
  }
785
729
  const userId = created.user.id;
786
- const scope = await loadProfileScope(supabaseAdmin, data.profileId);
730
+ const tenantId = await resolveSingleTenantId(supabaseAdmin);
787
731
  const { error: profileError } = await supabaseAdmin.from("profiles").upsert({
788
732
  id: userId,
789
733
  email,
790
734
  display_name: data.displayName,
791
- ...(scope?.tenantId ? { tenant_id: scope.tenantId } : {}),
735
+ ...(tenantId ? { tenant_id: tenantId } : {}),
792
736
  }, { onConflict: "id" });
793
737
  if (profileError)
794
738
  throw profileError;
@@ -799,261 +743,3 @@ export const createTestUser = createServerFn({ method: "POST" })
799
743
  throw roleError;
800
744
  return { ok: true, userId, email, password };
801
745
  });
802
- // ============================================================================
803
- // Bedrijfsprofielen: bundelen rollen + modules per organisatie (tenant)
804
- // ============================================================================
805
- const profileIdInput = z.object({ profileId: z.string().uuid() });
806
- const profileBodySchema = {
807
- name: z.string().trim().min(1).max(120),
808
- tenantId: z.string().uuid().nullable().optional(),
809
- /** Naam van het bedrijf; maakt een nieuw bedrijf aan of hernoemt het gekozen bedrijf. */
810
- tenantName: z.string().trim().max(120).optional(),
811
- roleIds: z.array(z.string().uuid()).max(200).default([]),
812
- moduleIds: z.array(z.string().trim().min(1).max(100)).max(200).default([]),
813
- };
814
- const createProfileInput = z.object(profileBodySchema);
815
- const updateProfileInput = z.object({ profileId: z.string().uuid(), ...profileBodySchema });
816
- /**
817
- * Bepaalt het tenant-id voor een profiel:
818
- * - bestaand bedrijf + gewijzigde naam -> hernoemen
819
- * - geen bedrijf + naam -> nieuw bedrijf aanmaken
820
- */
821
- async function resolveTenant(supabase, tenantId, tenantName) {
822
- const name = (tenantName ?? "").trim();
823
- if (tenantId) {
824
- if (name) {
825
- const { data: current, error: readError } = await supabase
826
- .from("tenants")
827
- .select("name")
828
- .eq("id", tenantId)
829
- .maybeSingle();
830
- if (readError)
831
- throw readError;
832
- if (current && current.name !== name) {
833
- const { error } = await supabase
834
- .from("tenants")
835
- .update({ name, updated_at: new Date().toISOString() })
836
- .eq("id", tenantId);
837
- if (error)
838
- throw error;
839
- }
840
- }
841
- return tenantId;
842
- }
843
- if (!name)
844
- return null;
845
- const { data: created, error } = await supabase
846
- .from("tenants")
847
- .insert({ name })
848
- .select("id")
849
- .single();
850
- if (error)
851
- throw error;
852
- return created.id;
853
- }
854
- async function writeProfileLinks(supabase, profileId, roleIds, moduleIds) {
855
- const { error: delRoles } = await supabase.from("profile_roles").delete().eq("profile_id", profileId);
856
- if (delRoles)
857
- throw delRoles;
858
- const { error: delModules } = await supabase.from("profile_modules").delete().eq("profile_id", profileId);
859
- if (delModules)
860
- throw delModules;
861
- const uniqueRoles = Array.from(new Set(roleIds));
862
- if (uniqueRoles.length > 0) {
863
- const { error } = await supabase
864
- .from("profile_roles")
865
- .insert(uniqueRoles.map((roleId) => ({ profile_id: profileId, role_id: roleId })));
866
- if (error)
867
- throw error;
868
- }
869
- const uniqueModules = Array.from(new Set(moduleIds));
870
- if (uniqueModules.length > 0) {
871
- const { error } = await supabase
872
- .from("profile_modules")
873
- .insert(uniqueModules.map((moduleId) => ({ profile_id: profileId, module_id: moduleId })));
874
- if (error)
875
- throw error;
876
- }
877
- }
878
- /** Alle bedrijfsprofielen met hun rollen, modules en tellingen. */
879
- export const getProfiles = createServerFn({ method: "GET" })
880
- .middleware([normalizeServerErrors, requireSupabaseAuth])
881
- .handler(async ({ context }) => {
882
- const { isAdmin } = await requireUserAdmin(context);
883
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
884
- const effective = await resolveEffectiveProfileId(supabaseAdmin, context.userId, undefined, isAdmin);
885
- const [{ data: profilesOrg, error: profilesError }, { data: profileRoles, error: profileRolesError }, { data: profileModules, error: profileModulesError }, { data: tenants, error: tenantsError },] = await Promise.all([
886
- context.supabase
887
- .from("profiles_org")
888
- .select("id, name, tenant_id, is_default")
889
- .order("name", { ascending: true }),
890
- context.supabase.from("profile_roles").select("profile_id, role_id"),
891
- context.supabase.from("profile_modules").select("profile_id, module_id"),
892
- // `tenants` is via RLS beperkt tot de eigen tenant; namen tonen mag hier
893
- // omdat de aanvrager al is geverifieerd als admin/administratie.
894
- supabaseAdmin.from("tenants").select("id, name").order("name", { ascending: true }),
895
- ]);
896
- if (profilesError)
897
- throw profilesError;
898
- if (profileRolesError)
899
- throw profileRolesError;
900
- if (profileModulesError)
901
- throw profileModulesError;
902
- if (tenantsError)
903
- throw tenantsError;
904
- const { data: userRows, error: userError } = await supabaseAdmin
905
- .from("profiles")
906
- .select("id, tenant_id");
907
- if (userError)
908
- throw userError;
909
- const allProfilesOrg = Array.isArray(profilesOrg) ? profilesOrg : [];
910
- // Niet-admin: alleen het eigen (vastgezette) profiel.
911
- const safeProfiles = effective.locked
912
- ? allProfilesOrg.filter((p) => p.id === effective.profileId)
913
- : allProfilesOrg;
914
- const safeProfileRoles = Array.isArray(profileRoles) ? profileRoles : [];
915
- const safeProfileModules = Array.isArray(profileModules) ? profileModules : [];
916
- const safeTenants = Array.isArray(tenants) ? tenants : [];
917
- const safeUsers = Array.isArray(userRows) ? userRows : [];
918
- const usersByTenant = new Map();
919
- for (const u of safeUsers) {
920
- if (!u.tenant_id)
921
- continue;
922
- usersByTenant.set(u.tenant_id, (usersByTenant.get(u.tenant_id) ?? 0) + 1);
923
- }
924
- const tenantName = new Map(safeTenants.map((t) => [t.id, t.name]));
925
- return {
926
- profiles: safeProfiles.map((p) => ({
927
- id: p.id,
928
- name: p.name,
929
- tenantId: (p.tenant_id ?? null),
930
- tenantName: p.tenant_id ? tenantName.get(p.tenant_id) ?? null : null,
931
- isDefault: Boolean(p.is_default),
932
- roleIds: safeProfileRoles.filter((r) => r.profile_id === p.id).map((r) => r.role_id),
933
- moduleIds: safeProfileModules
934
- .filter((m) => m.profile_id === p.id)
935
- .map((m) => m.module_id),
936
- userCount: p.tenant_id ? usersByTenant.get(p.tenant_id) ?? 0 : 0,
937
- })),
938
- tenants: safeTenants.map((t) => ({ id: t.id, name: t.name })),
939
- profileLocked: effective.locked,
940
- lockedProfileId: effective.locked ? effective.profileId ?? null : null,
941
- };
942
- });
943
- export const createProfile = createServerFn({ method: "POST" })
944
- .middleware([normalizeServerErrors, requireSupabaseAuth])
945
- .inputValidator((data) => createProfileInput.parse(data))
946
- .handler(async ({ context, data }) => {
947
- await requireAdmin(context);
948
- // `tenants` heeft geen insert-policy voor admins; na de adminguard mag dit
949
- // veilig via de serverclient.
950
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
951
- const tenantId = await resolveTenant(supabaseAdmin, data.tenantId, data.tenantName);
952
- const { data: created, error } = await context.supabase
953
- .from("profiles_org")
954
- .insert({ name: data.name, tenant_id: tenantId })
955
- .select("id")
956
- .single();
957
- if (error)
958
- throw error;
959
- await writeProfileLinks(context.supabase, created.id, data.roleIds, data.moduleIds);
960
- return { ok: true, profileId: created.id };
961
- });
962
- export const updateProfile = createServerFn({ method: "POST" })
963
- .middleware([normalizeServerErrors, requireSupabaseAuth])
964
- .inputValidator((data) => updateProfileInput.parse(data))
965
- .handler(async ({ context, data }) => {
966
- await requireAdmin(context);
967
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
968
- const tenantId = await resolveTenant(supabaseAdmin, data.tenantId, data.tenantName);
969
- const { error } = await context.supabase
970
- .from("profiles_org")
971
- .update({ name: data.name, tenant_id: tenantId, updated_at: new Date().toISOString() })
972
- .eq("id", data.profileId);
973
- if (error)
974
- throw error;
975
- await writeProfileLinks(context.supabase, data.profileId, data.roleIds, data.moduleIds);
976
- return { ok: true };
977
- });
978
- export const duplicateProfile = createServerFn({ method: "POST" })
979
- .middleware([normalizeServerErrors, requireSupabaseAuth])
980
- .inputValidator((data) => profileIdInput.parse(data))
981
- .handler(async ({ context, data }) => {
982
- await requireAdmin(context);
983
- const { data: source, error: sourceError } = await context.supabase
984
- .from("profiles_org")
985
- .select("name, tenant_id")
986
- .eq("id", data.profileId)
987
- .maybeSingle();
988
- if (sourceError)
989
- throw sourceError;
990
- if (!source)
991
- throw new Error("Profiel bestaat niet.");
992
- const [{ data: roleRows }, { data: moduleRows }] = await Promise.all([
993
- context.supabase.from("profile_roles").select("role_id").eq("profile_id", data.profileId),
994
- context.supabase.from("profile_modules").select("module_id").eq("profile_id", data.profileId),
995
- ]);
996
- const { data: created, error } = await context.supabase
997
- .from("profiles_org")
998
- .insert({ name: `${source.name} (kopie)`, tenant_id: source.tenant_id })
999
- .select("id")
1000
- .single();
1001
- if (error)
1002
- throw error;
1003
- await writeProfileLinks(context.supabase, created.id, (Array.isArray(roleRows) ? roleRows : []).map((r) => r.role_id), (Array.isArray(moduleRows) ? moduleRows : []).map((m) => m.module_id));
1004
- return { ok: true, profileId: created.id };
1005
- });
1006
- export const deleteProfile = createServerFn({ method: "POST" })
1007
- .middleware([normalizeServerErrors, requireSupabaseAuth])
1008
- .inputValidator((data) => profileIdInput.parse(data))
1009
- .handler(async ({ context, data }) => {
1010
- await requireAdmin(context);
1011
- const { data: profile, error: profileError } = await context.supabase
1012
- .from("profiles_org")
1013
- .select("id, tenant_id, is_default")
1014
- .eq("id", data.profileId)
1015
- .maybeSingle();
1016
- if (profileError)
1017
- throw profileError;
1018
- if (!profile)
1019
- throw new Error("Profiel bestaat niet.");
1020
- if (profile.is_default)
1021
- throw new Error("Het actieve profiel kan niet verwijderd worden.");
1022
- if (profile.tenant_id) {
1023
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
1024
- const { count, error: countError } = await supabaseAdmin
1025
- .from("profiles")
1026
- .select("id", { count: "exact", head: true })
1027
- .eq("tenant_id", profile.tenant_id);
1028
- if (countError)
1029
- throw countError;
1030
- if ((count ?? 0) > 0) {
1031
- throw new Error("Er zijn nog gebruikers aan dit bedrijf gekoppeld.");
1032
- }
1033
- }
1034
- const { error } = await context.supabase.from("profiles_org").delete().eq("id", data.profileId);
1035
- if (error)
1036
- throw error;
1037
- return { ok: true };
1038
- });
1039
- /** Zet precies één profiel als actief (is_default). */
1040
- export const setActiveProfile = createServerFn({ method: "POST" })
1041
- .middleware([normalizeServerErrors, requireSupabaseAuth])
1042
- .inputValidator((data) => profileIdInput.parse(data))
1043
- .handler(async ({ context, data }) => {
1044
- await requireAdmin(context);
1045
- const { error: clearError } = await context.supabase
1046
- .from("profiles_org")
1047
- .update({ is_default: false })
1048
- .eq("is_default", true)
1049
- .neq("id", data.profileId);
1050
- if (clearError)
1051
- throw clearError;
1052
- const { error } = await context.supabase
1053
- .from("profiles_org")
1054
- .update({ is_default: true, updated_at: new Date().toISOString() })
1055
- .eq("id", data.profileId);
1056
- if (error)
1057
- throw error;
1058
- return { ok: true };
1059
- });