@koloseum/utils 0.2.28 → 0.3.1

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.
@@ -0,0 +1,824 @@
1
+ import { Transform } from "./formatting.js";
2
+ import { Exception } from "./server.js";
3
+ import { v4 as uuidv4 } from "uuid";
4
+ import validator from "validator";
5
+ /* HELPERS */
6
+ const { trim, escape } = validator;
7
+ const { KenyaAdministrativeDivisions } = (await import("kenya-administrative-divisions")).default;
8
+ /* CONFIG HELPERS */
9
+ export const Config = {
10
+ /**
11
+ * A reference of microservices available on the platform, represented as an object with each user group (i.e. `public`, `players`, `lounges`, and `backroom`) having its own array of microservices. Each microservice in a list is an object with the following properties:
12
+ * - `name`: The name of the microservice.
13
+ * - `description`: A description of the microservice.
14
+ * - `slug`: The slug of the microservice.
15
+ * - `features`: An array of features that the microservice has.
16
+ * - `roles`: An array of roles attached to the microservice.
17
+ */
18
+ microservices: {
19
+ backroom: [
20
+ {
21
+ name: "Compliance",
22
+ description: "Management of compliance and regulatory adherence for Players and Lounges.",
23
+ slug: "compliance",
24
+ features: [
25
+ {
26
+ name: "Players",
27
+ description: "Search, review, and manage Player data.",
28
+ slug: "players",
29
+ tabs: [
30
+ {
31
+ name: "Profile",
32
+ description: "Review basic Player data.",
33
+ root: true
34
+ },
35
+ {
36
+ name: "Documents",
37
+ description: "Review Player documents.",
38
+ slug: "documents"
39
+ },
40
+ {
41
+ name: "Data updates",
42
+ description: "Review Player data updates.",
43
+ slug: "data-updates",
44
+ tabs: [
45
+ {
46
+ name: "Requests",
47
+ description: "Review Player data update requests.",
48
+ root: true
49
+ },
50
+ {
51
+ name: "History",
52
+ description: "Review Player data update history.",
53
+ slug: "history"
54
+ }
55
+ ]
56
+ },
57
+ {
58
+ name: "Credits",
59
+ description: "Review Koloseum Credits data for the Player.",
60
+ slug: "credits",
61
+ tabs: [
62
+ {
63
+ name: "Transactions",
64
+ description: "Review Player transactions with Koloseum Credits.",
65
+ root: true
66
+ },
67
+ {
68
+ name: "Transfers",
69
+ description: "Review Player transfers with Koloseum Credits.",
70
+ slug: "transfers"
71
+ },
72
+ {
73
+ name: "Subscriptions",
74
+ description: "Review Player subscriptions with Koloseum Credits.",
75
+ slug: "subscriptions"
76
+ }
77
+ ]
78
+ },
79
+ {
80
+ name: "Misconduct",
81
+ description: "Review Player reports and sanctions.",
82
+ slug: "misconduct",
83
+ tabs: [
84
+ {
85
+ name: "Reports",
86
+ description: "Review Player reports.",
87
+ root: true
88
+ },
89
+ {
90
+ name: "Sanctions",
91
+ description: "Review Player sanctions.",
92
+ slug: "sanctions"
93
+ }
94
+ ]
95
+ },
96
+ {
97
+ name: "Points / KXP",
98
+ description: "Review Koloseum Experience Points (KXP) transactions for the Player.",
99
+ slug: "points"
100
+ }
101
+ ]
102
+ },
103
+ {
104
+ name: "Lounges",
105
+ description: "Search, review, and manage Lounge data.",
106
+ slug: "lounges",
107
+ tabs: [
108
+ {
109
+ name: "Profile",
110
+ description: "Review basic Lounge data.",
111
+ root: true
112
+ },
113
+ {
114
+ name: "Documents",
115
+ description: "Review Lounge documents.",
116
+ slug: "documents"
117
+ },
118
+ {
119
+ name: "Staff",
120
+ description: "Search, review, and manage Lounge staff data.",
121
+ slug: "staff"
122
+ },
123
+ {
124
+ name: "Branches",
125
+ description: "Review Lounge branches.",
126
+ slug: "branches",
127
+ tabs: [
128
+ {
129
+ name: "Profile",
130
+ description: "Review basic Lounge branch data.",
131
+ root: true
132
+ },
133
+ {
134
+ name: "Amenities",
135
+ description: "Review Lounge branch amenities.",
136
+ slug: "amenities"
137
+ },
138
+ {
139
+ name: "Staff",
140
+ description: "Search, review, and manage Lounge branchstaff data.",
141
+ slug: "staff"
142
+ }
143
+ ]
144
+ },
145
+ {
146
+ name: "Data updates",
147
+ description: "Review Lounge data updates.",
148
+ slug: "data-updates",
149
+ tabs: [
150
+ {
151
+ name: "Requests",
152
+ description: "Review Lounge data update requests.",
153
+ root: true
154
+ },
155
+ {
156
+ name: "History",
157
+ description: "Review Lounge data update history.",
158
+ slug: "history"
159
+ }
160
+ ]
161
+ },
162
+ {
163
+ name: "Credits",
164
+ description: "Review Koloseum Credits data for the Lounge.",
165
+ slug: "credits",
166
+ tabs: [
167
+ {
168
+ name: "Transactions",
169
+ description: "Review Lounge transactions with Koloseum Credits.",
170
+ root: true
171
+ },
172
+ {
173
+ name: "Transfers",
174
+ description: "Review Lounge transfers with Koloseum Credits.",
175
+ slug: "transfers"
176
+ },
177
+ {
178
+ name: "Subscriptions",
179
+ description: "Review Lounge subscriptions with Koloseum Credits.",
180
+ slug: "subscriptions"
181
+ }
182
+ ]
183
+ },
184
+ {
185
+ name: "Misconduct",
186
+ description: "Review Lounge reports and sanctions.",
187
+ slug: "misconduct",
188
+ tabs: [
189
+ {
190
+ name: "Reports",
191
+ description: "Review Lounge reports.",
192
+ root: true
193
+ },
194
+ {
195
+ name: "Sanctions",
196
+ description: "Review Lounge sanctions.",
197
+ slug: "sanctions"
198
+ }
199
+ ]
200
+ },
201
+ {
202
+ name: "Points / KXP",
203
+ description: "Review Koloseum Experience Points (KXP) transactions for the Lounge.",
204
+ slug: "points"
205
+ }
206
+ ]
207
+ }
208
+ ],
209
+ roles: [
210
+ {
211
+ name: "Compliance",
212
+ slug: "compliance",
213
+ root: true
214
+ },
215
+ {
216
+ name: "Players",
217
+ slug: "players",
218
+ featureSlugs: ["players"]
219
+ },
220
+ {
221
+ name: "Lounges",
222
+ slug: "lounges",
223
+ featureSlugs: ["lounges"]
224
+ }
225
+ ]
226
+ },
227
+ {
228
+ name: "Competitions",
229
+ description: "Management of competitions data and live events across Markets.",
230
+ slug: "competitions",
231
+ features: [
232
+ {
233
+ name: "Ma Esto",
234
+ description: "Manage football esports data for Ma Esto.",
235
+ slug: "fbl"
236
+ },
237
+ {
238
+ name: "Savanna FGC",
239
+ description: "Manage fighting games esports data for Savanna FGC.",
240
+ slug: "fgc",
241
+ tabs: [
242
+ {
243
+ name: "Savanna Circuit",
244
+ description: "Manage competition data for the Savanna Circuit.",
245
+ slug: "league",
246
+ tabs: [
247
+ {
248
+ name: "Rankings",
249
+ description: "Manage rankings for any Savanna Circuit season.",
250
+ root: true
251
+ },
252
+ {
253
+ name: "Tournaments",
254
+ description: "Manage tournaments for any Savanna Circuit season.",
255
+ slug: "tournaments",
256
+ tabs: [
257
+ {
258
+ name: "Events",
259
+ description: "Manage events for any Savanna Circuit tournament.",
260
+ root: true
261
+ },
262
+ {
263
+ name: "Tickets",
264
+ description: "Manage tickets for any Savanna Circuit tournament.",
265
+ slug: "tickets"
266
+ },
267
+ {
268
+ name: "Settings",
269
+ description: "Manage settings for any Savanna Circuit tournament.",
270
+ slug: "settings"
271
+ }
272
+ ]
273
+ },
274
+ {
275
+ name: "Settings",
276
+ description: "Manage settings for any Savanna Circuit season.",
277
+ slug: "settings"
278
+ }
279
+ ]
280
+ },
281
+ {
282
+ name: "Savanna Fight Night",
283
+ description: "Manage competition data for Savanna Fight Night.",
284
+ slug: "challenges"
285
+ }
286
+ ]
287
+ },
288
+ {
289
+ name: "Hit List",
290
+ description: "Manage battle royale esports data for Hit List.",
291
+ slug: "bryl"
292
+ }
293
+ ],
294
+ roles: [
295
+ {
296
+ name: "Competitions",
297
+ slug: "competitions",
298
+ root: true
299
+ },
300
+ {
301
+ name: "Ma Esto",
302
+ slug: "fbl",
303
+ featureSlugs: ["fbl"]
304
+ },
305
+ {
306
+ name: "Savanna FGC",
307
+ slug: "fgc",
308
+ featureSlugs: ["fgc"]
309
+ },
310
+ {
311
+ name: "Hit List",
312
+ slug: "bryl",
313
+ featureSlugs: ["bryl"]
314
+ }
315
+ ]
316
+ },
317
+ {
318
+ name: "KLSM",
319
+ description: "Management of game and console trades between users.",
320
+ slug: "commerce",
321
+ features: [],
322
+ roles: [
323
+ {
324
+ name: "Commerce",
325
+ slug: "commerce",
326
+ root: true
327
+ }
328
+ ]
329
+ },
330
+ {
331
+ name: "Staff",
332
+ description: "Management of Backroom staff authorisation.",
333
+ slug: "staff",
334
+ features: [
335
+ {
336
+ name: "Roles",
337
+ description: "Review and manage Backroom roles.",
338
+ slug: "roles"
339
+ },
340
+ {
341
+ name: "Users",
342
+ description: "Review and manage Backroom users.",
343
+ slug: "users"
344
+ }
345
+ ],
346
+ roles: [
347
+ {
348
+ name: "Human Resources",
349
+ slug: "hr",
350
+ root: true
351
+ }
352
+ ]
353
+ }
354
+ ],
355
+ players: [
356
+ {
357
+ name: "Sessions",
358
+ description: "Track your gaming sessions at registered lounges.",
359
+ slug: "sessions",
360
+ features: [],
361
+ roles: null
362
+ },
363
+ {
364
+ name: "KLSM",
365
+ description: "Trade used games and consoles with other users at affordable prices.",
366
+ slug: "commerce",
367
+ features: [],
368
+ roles: null
369
+ },
370
+ {
371
+ name: "Savanna FGC",
372
+ description: "Check out the latest news and competitions from Savanna FGC.",
373
+ slug: "fgc",
374
+ features: [
375
+ {
376
+ name: "Home",
377
+ description: "Learn more about the Savanna Circuit.",
378
+ root: true
379
+ },
380
+ {
381
+ name: "Rules",
382
+ description: "Review the rules and regulations of the Savanna Circuit.",
383
+ slug: "rules"
384
+ },
385
+ {
386
+ name: "Tournaments",
387
+ description: "Review and join upcoming tournaments on the Savanna Circuit.",
388
+ slug: "tournaments"
389
+ }
390
+ ],
391
+ roles: null
392
+ },
393
+ {
394
+ name: "Account",
395
+ description: "Review Player account settings and preferences.",
396
+ slug: "account",
397
+ features: [
398
+ {
399
+ name: "General",
400
+ description: "Review and manage your general settings.",
401
+ slug: "general"
402
+ },
403
+ {
404
+ name: "Documents",
405
+ description: "Review and manage your ID documents.",
406
+ slug: "documents"
407
+ },
408
+ {
409
+ name: "Personal",
410
+ description: "Review and manage your personal information.",
411
+ slug: "personal"
412
+ },
413
+ {
414
+ name: "Gaming & Socials",
415
+ description: "Review and manage your gaming and social media information.",
416
+ slug: "gaming-socials"
417
+ },
418
+ {
419
+ name: "Notifications",
420
+ description: "Review and manage your notification preferences.",
421
+ slug: "notifications"
422
+ },
423
+ {
424
+ name: "Security",
425
+ description: "Review and manage your security preferences.",
426
+ slug: "security"
427
+ },
428
+ {
429
+ name: "Lounges",
430
+ description: "Review your access to Lounges microservices.",
431
+ slug: "lounges"
432
+ },
433
+ {
434
+ name: "Backroom",
435
+ description: "Review your access to Backroom microservices.",
436
+ slug: "backroom"
437
+ }
438
+ ],
439
+ roles: null
440
+ }
441
+ ]
442
+ }
443
+ };
444
+ /* DUMMY DATA */
445
+ export const Mock = {
446
+ /**
447
+ * A generic authenticated user.
448
+ * @param {string} id - The user ID; defaults to a random UUID
449
+ * @param {Date} date - The date and time; defaults to the current date and time
450
+ * @param {string} phone - The phone number; defaults to a generic Kenyan phone number
451
+ * @param {string} identityId - A default identity ID; defaults to a random UUID
452
+ * @returns A generic authenticated user.
453
+ */
454
+ authenticatedUser: (id = uuidv4(), date = new Date(), phone = "254111222333", identityId = uuidv4()) => {
455
+ // Convert date to ISO string
456
+ const time = date.toISOString();
457
+ // User data
458
+ return {
459
+ id,
460
+ aud: "authenticated",
461
+ role: "authenticated",
462
+ email: "",
463
+ phone,
464
+ phone_confirmed_at: time,
465
+ confirmation_sent_at: time,
466
+ confirmed_at: time,
467
+ last_sign_in_at: time,
468
+ app_metadata: {
469
+ person_data: { player_id: "KP1234567", first_name: "John", last_name: "Test", pseudonym: "JDtest" },
470
+ provider: "phone",
471
+ providers: ["phone"],
472
+ roles: ["player", "backroom_superuser"]
473
+ },
474
+ user_metadata: {
475
+ email_verified: false,
476
+ phone_verified: false,
477
+ sub: id,
478
+ backroom: {
479
+ welcome_notification_sent: true
480
+ },
481
+ players: {
482
+ welcome_notification_sent: true
483
+ }
484
+ },
485
+ identities: [
486
+ {
487
+ identity_id: identityId,
488
+ id,
489
+ user_id: id,
490
+ identity_data: {
491
+ email_verified: false,
492
+ phone_verified: false,
493
+ sub: id
494
+ },
495
+ provider: "phone",
496
+ last_sign_in_at: time,
497
+ created_at: time,
498
+ updated_at: time
499
+ }
500
+ ],
501
+ created_at: time,
502
+ updated_at: time,
503
+ is_anonymous: false
504
+ };
505
+ }
506
+ };
507
+ /* RESOURCE HELPERS */
508
+ export const Resource = {
509
+ /**
510
+ * Returns a list of all counties in Kenya.
511
+ * @param {"name" | "code"} sortBy - The field to sort the counties by, i.e. `name` or `code`; defaults to `name`
512
+ * @returns {Promise<County[]>} A list of objects with the county `name`, `code`, and a list of `subCounties`
513
+ */
514
+ getKenyaCounties: async (sortBy = "name") => {
515
+ // Create Kenya administrative divisions instance
516
+ const kenyaAdmin = new KenyaAdministrativeDivisions();
517
+ // Get all counties
518
+ const countiesData = await kenyaAdmin.getAll();
519
+ // Format counties
520
+ const counties = countiesData.map((county) => {
521
+ // Get list of sub-counties
522
+ const subCounties = [];
523
+ for (const subCounty of county.constituencies)
524
+ subCounties.push(subCounty.constituency_name);
525
+ subCounties.sort();
526
+ // Format county name and add to list
527
+ const countyName = county.county_name.split(" ");
528
+ let name = county.county_name;
529
+ if (countyName.length > 1)
530
+ name = countyName.map((word) => Transform.capitalise(word)).join(" ");
531
+ // Return county
532
+ return { name, code: county.county_code, subCounties };
533
+ });
534
+ // Return sorted counties
535
+ return counties.sort((a, b) => (sortBy === "name" ? a.name.localeCompare(b.name) : a.code - b.code));
536
+ },
537
+ /**
538
+ * Returns the parent URL for a given base URL.
539
+ * @param {string} base - The base URL
540
+ * @returns {string} The parent URL
541
+ */
542
+ getParentUrl: (base) => {
543
+ // Validate input
544
+ if (typeof base !== "string")
545
+ return "";
546
+ // Return parent URL
547
+ return base.replace(/\/$/, "").split("/").slice(0, -1).join("/") || "/";
548
+ },
549
+ /**
550
+ * Returns the redirect URL for a given URI.
551
+ * @param {string} uri - The URI to get the redirect URL for, i.e. `microserviceGroup:path` (e.g. `players:fgc/tournaments`)
552
+ * @param {"development" | "production"} env - The environment to use for the redirect URL; defaults to `production`
553
+ * @returns {string} An object with the redirect `url`, or an `error` if any occurs
554
+ */
555
+ getRedirectUrl: (uri, env = "production") => {
556
+ // Get microservice groups
557
+ const microserviceGroups = ["public", "players", "lounges", "backroom"];
558
+ // Extract microservice group and path from URI
559
+ let [microserviceGroup, path] = uri.split(":");
560
+ if (!microserviceGroup || !path)
561
+ return { error: { code: 400, message: "URI is invalid." } };
562
+ if (!microserviceGroups.includes(microserviceGroup))
563
+ return { error: { code: 400, message: "Microservice group is invalid." } };
564
+ // Initialise port number for local development
565
+ let port;
566
+ // Return redirect URL for Public microservices
567
+ if (microserviceGroup === "public") {
568
+ // Initialise subdomain for production
569
+ let subdomain = "";
570
+ // Handle Authentication microservice
571
+ if (path.startsWith("auth")) {
572
+ subdomain = "auth.";
573
+ port = 5173;
574
+ path = path.replace("auth", "");
575
+ }
576
+ // Handle Legal microservice
577
+ else if (path.startsWith("legal")) {
578
+ subdomain = "legal.";
579
+ port = 5174;
580
+ path = path.replace("legal", "");
581
+ }
582
+ // Handle Landing microservice
583
+ else if (path.startsWith("landing")) {
584
+ port = 5184;
585
+ path = path.replace("landing", "");
586
+ }
587
+ // Return redirect URL
588
+ return {
589
+ url: env === "production"
590
+ ? `https://${subdomain}koloseum.ke${path || ""}`
591
+ : `http://127.0.0.1:${port}${path || ""}`
592
+ };
593
+ }
594
+ // Handle Players microservices
595
+ if (microserviceGroup === "players") {
596
+ if (path.startsWith("account")) {
597
+ port = 5175;
598
+ if (env === "development")
599
+ path = path.replace("account", "");
600
+ }
601
+ if (path.startsWith("sessions")) {
602
+ port = 5178;
603
+ if (env === "development")
604
+ path = path.replace("sessions", "");
605
+ }
606
+ if (path.startsWith("competitions")) {
607
+ port = 5179;
608
+ if (env === "development")
609
+ path = path.replace("competitions", "");
610
+ }
611
+ if (path.startsWith("commerce")) {
612
+ port = 5180;
613
+ if (env === "development")
614
+ path = path.replace("commerce", "");
615
+ }
616
+ }
617
+ // Handle Lounges microservices
618
+ if (microserviceGroup === "lounges") {
619
+ if (path.startsWith("account")) {
620
+ port = 5181;
621
+ if (env === "development")
622
+ path = path.replace("account", "");
623
+ }
624
+ if (path.startsWith("operations")) {
625
+ port = 5182;
626
+ if (env === "development")
627
+ path = path.replace("operations", "");
628
+ }
629
+ if (path.startsWith("staff")) {
630
+ port = 5183;
631
+ if (env === "development")
632
+ path = path.replace("staff", "");
633
+ }
634
+ }
635
+ // Handle Backroom microservices
636
+ if (microserviceGroup === "backroom") {
637
+ if (path.startsWith("compliance")) {
638
+ port = 5176;
639
+ if (env === "development")
640
+ path = path.replace("compliance", "");
641
+ }
642
+ if (path.startsWith("commerce")) {
643
+ port = 5184;
644
+ if (env === "development")
645
+ path = path.replace("commerce", "");
646
+ }
647
+ if (path.startsWith("staff")) {
648
+ port = 5185;
649
+ if (env === "development")
650
+ path = path.replace("staff", "");
651
+ }
652
+ }
653
+ // Return redirect URL
654
+ return {
655
+ url: env === "production"
656
+ ? `https://${microserviceGroup}.koloseum.ke/${path || ""}`
657
+ : `http://127.0.0.1:${port}${path || ""}`
658
+ };
659
+ },
660
+ /**
661
+ * Processes a redirect URI and returns a validated, safe redirect URL. Callers in SvelteKit can pass `dev` from `$app/environment`.
662
+ * @param uri - The URI to process (e.g. `players:competitions/leagues`)
663
+ * @param dev - Whether the environment is development/test; defaults to `false`
664
+ * @returns An object with the safe redirect `url`, or an `error` if the URI or URL is invalid or unsafe
665
+ */
666
+ getSafeRedirectUrl: (uri, dev = false) => {
667
+ const { url, error } = Resource.getRedirectUrl(uri, dev ? "development" : "production");
668
+ if (error)
669
+ return {
670
+ error: Exception.customError(error.code ?? 400, error.message ?? "Redirect URI is invalid.")
671
+ };
672
+ if (!url || !Resource.validateRedirectUrl(url, dev))
673
+ return { error: Exception.customError(400, "Redirect URL is invalid or unsafe.") };
674
+ return { url };
675
+ },
676
+ /**
677
+ * Returns whether the given URL points to a local Supabase instance (localhost or 127.0.0.1, any port).
678
+ * Use for cookie domain, redirects, and MSW so non-default local ports (e.g. 54621) behave like default (54321).
679
+ * @param url - Supabase API URL (e.g. PUBLIC_SUPABASE_URL)
680
+ * @returns `true` if the URL host is localhost or 127.0.0.1, `false` otherwise
681
+ */
682
+ isLocalSupabase: (url) => {
683
+ if (!url || typeof url !== "string")
684
+ return false;
685
+ try {
686
+ const host = new URL(url).hostname;
687
+ return host === "localhost" || host === "127.0.0.1";
688
+ }
689
+ catch {
690
+ return false;
691
+ }
692
+ },
693
+ /**
694
+ * Parses a resource request and returns the URL and path.
695
+ * @param request - The request to parse.
696
+ * @returns An object with the `url` and `path` of the request.
697
+ */
698
+ parseResourceRequest: (request) => {
699
+ // Get the request URL and path
700
+ const url = new URL(request.url);
701
+ const path = url.pathname.split("/").pop();
702
+ // Return the request URL and path
703
+ return { url, path };
704
+ },
705
+ /**
706
+ * Validates address data submitted in a form and returns the validated data.
707
+ * @param {FormData} formData - The submitted form data
708
+ * @returns An object with the validated `address`, or an `error` if any has occurred
709
+ */
710
+ validateAddress: async (formData) => {
711
+ // Create Kenya administrative divisions instance
712
+ const kenyaAdmin = new KenyaAdministrativeDivisions();
713
+ // Building name
714
+ let buildingName = formData.get("building-name");
715
+ if (!buildingName)
716
+ return { error: { code: 400, message: "Building name is required." } };
717
+ buildingName = Transform.sanitiseHtml(trim(escape(buildingName)));
718
+ // Unit number
719
+ let unitNumber = formData.get("unit-number") || undefined;
720
+ if (unitNumber) {
721
+ unitNumber = Transform.sanitiseHtml(trim(escape(unitNumber)));
722
+ if (unitNumber === "")
723
+ unitNumber = undefined;
724
+ }
725
+ // Street name
726
+ let streetName = formData.get("street-name");
727
+ if (!streetName)
728
+ return { error: { code: 400, message: "Street name is required." } };
729
+ streetName = Transform.sanitiseHtml(trim(escape(streetName)));
730
+ // P.O. box and postal code
731
+ let boxNumber = formData.get("box-number") || undefined;
732
+ let postalCode = formData.get("postal-code") || undefined;
733
+ if (boxNumber) {
734
+ boxNumber = Transform.sanitiseHtml(trim(escape(boxNumber)));
735
+ if (boxNumber === "")
736
+ boxNumber = undefined;
737
+ if (boxNumber && !postalCode)
738
+ return { error: { code: 400, message: "Postal code is required." } };
739
+ }
740
+ if (postalCode) {
741
+ postalCode = Transform.sanitiseHtml(trim(escape(postalCode)));
742
+ if (postalCode === "")
743
+ postalCode = undefined;
744
+ if (postalCode && !boxNumber)
745
+ return { error: { code: 400, message: "P.O. box is required." } };
746
+ }
747
+ // Town
748
+ let town = formData.get("town");
749
+ if (!town)
750
+ return { error: { code: 400, message: "City/town is required." } };
751
+ town = Transform.sanitiseHtml(trim(escape(town)));
752
+ // County
753
+ let county = undefined;
754
+ const countyCode = Number(formData.get("county-code"));
755
+ if (isNaN(countyCode))
756
+ return { error: { code: 400, message: "County is invalid." } };
757
+ if (!countyCode)
758
+ return { error: { code: 400, message: "County is required." } };
759
+ const counties = await kenyaAdmin.getAll();
760
+ if (!counties || counties.length === 0)
761
+ return { error: { code: 400, message: "County is invalid." } };
762
+ const countyData = counties.find(({ county_code }) => county_code === countyCode);
763
+ if (!countyData)
764
+ return { error: { code: 400, message: "County is invalid." } };
765
+ const { county_name, constituencies } = countyData;
766
+ county = county_name;
767
+ // Sub-county
768
+ let subCounty = formData.get("sub-county") || undefined;
769
+ if (subCounty) {
770
+ subCounty = Transform.sanitiseHtml(trim(escape(subCounty)));
771
+ if (subCounty === "")
772
+ subCounty = undefined;
773
+ else {
774
+ // Check if constituencies exists and is an array before mapping
775
+ if (!constituencies || !Array.isArray(constituencies))
776
+ return { error: { code: 400, message: "Sub-county data is unavailable." } };
777
+ // Get list of sub-counties
778
+ const subCounties = constituencies.map(({ constituency_name: name }) => name);
779
+ if (!subCounties.includes(subCounty))
780
+ return { error: { code: 400, message: "Sub-county is invalid." } };
781
+ }
782
+ }
783
+ // Additional information
784
+ let additionalInfo = formData.get("address-additional-info") || undefined;
785
+ if (additionalInfo) {
786
+ additionalInfo = Transform.sanitiseHtml(trim(escape(additionalInfo)));
787
+ if (additionalInfo === "")
788
+ additionalInfo = undefined;
789
+ }
790
+ // Create address object
791
+ const address = {
792
+ buildingName,
793
+ unitNumber,
794
+ streetName,
795
+ town,
796
+ subCounty,
797
+ county,
798
+ additionalInfo
799
+ };
800
+ // Return data
801
+ return { address };
802
+ },
803
+ /**
804
+ * Validates that a redirect URL is safe and allowed. Callers in SvelteKit can pass `dev` from `$app/environment`.
805
+ * @param url - The URL to validate
806
+ * @param dev - Whether the environment is development/test. Defaults to `false`
807
+ * @returns `true` if the URL is safe, `false` otherwise
808
+ */
809
+ validateRedirectUrl: (url, dev = false) => {
810
+ try {
811
+ const parsedUrl = new URL(url);
812
+ if (!dev && parsedUrl.protocol !== "https:")
813
+ return false;
814
+ if (dev && parsedUrl.hostname === "127.0.0.1")
815
+ return true;
816
+ if (!parsedUrl.hostname.endsWith(".koloseum.ke") && parsedUrl.hostname !== "koloseum.ke")
817
+ return false;
818
+ return true;
819
+ }
820
+ catch {
821
+ return false;
822
+ }
823
+ }
824
+ };