@koloseum/utils 0.3.6 → 0.3.7

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.
package/dist/client.js CHANGED
@@ -367,7 +367,9 @@ export const Access = {
367
367
  if (!user)
368
368
  return null;
369
369
  // Initialise variables
370
- let features = Config.microservices.players.filter(({ slug }) => slug === "account").flatMap(({ features }) => features);
370
+ let features = Config.microservices.players
371
+ .filter(({ slug }) => slug === "account")
372
+ .flatMap(({ features }) => features);
371
373
  let loungesCount = 0;
372
374
  let backroomCount = 0;
373
375
  // Count number of Lounges and Backroom roles
@@ -441,11 +443,16 @@ export const Interface = {
441
443
  if (result.type === "redirect" && result.location) {
442
444
  const { goto } = await import("$app/navigation");
443
445
  await goto(result.location);
446
+ // Restore button if not an OTP button
447
+ if (!button.classList.contains("otp-button")) {
448
+ button.disabled = false;
449
+ button.innerHTML = innerHTML;
450
+ }
444
451
  return;
445
452
  }
446
453
  // Update form
447
- // OTP buttons are not restored on failure/error so OTP flows keep the loading state
448
454
  await update();
455
+ // OTP buttons are not restored on failure/error so OTP flows keep the loading state
449
456
  const isOtpButton = button.classList.contains("otp-button");
450
457
  const isFailureOrError = result.type === "failure" || result.type === "error";
451
458
  if (!isOtpButton || !isFailureOrError) {
@@ -1,4 +1,4 @@
1
- import type { IdentityType, PronounsItem, Sex, SocialMediaPlatform, SocialMediaPlatformObject } from "@koloseum/types/public/auth";
1
+ import type { BranchAddressObject, IdentityType, PronounsItem, Sex, SocialMediaPlatform, SocialMediaPlatformObject } from "@koloseum/types/public/auth";
2
2
  import type { MobilePhoneLocale } from "validator";
3
3
  export declare const Transform: {
4
4
  /**
@@ -14,24 +14,33 @@ export declare const Transform: {
14
14
  */
15
15
  cleanUrl: (url: string) => string;
16
16
  /**
17
- * Formats a Koloseum Credits balance in Kenyan shillings (e.g. "Ksh 12.34").
18
- * @param {number | null | undefined} cents - The balance to be formatted
19
- * @returns {string} The formatted balance string, or "—" if the input is invalid
17
+ * Formats an amount in cents to Kenyan shillings (e.g. "Ksh 12.34").
18
+ * @param {number | null | undefined} cents - The amount to be formatted
19
+ * @param {number} decimalPlaces - The number of decimal places to display; defaults to 2
20
+ * @returns {string} The formatted string, or "—" if the input is invalid
20
21
  */
21
- formatCreditsBalance: (cents: number | null | undefined) => string;
22
+ formatCentsToKsh: (cents: number | null | undefined, decimalPlaces?: number) => string;
22
23
  /**
23
24
  * Formats a date in the format DD/MM/YYYY (by default) or YYYY-MM-DD (for client).
24
25
  * @param {string} date - Date to be formatted
25
- * @param {boolean} forClient - Specify whether the data is for displaying on the client; defaults to `false`
26
- * @returns The formatted date string, or `null` if invalid input or in case of an error
26
+ * @param {boolean} forClient - Specify whether the data is for displaying on the client (if not `isLocaleDateString`); defaults to `false`
27
+ * @param {boolean} isLocaleDateString - Specify whether the date should be returned as a locale date string; defaults to `false`
28
+ * @param {"short" | "medium" | "long" | "full"} dateStyle - The style of the date to return (if `isLocaleDateString`); defaults to `long`
29
+ * @returns The formatted date string, or an empty string if `isLocaleDateString` is `true`, or `null` if invalid input or in case of an error
27
30
  */
28
- formatDate: (date: string, forClient?: boolean) => string | null;
31
+ formatDate: (date: string, forClient?: boolean, isLocaleDateString?: boolean, dateStyle?: "short" | "medium" | "long" | "full") => string | null;
29
32
  /**
30
33
  * Formats a Koloseum Experience Points (KXP) balance as an integer (e.g. "1,234 KXP").
31
34
  * @param {number | null | undefined} kxp - The balance to be formatted
32
35
  * @returns {string} The formatted balance string, or "—" if the input is invalid
33
36
  */
34
37
  formatKxpBalance: (kxp: number | null | undefined) => string;
38
+ /**
39
+ * Formats a PostgreSQL time string as "HH:MM".
40
+ * @param {string} time - The time string to be formatted (i.e. from PostgreSQL `time` or `timetz`)
41
+ * @returns {string} The formatted time string "HH:MM", or an empty string if the input is invalid
42
+ */
43
+ formatPostgresTime: (time: string) => string;
35
44
  /**
36
45
  * Formats a social media platform for display.
37
46
  * @param {SocialMediaPlatform} platform - The social media platform to be formatted
@@ -47,11 +56,12 @@ export declare const Transform: {
47
56
  /**
48
57
  * Formats a date as a locale-specific string.
49
58
  * @param {Date | string | number | null} date - Date to be formatted
50
- * @param {boolean} withTime - Specify whether the date should include time; defaults to `false`
59
+ * @param {boolean} withTime - Whether the date should include time; defaults to `false`
51
60
  * @param {string} timeZone - The time zone to use for formatting; defaults to `Africa/Nairobi`
61
+ * @param {boolean} timeOnly - Whether to output time only (no date), if `withTime` is `true`; defaults to `false`
52
62
  * @returns The formatted date string, or an empty string if invalid input
53
63
  */
54
- getDateString: (date: Date | string | number | null, withTime?: boolean, timeZone?: string) => string;
64
+ getDateString: (date: Date | string | number | null, withTime?: boolean, timeZone?: string, timeOnly?: boolean) => string;
55
65
  /**
56
66
  * Formats an identity type for display.
57
67
  * @param {IdentityType} identityType - The identity type to be formatted
@@ -84,6 +94,12 @@ export declare const Transform: {
84
94
  paginatedItems: T[];
85
95
  totalPages: number;
86
96
  };
97
+ /**
98
+ * Parses a Lounge branch address into an array of formatted address details.
99
+ * @param {BranchAddressObject} address - The address to parse
100
+ * @returns {string[]} The formatted address details array
101
+ */
102
+ parseLoungeBranchAddress: (address: BranchAddressObject) => string[];
87
103
  /**
88
104
  * Sanitises any potential HTML injected into user input.
89
105
  * @param {string} input - The input to be sanitised
@@ -22,21 +22,22 @@ export const Transform = {
22
22
  */
23
23
  cleanUrl: (url) => url.replace(/^https?:\/\//, "").replace(/\/+$/, ""),
24
24
  /**
25
- * Formats a Koloseum Credits balance in Kenyan shillings (e.g. "Ksh 12.34").
26
- * @param {number | null | undefined} cents - The balance to be formatted
27
- * @returns {string} The formatted balance string, or "—" if the input is invalid
25
+ * Formats an amount in cents to Kenyan shillings (e.g. "Ksh 12.34").
26
+ * @param {number | null | undefined} cents - The amount to be formatted
27
+ * @param {number} decimalPlaces - The number of decimal places to display; defaults to 2
28
+ * @returns {string} The formatted string, or "—" if the input is invalid
28
29
  */
29
- formatCreditsBalance: (cents) => {
30
- // Return placeholder if no balance is provided or input is invalid
30
+ formatCentsToKsh: (cents, decimalPlaces = 2) => {
31
+ // Return placeholder if no amount is provided or input is invalid
31
32
  if (cents == null || typeof cents !== "number")
32
33
  return "—";
33
- // Format balance
34
+ // Format amount
34
35
  const value = cents / 100;
35
36
  let formatted = new Intl.NumberFormat("en-KE", {
36
37
  style: "currency",
37
38
  currency: "KES",
38
- minimumFractionDigits: 2,
39
- maximumFractionDigits: 2
39
+ minimumFractionDigits: decimalPlaces,
40
+ maximumFractionDigits: decimalPlaces
40
41
  }).format(value);
41
42
  formatted = formatted.replace(/\u00A0/g, " ");
42
43
  // Return formatted string
@@ -45,20 +46,25 @@ export const Transform = {
45
46
  /**
46
47
  * Formats a date in the format DD/MM/YYYY (by default) or YYYY-MM-DD (for client).
47
48
  * @param {string} date - Date to be formatted
48
- * @param {boolean} forClient - Specify whether the data is for displaying on the client; defaults to `false`
49
- * @returns The formatted date string, or `null` if invalid input or in case of an error
49
+ * @param {boolean} forClient - Specify whether the data is for displaying on the client (if not `isLocaleDateString`); defaults to `false`
50
+ * @param {boolean} isLocaleDateString - Specify whether the date should be returned as a locale date string; defaults to `false`
51
+ * @param {"short" | "medium" | "long" | "full"} dateStyle - The style of the date to return (if `isLocaleDateString`); defaults to `long`
52
+ * @returns The formatted date string, or an empty string if `isLocaleDateString` is `true`, or `null` if invalid input or in case of an error
50
53
  */
51
- formatDate: (date, forClient = false) => {
54
+ formatDate: (date, forClient = false, isLocaleDateString = false, dateStyle = "long") => {
52
55
  // Return null if no date is provided
56
+ const nullDate = isLocaleDateString ? "" : null;
53
57
  if (date === "" || typeof date !== "string")
54
- return null;
58
+ return nullDate;
55
59
  // Handle input
56
60
  try {
57
61
  // Parse date string
58
62
  const parsedDate = Date.parse(date);
59
- // Return null if date string is not parseable
60
63
  if (isNaN(parsedDate))
61
- return null;
64
+ return nullDate;
65
+ // Return locale date string if requested
66
+ if (isLocaleDateString)
67
+ return new Date(parsedDate).toLocaleDateString("en-KE", { dateStyle });
62
68
  // Use the Intl.DateTimeFormat with explicit options to ensure correct formatting
63
69
  const formattedDate = new Intl.DateTimeFormat(forClient ? "fr-CA" : "en-KE", {
64
70
  day: "2-digit",
@@ -71,7 +77,7 @@ export const Transform = {
71
77
  }
72
78
  catch (error) {
73
79
  console.error(error);
74
- return null;
80
+ return nullDate;
75
81
  }
76
82
  },
77
83
  /**
@@ -85,6 +91,39 @@ export const Transform = {
85
91
  const formatted = new Intl.NumberFormat("en-KE").format(kxp);
86
92
  return `${formatted} KXP`;
87
93
  },
94
+ /**
95
+ * Formats a PostgreSQL time string as "HH:MM".
96
+ * @param {string} time - The time string to be formatted (i.e. from PostgreSQL `time` or `timetz`)
97
+ * @returns {string} The formatted time string "HH:MM", or an empty string if the input is invalid
98
+ */
99
+ formatPostgresTime: (time) => {
100
+ // Return empty string if no time is provided or input is not a string
101
+ if (time == null || typeof time !== "string")
102
+ return "";
103
+ const trimmed = time.trim();
104
+ if (trimmed === "")
105
+ return "";
106
+ // Match PostgreSQL/ISO-style time: H:MM, HH:MM, HH:MM:SS, or HH:MM:SS.ffffff
107
+ const match = trimmed.match(/^(\d{1,2}):(\d{2})(?::(\d{1,2})(?:\.\d+)?)?$/);
108
+ if (!match)
109
+ return "";
110
+ const hours = Number.parseInt(match[1], 10);
111
+ const minutes = Number.parseInt(match[2], 10);
112
+ const seconds = match[3] !== undefined ? Number.parseInt(match[3], 10) : 0;
113
+ // Validate ranges: hour 0–24, minute 0–59, second 0–60; 24:00:00 is end-of-day
114
+ if (hours < 0 || hours > 24)
115
+ return "";
116
+ if (minutes < 0 || minutes > 59)
117
+ return "";
118
+ if (seconds < 0 || seconds > 60)
119
+ return "";
120
+ if (hours === 24 && (minutes !== 0 || seconds !== 0))
121
+ return "";
122
+ // Return formatted time
123
+ const h = String(hours).padStart(2, "0");
124
+ const m = String(minutes).padStart(2, "0");
125
+ return `${h}:${m}`;
126
+ },
88
127
  /**
89
128
  * Formats a social media platform for display.
90
129
  * @param {SocialMediaPlatform} platform - The social media platform to be formatted
@@ -148,11 +187,12 @@ export const Transform = {
148
187
  /**
149
188
  * Formats a date as a locale-specific string.
150
189
  * @param {Date | string | number | null} date - Date to be formatted
151
- * @param {boolean} withTime - Specify whether the date should include time; defaults to `false`
190
+ * @param {boolean} withTime - Whether the date should include time; defaults to `false`
152
191
  * @param {string} timeZone - The time zone to use for formatting; defaults to `Africa/Nairobi`
192
+ * @param {boolean} timeOnly - Whether to output time only (no date), if `withTime` is `true`; defaults to `false`
153
193
  * @returns The formatted date string, or an empty string if invalid input
154
194
  */
155
- getDateString: (date, withTime = false, timeZone = "Africa/Nairobi") => {
195
+ getDateString: (date, withTime = false, timeZone = "Africa/Nairobi", timeOnly = false) => {
156
196
  // Return empty string if no date is provided
157
197
  if (!date)
158
198
  return "";
@@ -178,9 +218,9 @@ export const Transform = {
178
218
  // Format date
179
219
  return dateToFormat.toLocaleString("en-KE", {
180
220
  timeZone,
181
- year: "numeric",
182
- month: "long",
183
- day: "numeric",
221
+ year: withTime && timeOnly ? undefined : "numeric",
222
+ month: withTime && timeOnly ? undefined : "long",
223
+ day: withTime && timeOnly ? undefined : "numeric",
184
224
  hour: withTime ? "numeric" : undefined,
185
225
  minute: withTime ? "2-digit" : undefined,
186
226
  hour12: withTime ? true : undefined
@@ -330,6 +370,36 @@ export const Transform = {
330
370
  // Return data
331
371
  return { paginatedItems, totalPages };
332
372
  },
373
+ /**
374
+ * Parses a Lounge branch address into an array of formatted address details.
375
+ * @param {BranchAddressObject} address - The address to parse
376
+ * @returns {string[]} The formatted address details array
377
+ */
378
+ parseLoungeBranchAddress: (address) => {
379
+ // Validate address
380
+ if (!address || Array.isArray(address) || typeof address !== "object")
381
+ return [];
382
+ // Destructure address
383
+ const { buildingName, unitNumber, streetName, boxNumber, postalCode, town, county } = address;
384
+ // Require fields used in template literals to avoid "undefined" in output
385
+ const required = typeof buildingName === "string" &&
386
+ typeof streetName === "string" &&
387
+ typeof town === "string" &&
388
+ typeof county === "string";
389
+ if (!required)
390
+ return [];
391
+ // Define address details
392
+ const addressDetails = [];
393
+ // Add address details
394
+ addressDetails.push(`${buildingName}${unitNumber ? `, ${unitNumber}` : ""}`);
395
+ addressDetails.push(streetName);
396
+ if (boxNumber && postalCode)
397
+ addressDetails.push(`P.O. Box ${boxNumber}-${postalCode}`);
398
+ addressDetails.push(town);
399
+ addressDetails.push(`${county} County`);
400
+ // Return address details
401
+ return addressDetails;
402
+ },
333
403
  /**
334
404
  * Sanitises any potential HTML injected into user input.
335
405
  * @param {string} input - The input to be sanitised
package/dist/platform.js CHANGED
@@ -4,7 +4,6 @@ import { v4 as uuidv4 } from "uuid";
4
4
  import validator from "validator";
5
5
  /* HELPERS */
6
6
  const { trim, escape } = validator;
7
- const { KenyaAdministrativeDivisions } = (await import("kenya-administrative-divisions")).default;
8
7
  /* CONFIG HELPERS */
9
8
  export const Config = {
10
9
  /**
@@ -104,12 +103,12 @@ export const Config = {
104
103
  {
105
104
  name: "Start Session",
106
105
  description: "Search for a Lounge, choose game and station, check in or reserve, and manage your session to checkout.",
107
- slug: "start-session"
106
+ slug: "start"
108
107
  },
109
108
  {
110
109
  name: "Find a Lounge",
111
110
  description: "Browse Lounges, view branches and stations, start a session, or file a report.",
112
- slug: "find-lounge"
111
+ slug: "lounges"
113
112
  }
114
113
  ],
115
114
  roles: null
@@ -165,7 +164,7 @@ export const Config = {
165
164
  {
166
165
  name: "Game Exchange",
167
166
  description: "Discover and reserve used games and consoles for sale.",
168
- slug: "game-exchange"
167
+ slug: "exchange"
169
168
  }
170
169
  ],
171
170
  roles: null
@@ -446,7 +445,7 @@ export const Config = {
446
445
  {
447
446
  name: "Game Exchange",
448
447
  description: "Review and manage used game and console listings and track revenue.",
449
- slug: "game-exchange"
448
+ slug: "exchange"
450
449
  }
451
450
  ],
452
451
  roles: [
@@ -472,13 +471,13 @@ export const Config = {
472
471
  },
473
472
  {
474
473
  name: "Box Office",
475
- slug: "box-office",
474
+ slug: "events",
476
475
  featureSlugs: ["dashboard", "events"]
477
476
  },
478
477
  {
479
478
  name: "Game Exchange",
480
- slug: "game-exchange",
481
- featureSlugs: ["dashboard", "game-exchange"]
479
+ slug: "exchange",
480
+ featureSlugs: ["dashboard", "exchange"]
482
481
  }
483
482
  ]
484
483
  },
@@ -591,6 +590,7 @@ export const Resource = {
591
590
  */
592
591
  getKenyaCounties: async (sortBy = "name") => {
593
592
  // Create Kenya administrative divisions instance
593
+ const { KenyaAdministrativeDivisions } = (await import("kenya-administrative-divisions")).default;
594
594
  const kenyaAdmin = new KenyaAdministrativeDivisions();
595
595
  // Get all counties
596
596
  const countiesData = await kenyaAdmin.getAll();
@@ -810,6 +810,7 @@ export const Resource = {
810
810
  */
811
811
  validateAddress: async (formData) => {
812
812
  // Create Kenya administrative divisions instance
813
+ const { KenyaAdministrativeDivisions } = (await import("kenya-administrative-divisions")).default;
813
814
  const kenyaAdmin = new KenyaAdministrativeDivisions();
814
815
  // Building name
815
816
  let buildingName = formData.get("building-name");
package/dist/server.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Database } from "@koloseum/types/database";
2
2
  import type { CustomError, Microservice, MicroserviceGroup, UserWithCustomMetadata } from "@koloseum/types/general";
3
- import type { SupabaseClient, FunctionInvokeOptions, PostgrestError } from "@supabase/supabase-js";
3
+ import type { FunctionInvokeOptions, PostgrestError, SupabaseClient } from "@supabase/supabase-js";
4
4
  export declare const Instance: {
5
5
  /**
6
6
  * Calls a Supabase Edge function.
@@ -35,7 +35,7 @@ export declare const Instance: {
35
35
  * @param {Object} options - The options for the function.
36
36
  * @param {MicroserviceGroup} options.microserviceGroup - The microservice group.
37
37
  * @param {Microservice<MicroserviceGroup> | string} options.microservice - The microservice.
38
- * @param {string} options.playersUrl - The URL to the Players microservice.
38
+ * @param {string} options.playersUrl - The URL to the Players microservice; include only if `microserviceGroup` is not `players`.
39
39
  * @param {(role: string) => string} options.requestedPermission - A function that returns the requested permission for a given role.
40
40
  * @returns {Promise<{ isAuthorised?: boolean; redirect?: { code: number; url: string }; error?: CustomError }>} The result of the function.
41
41
  */
@@ -73,10 +73,10 @@ export declare const Exception: {
73
73
  customError: (code: number | undefined, message: string) => CustomError;
74
74
  /**
75
75
  * Parses a `CustomError` object within a page/layout server load function and returns an error to be thrown.
76
- * @param {CustomError} error - The error object
76
+ * @param {CustomError | PostgrestError | null} error - The error object
77
77
  * @returns A new `Error` object if the error is unexpected, or a Svelte error otherwise
78
78
  */
79
- parseLoadError: (error: CustomError) => Error | never;
79
+ parseLoadError: (error: CustomError | PostgrestError | null) => Error | never;
80
80
  /**
81
81
  * Parses a `PostgrestError` object and returns a custom error object if any has occurred.
82
82
  * @param {PostgrestError | null} postgrestError - The `PostgrestError` object, or `null` if no error occurred
@@ -87,3 +87,40 @@ export declare const Exception: {
87
87
  error?: CustomError;
88
88
  };
89
89
  };
90
+ export declare const Phone: {
91
+ /**
92
+ * Requests an OTP to be sent to the given phone.
93
+ * Logs the attempt to compliance.phone_verification_attempts via log_phone_verification.
94
+ * @param {SupabaseClient<Database>} supabase - The Supabase client
95
+ * @param {string} phone - The phone number in E.164 format
96
+ * @param {Object} options - The options for the function.
97
+ * @param {boolean} options.dev - Whether the environment is development/test; defaults to `false`
98
+ * @param {string} options.supabaseUrl - The URL of the Supabase instance; defaults to `env.PUBLIC_SUPABASE_URL`
99
+ * @returns An object with `success` if the OTP request succeeded, or an `error` if any occurred
100
+ */
101
+ requestOtp: (supabase: SupabaseClient<Database>, phone: string, options: {
102
+ dev: boolean;
103
+ supabaseUrl: string;
104
+ }) => Promise<{
105
+ twilioSid?: string;
106
+ error?: CustomError;
107
+ }>;
108
+ /**
109
+ * Verifies an OTP code.
110
+ * Updates the attempt in compliance.phone_verification_attempts via log_phone_verification.
111
+ * @param {SupabaseClient<Database>} supabase - The Supabase client
112
+ * @param {string} phone - The phone number in E.164 format
113
+ * @param {string} code - The one-time passcode entered by the user
114
+ * @param {Object} options - The options for the function.
115
+ * @param {boolean} options.dev - Whether the environment is development/test; defaults to `false`
116
+ * @param {string} options.supabaseUrl - The URL of the Supabase instance; defaults to `env.PUBLIC_SUPABASE_URL`
117
+ * @returns An object with `success` if the OTP verification succeeded, or an `error` if any occurred
118
+ */
119
+ verifyOtp: (supabase: SupabaseClient<Database>, phone: string, code: string, options: {
120
+ dev: boolean;
121
+ supabaseUrl: string;
122
+ }) => Promise<{
123
+ twilioSid?: string;
124
+ error?: CustomError;
125
+ }>;
126
+ };
package/dist/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Status } from "./general.js";
2
+ import { Resource } from "./platform.js";
2
3
  import { error as svelteError } from "@sveltejs/kit";
3
4
  import { FunctionsFetchError, FunctionsHttpError, FunctionsRelayError } from "@supabase/supabase-js";
4
5
  /* INSTANCE HELPERS */
@@ -100,7 +101,7 @@ export const Instance = {
100
101
  * @param {Object} options - The options for the function.
101
102
  * @param {MicroserviceGroup} options.microserviceGroup - The microservice group.
102
103
  * @param {Microservice<MicroserviceGroup> | string} options.microservice - The microservice.
103
- * @param {string} options.playersUrl - The URL to the Players microservice.
104
+ * @param {string} options.playersUrl - The URL to the Players microservice; include only if `microserviceGroup` is not `players`.
104
105
  * @param {(role: string) => string} options.requestedPermission - A function that returns the requested permission for a given role.
105
106
  * @returns {Promise<{ isAuthorised?: boolean; redirect?: { code: number; url: string }; error?: CustomError }>} The result of the function.
106
107
  */
@@ -113,6 +114,8 @@ export const Instance = {
113
114
  // Validate user metadata and app metadata
114
115
  if (!user.user_metadata || !user.app_metadata)
115
116
  return { error: Exception.customError(400, "User metadata is required.") };
117
+ if (!Array.isArray(user.app_metadata.roles))
118
+ return { error: Exception.customError(400, "User roles are required.") };
116
119
  // Get user's roles and the role prefix
117
120
  const roles = [];
118
121
  const rolePrefix = microserviceGroup === "backroom" ? "backroom" : microserviceGroup.slice(0, -1);
@@ -134,30 +137,33 @@ export const Instance = {
134
137
  // Redirect to Players microservices
135
138
  return { redirect: { code: 307, url: playersUrl } };
136
139
  }
137
- // Destructure role
138
- const [role] = roles;
139
- // Define condition variables
140
- const isPlayer = microserviceGroup === "players" && role === "player";
141
- const isSuperuser = (microserviceGroup === "backroom" || microserviceGroup === "lounges") && role === "superuser";
142
- let isAuthorised = false;
143
140
  // Grant access if Player (accessing Players microservices), superuser, or account microservice
144
- if (isPlayer || isSuperuser || microservice === "account")
145
- isAuthorised = true;
146
- // Evaluate access
147
- else if (microserviceGroup !== "players") {
141
+ let isAuthorised = (microserviceGroup === "players" && roles.includes("player")) ||
142
+ ((microserviceGroup === "backroom" || microserviceGroup === "lounges") && roles.includes("superuser")) ||
143
+ microservice === "account" ||
144
+ false;
145
+ // Evaluate access for each role until one grants permission
146
+ if (!isAuthorised) {
147
+ // Return false if not a Players microservice; no permissions to check
148
+ if (microserviceGroup === "players")
149
+ return { isAuthorised };
148
150
  // Return error if requested permission is not provided
149
151
  if (!options.requestedPermission)
150
152
  return { error: Exception.customError(400, "Requested permission is required.") };
151
- // Get user's role and requested app permission
152
- const requestedPermission = options.requestedPermission(role);
153
- // Check if user has the requested permission
154
- const { data, error: isAuthorisedError } = await supabase
155
- .schema("compliance")
156
- .rpc("authorise", { requested_permission: requestedPermission });
157
- if (isAuthorisedError)
158
- return Exception.parsePostgrestError(isAuthorisedError);
159
- // Assign result of authorisation check
160
- isAuthorised = data;
153
+ for (const role of roles) {
154
+ if (role == null)
155
+ continue;
156
+ const requestedPermission = options.requestedPermission(role);
157
+ const { data, error: isAuthorisedError } = await supabase
158
+ .schema("compliance")
159
+ .rpc("authorise", { requested_permission: requestedPermission });
160
+ if (isAuthorisedError)
161
+ return Exception.parsePostgrestError(isAuthorisedError);
162
+ if (data) {
163
+ isAuthorised = true;
164
+ break;
165
+ }
166
+ }
161
167
  }
162
168
  // If user has completed Player registration
163
169
  if (roles.includes("player")) {
@@ -165,10 +171,16 @@ export const Instance = {
165
171
  let personData = {};
166
172
  if (user.app_metadata.person_data) {
167
173
  const { first_name: firstName, last_name: lastName, pseudonym } = user.app_metadata.person_data;
168
- personData = { firstName, lastName, phone: user.phone, pseudonym };
174
+ personData = { firstName, lastName, pseudonym };
175
+ if (user.phone != null)
176
+ personData.phone = user.phone;
169
177
  }
170
178
  // Validate SuprSend subscriber
171
- const { code, error: validationError } = await Instance.callEdgeFunction(false, supabase, `suprsend/validate-subscriber?user-id=${user.id}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: personData });
179
+ const { code, error: validationError } = await Instance.callEdgeFunction(false, supabase, `suprsend/validate-subscriber?user-id=${user.id}`, {
180
+ method: "POST",
181
+ headers: { "Content-Type": "application/json" },
182
+ body: personData
183
+ });
172
184
  if (validationError)
173
185
  return { error: Exception.customError(code, validationError) };
174
186
  // Send welcome notification if not already sent
@@ -201,7 +213,9 @@ export const Instance = {
201
213
  }
202
214
  });
203
215
  if (updateError)
204
- return { error: Exception.customError(updateError.status ?? 500, updateError.message) };
216
+ return {
217
+ error: Exception.customError(updateError.status ?? 500, updateError.message)
218
+ };
205
219
  // Only send notification if flag was successfully updated
206
220
  const updatedBackroom = updatedUser.user?.user_metadata?.backroom;
207
221
  if (updatedBackroom?.welcome_notification_sent === true &&
@@ -219,7 +233,11 @@ export const Instance = {
219
233
  ]
220
234
  };
221
235
  // Send welcome notification
222
- const { code, error: workflowError } = await Instance.callEdgeFunction(false, supabase, `suprsend/trigger-workflow?user-id=${user.id}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: workflowBody });
236
+ const { code, error: workflowError } = await Instance.callEdgeFunction(false, supabase, `suprsend/trigger-workflow?user-id=${user.id}`, {
237
+ method: "POST",
238
+ headers: { "Content-Type": "application/json" },
239
+ body: workflowBody
240
+ });
223
241
  // If notification fails, revert the flag and return error
224
242
  if (workflowError) {
225
243
  await supabase.auth.updateUser({
@@ -252,10 +270,27 @@ export const Exception = {
252
270
  customError: (code, message) => !code || code < 400 || code > 599 ? { code: 500, message: Status.ERROR } : { code, message },
253
271
  /**
254
272
  * Parses a `CustomError` object within a page/layout server load function and returns an error to be thrown.
255
- * @param {CustomError} error - The error object
273
+ * @param {CustomError | PostgrestError | null} error - The error object
256
274
  * @returns A new `Error` object if the error is unexpected, or a Svelte error otherwise
257
275
  */
258
- parseLoadError: (error) => error.code === 500 ? new Error(error.message) : svelteError(error.code, { message: error.message }),
276
+ parseLoadError: (error) => {
277
+ // Return generic error if no error was provided
278
+ if (!error)
279
+ return new Error(Status.ERROR);
280
+ // Parse Postgrest error if provided
281
+ let parsedError;
282
+ if ("hint" in error)
283
+ ({ error: parsedError } = Exception.parsePostgrestError(error));
284
+ else
285
+ parsedError = error;
286
+ // Treat missing parsed error as generic server error
287
+ if (!parsedError)
288
+ return new Error(Status.ERROR);
289
+ // Return error to be thrown
290
+ if (parsedError.code === 500)
291
+ return new Error(parsedError.message);
292
+ return svelteError(Number(parsedError.code), { message: parsedError.message });
293
+ },
259
294
  /**
260
295
  * Parses a `PostgrestError` object and returns a custom error object if any has occurred.
261
296
  * @param {PostgrestError | null} postgrestError - The `PostgrestError` object, or `null` if no error occurred
@@ -269,7 +304,8 @@ export const Exception = {
269
304
  return { error };
270
305
  // Get custom error code from hint
271
306
  const customErrorCode = Number(postgrestError.hint);
272
- // Clamp 5xx errors down to 422 to prevent Sentry from capturing them as unhandled; see https://koloseum-technologies.sentry.io/issues/6766267685/
307
+ // Clamp 5xx errors down to 422 to prevent Sentry from capturing them as unhandled
308
+ // see https://koloseum-technologies.sentry.io/issues/6766267685/
273
309
  let statusCode = clientSafe ? (customErrorCode >= 500 ? 422 : customErrorCode) : customErrorCode;
274
310
  // Return custom error if hint is a number between 400 and 599
275
311
  if (!isNaN(customErrorCode) && customErrorCode >= 400 && customErrorCode <= 599) {
@@ -320,3 +356,79 @@ export const Exception = {
320
356
  return { error };
321
357
  }
322
358
  };
359
+ /* PHONE HELPERS */
360
+ export const Phone = {
361
+ /**
362
+ * Requests an OTP to be sent to the given phone.
363
+ * Logs the attempt to compliance.phone_verification_attempts via log_phone_verification.
364
+ * @param {SupabaseClient<Database>} supabase - The Supabase client
365
+ * @param {string} phone - The phone number in E.164 format
366
+ * @param {Object} options - The options for the function.
367
+ * @param {boolean} options.dev - Whether the environment is development/test; defaults to `false`
368
+ * @param {string} options.supabaseUrl - The URL of the Supabase instance; defaults to `env.PUBLIC_SUPABASE_URL`
369
+ * @returns An object with `success` if the OTP request succeeded, or an `error` if any occurred
370
+ */
371
+ requestOtp: async (supabase, phone, options) => {
372
+ // Request OTP
373
+ const result = await Instance.callEdgeFunction(false, supabase, `twilio/request-otp?phone=${encodeURIComponent(phone)}`);
374
+ if (result.error)
375
+ return { error: Exception.customError(result.code, result.error) };
376
+ // Destructure options
377
+ const { dev, supabaseUrl } = options;
378
+ // Parse response
379
+ const data = result.data;
380
+ let { dateCreated, dateUpdated } = data;
381
+ if (dev || Resource.isLocalSupabase(supabaseUrl)) {
382
+ dateCreated = new Date().toISOString();
383
+ dateUpdated = new Date().toISOString();
384
+ }
385
+ // Log attempt to database
386
+ const { error } = await supabase.schema("compliance").rpc("log_phone_verification", {
387
+ p_twilio_sid: data.sid,
388
+ p_phone: data.to,
389
+ p_send_code_attempts: data.sendCodeAttempts,
390
+ p_status: data.status,
391
+ p_created_at: new Date(dateCreated),
392
+ p_updated_at: new Date(dateUpdated)
393
+ });
394
+ if (error)
395
+ return Exception.parsePostgrestError(error);
396
+ // Return Twilio SID
397
+ return { twilioSid: data.sid };
398
+ },
399
+ /**
400
+ * Verifies an OTP code.
401
+ * Updates the attempt in compliance.phone_verification_attempts via log_phone_verification.
402
+ * @param {SupabaseClient<Database>} supabase - The Supabase client
403
+ * @param {string} phone - The phone number in E.164 format
404
+ * @param {string} code - The one-time passcode entered by the user
405
+ * @param {Object} options - The options for the function.
406
+ * @param {boolean} options.dev - Whether the environment is development/test; defaults to `false`
407
+ * @param {string} options.supabaseUrl - The URL of the Supabase instance; defaults to `env.PUBLIC_SUPABASE_URL`
408
+ * @returns An object with `success` if the OTP verification succeeded, or an `error` if any occurred
409
+ */
410
+ verifyOtp: async (supabase, phone, code, options) => {
411
+ // Verify OTP
412
+ const result = await Instance.callEdgeFunction(false, supabase, `twilio/verify-otp?phone=${encodeURIComponent(phone)}&code=${encodeURIComponent(code)}`);
413
+ if (result.error)
414
+ return { error: Exception.customError(result.code, result.error) };
415
+ // Destructure options
416
+ const { dev, supabaseUrl } = options;
417
+ // Parse response
418
+ const data = result.data;
419
+ let { dateUpdated } = data;
420
+ if (dev || Resource.isLocalSupabase(supabaseUrl)) {
421
+ dateUpdated = new Date().toISOString();
422
+ }
423
+ // Update attempt in database
424
+ const { error } = await supabase.schema("compliance").rpc("log_phone_verification", {
425
+ p_twilio_sid: data.sid,
426
+ p_status: data.status,
427
+ p_updated_at: new Date(dateUpdated)
428
+ });
429
+ if (error)
430
+ return Exception.parsePostgrestError(error);
431
+ // Return Twilio SID
432
+ return { twilioSid: data.sid };
433
+ }
434
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koloseum/utils",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "author": "Koloseum Technologies Limited",
5
5
  "type": "module",
6
6
  "description": "Utility logic for use across Koloseum web apps (TypeScript)",
@@ -62,7 +62,7 @@
62
62
  "validator": "^13.15.15"
63
63
  },
64
64
  "devDependencies": {
65
- "@koloseum/types": "^0.3.5",
65
+ "@koloseum/types": "^0.3.6",
66
66
  "@playwright/test": "^1.55.0",
67
67
  "@suprsend/web-components": "^0.4.0",
68
68
  "@types/sanitize-html": "^2.16.0",