@koloseum/utils 0.3.6 → 0.3.8
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 +9 -2
- package/dist/formatting.d.ts +26 -10
- package/dist/formatting.js +90 -20
- package/dist/platform.js +9 -8
- package/dist/server.d.ts +41 -4
- package/dist/server.js +131 -42
- package/package.json +2 -2
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
|
|
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) {
|
package/dist/formatting.d.ts
CHANGED
|
@@ -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
|
|
18
|
-
* @param {number | null | undefined} cents - The
|
|
19
|
-
* @
|
|
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
|
-
|
|
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
|
-
* @
|
|
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 -
|
|
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
|
package/dist/formatting.js
CHANGED
|
@@ -22,21 +22,22 @@ export const Transform = {
|
|
|
22
22
|
*/
|
|
23
23
|
cleanUrl: (url) => url.replace(/^https?:\/\//, "").replace(/\/+$/, ""),
|
|
24
24
|
/**
|
|
25
|
-
* Formats
|
|
26
|
-
* @param {number | null | undefined} cents - The
|
|
27
|
-
* @
|
|
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
|
-
|
|
30
|
-
// Return placeholder if no
|
|
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
|
|
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:
|
|
39
|
-
maximumFractionDigits:
|
|
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
|
-
* @
|
|
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
|
|
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
|
|
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
|
|
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 -
|
|
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
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
474
|
+
slug: "events",
|
|
476
475
|
featureSlugs: ["dashboard", "events"]
|
|
477
476
|
},
|
|
478
477
|
{
|
|
479
478
|
name: "Game Exchange",
|
|
480
|
-
slug: "
|
|
481
|
-
featureSlugs: ["dashboard", "
|
|
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 {
|
|
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,47 +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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// Prepare person data
|
|
165
|
-
let personData = {};
|
|
166
|
-
if (user.app_metadata.person_data) {
|
|
167
|
-
const { first_name: firstName, last_name: lastName, pseudonym } = user.app_metadata.person_data;
|
|
168
|
-
personData = { firstName, lastName, phone: user.phone, pseudonym };
|
|
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
|
+
}
|
|
169
166
|
}
|
|
170
|
-
// 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 });
|
|
172
|
-
if (validationError)
|
|
173
|
-
return { error: Exception.customError(code, validationError) };
|
|
174
|
-
// Send welcome notification if not already sent
|
|
175
|
-
const { error: welcomeError } = await Instance.sendWelcomeNotification(supabase, user, microserviceGroup);
|
|
176
|
-
if (welcomeError)
|
|
177
|
-
return { error: welcomeError };
|
|
178
167
|
}
|
|
179
168
|
// Return result
|
|
180
169
|
return { isAuthorised };
|
|
@@ -201,7 +190,9 @@ export const Instance = {
|
|
|
201
190
|
}
|
|
202
191
|
});
|
|
203
192
|
if (updateError)
|
|
204
|
-
return {
|
|
193
|
+
return {
|
|
194
|
+
error: Exception.customError(updateError.status ?? 500, updateError.message)
|
|
195
|
+
};
|
|
205
196
|
// Only send notification if flag was successfully updated
|
|
206
197
|
const updatedBackroom = updatedUser.user?.user_metadata?.backroom;
|
|
207
198
|
if (updatedBackroom?.welcome_notification_sent === true &&
|
|
@@ -219,7 +210,11 @@ export const Instance = {
|
|
|
219
210
|
]
|
|
220
211
|
};
|
|
221
212
|
// Send welcome notification
|
|
222
|
-
const { code, error: workflowError } = await Instance.callEdgeFunction(false, supabase, `suprsend/trigger-workflow?user-id=${user.id}`, {
|
|
213
|
+
const { code, error: workflowError } = await Instance.callEdgeFunction(false, supabase, `suprsend/trigger-workflow?user-id=${user.id}`, {
|
|
214
|
+
method: "POST",
|
|
215
|
+
headers: { "Content-Type": "application/json" },
|
|
216
|
+
body: workflowBody
|
|
217
|
+
});
|
|
223
218
|
// If notification fails, revert the flag and return error
|
|
224
219
|
if (workflowError) {
|
|
225
220
|
await supabase.auth.updateUser({
|
|
@@ -252,10 +247,27 @@ export const Exception = {
|
|
|
252
247
|
customError: (code, message) => !code || code < 400 || code > 599 ? { code: 500, message: Status.ERROR } : { code, message },
|
|
253
248
|
/**
|
|
254
249
|
* 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
|
|
250
|
+
* @param {CustomError | PostgrestError | null} error - The error object
|
|
256
251
|
* @returns A new `Error` object if the error is unexpected, or a Svelte error otherwise
|
|
257
252
|
*/
|
|
258
|
-
parseLoadError: (error) =>
|
|
253
|
+
parseLoadError: (error) => {
|
|
254
|
+
// Return generic error if no error was provided
|
|
255
|
+
if (!error)
|
|
256
|
+
return new Error(Status.ERROR);
|
|
257
|
+
// Parse Postgrest error if provided
|
|
258
|
+
let parsedError;
|
|
259
|
+
if ("hint" in error)
|
|
260
|
+
({ error: parsedError } = Exception.parsePostgrestError(error));
|
|
261
|
+
else
|
|
262
|
+
parsedError = error;
|
|
263
|
+
// Treat missing parsed error as generic server error
|
|
264
|
+
if (!parsedError)
|
|
265
|
+
return new Error(Status.ERROR);
|
|
266
|
+
// Return error to be thrown
|
|
267
|
+
if (parsedError.code === 500)
|
|
268
|
+
return new Error(parsedError.message);
|
|
269
|
+
return svelteError(Number(parsedError.code), { message: parsedError.message });
|
|
270
|
+
},
|
|
259
271
|
/**
|
|
260
272
|
* Parses a `PostgrestError` object and returns a custom error object if any has occurred.
|
|
261
273
|
* @param {PostgrestError | null} postgrestError - The `PostgrestError` object, or `null` if no error occurred
|
|
@@ -269,7 +281,8 @@ export const Exception = {
|
|
|
269
281
|
return { error };
|
|
270
282
|
// Get custom error code from hint
|
|
271
283
|
const customErrorCode = Number(postgrestError.hint);
|
|
272
|
-
// Clamp 5xx errors down to 422 to prevent Sentry from capturing them as unhandled
|
|
284
|
+
// Clamp 5xx errors down to 422 to prevent Sentry from capturing them as unhandled
|
|
285
|
+
// see https://koloseum-technologies.sentry.io/issues/6766267685/
|
|
273
286
|
let statusCode = clientSafe ? (customErrorCode >= 500 ? 422 : customErrorCode) : customErrorCode;
|
|
274
287
|
// Return custom error if hint is a number between 400 and 599
|
|
275
288
|
if (!isNaN(customErrorCode) && customErrorCode >= 400 && customErrorCode <= 599) {
|
|
@@ -320,3 +333,79 @@ export const Exception = {
|
|
|
320
333
|
return { error };
|
|
321
334
|
}
|
|
322
335
|
};
|
|
336
|
+
/* PHONE HELPERS */
|
|
337
|
+
export const Phone = {
|
|
338
|
+
/**
|
|
339
|
+
* Requests an OTP to be sent to the given phone.
|
|
340
|
+
* Logs the attempt to compliance.phone_verification_attempts via log_phone_verification.
|
|
341
|
+
* @param {SupabaseClient<Database>} supabase - The Supabase client
|
|
342
|
+
* @param {string} phone - The phone number in E.164 format
|
|
343
|
+
* @param {Object} options - The options for the function.
|
|
344
|
+
* @param {boolean} options.dev - Whether the environment is development/test; defaults to `false`
|
|
345
|
+
* @param {string} options.supabaseUrl - The URL of the Supabase instance; defaults to `env.PUBLIC_SUPABASE_URL`
|
|
346
|
+
* @returns An object with `success` if the OTP request succeeded, or an `error` if any occurred
|
|
347
|
+
*/
|
|
348
|
+
requestOtp: async (supabase, phone, options) => {
|
|
349
|
+
// Request OTP
|
|
350
|
+
const result = await Instance.callEdgeFunction(false, supabase, `twilio/request-otp?phone=${encodeURIComponent(phone)}`);
|
|
351
|
+
if (result.error)
|
|
352
|
+
return { error: Exception.customError(result.code, result.error) };
|
|
353
|
+
// Destructure options
|
|
354
|
+
const { dev, supabaseUrl } = options;
|
|
355
|
+
// Parse response
|
|
356
|
+
const data = result.data;
|
|
357
|
+
let { dateCreated, dateUpdated } = data;
|
|
358
|
+
if (dev || Resource.isLocalSupabase(supabaseUrl)) {
|
|
359
|
+
dateCreated = new Date().toISOString();
|
|
360
|
+
dateUpdated = new Date().toISOString();
|
|
361
|
+
}
|
|
362
|
+
// Log attempt to database
|
|
363
|
+
const { error } = await supabase.schema("compliance").rpc("log_phone_verification", {
|
|
364
|
+
p_twilio_sid: data.sid,
|
|
365
|
+
p_phone: data.to,
|
|
366
|
+
p_send_code_attempts: data.sendCodeAttempts,
|
|
367
|
+
p_status: data.status,
|
|
368
|
+
p_created_at: new Date(dateCreated),
|
|
369
|
+
p_updated_at: new Date(dateUpdated)
|
|
370
|
+
});
|
|
371
|
+
if (error)
|
|
372
|
+
return Exception.parsePostgrestError(error);
|
|
373
|
+
// Return Twilio SID
|
|
374
|
+
return { twilioSid: data.sid };
|
|
375
|
+
},
|
|
376
|
+
/**
|
|
377
|
+
* Verifies an OTP code.
|
|
378
|
+
* Updates the attempt in compliance.phone_verification_attempts via log_phone_verification.
|
|
379
|
+
* @param {SupabaseClient<Database>} supabase - The Supabase client
|
|
380
|
+
* @param {string} phone - The phone number in E.164 format
|
|
381
|
+
* @param {string} code - The one-time passcode entered by the user
|
|
382
|
+
* @param {Object} options - The options for the function.
|
|
383
|
+
* @param {boolean} options.dev - Whether the environment is development/test; defaults to `false`
|
|
384
|
+
* @param {string} options.supabaseUrl - The URL of the Supabase instance; defaults to `env.PUBLIC_SUPABASE_URL`
|
|
385
|
+
* @returns An object with `success` if the OTP verification succeeded, or an `error` if any occurred
|
|
386
|
+
*/
|
|
387
|
+
verifyOtp: async (supabase, phone, code, options) => {
|
|
388
|
+
// Verify OTP
|
|
389
|
+
const result = await Instance.callEdgeFunction(false, supabase, `twilio/verify-otp?phone=${encodeURIComponent(phone)}&code=${encodeURIComponent(code)}`);
|
|
390
|
+
if (result.error)
|
|
391
|
+
return { error: Exception.customError(result.code, result.error) };
|
|
392
|
+
// Destructure options
|
|
393
|
+
const { dev, supabaseUrl } = options;
|
|
394
|
+
// Parse response
|
|
395
|
+
const data = result.data;
|
|
396
|
+
let { dateUpdated } = data;
|
|
397
|
+
if (dev || Resource.isLocalSupabase(supabaseUrl)) {
|
|
398
|
+
dateUpdated = new Date().toISOString();
|
|
399
|
+
}
|
|
400
|
+
// Update attempt in database
|
|
401
|
+
const { error } = await supabase.schema("compliance").rpc("log_phone_verification", {
|
|
402
|
+
p_twilio_sid: data.sid,
|
|
403
|
+
p_status: data.status,
|
|
404
|
+
p_updated_at: new Date(dateUpdated)
|
|
405
|
+
});
|
|
406
|
+
if (error)
|
|
407
|
+
return Exception.parsePostgrestError(error);
|
|
408
|
+
// Return Twilio SID
|
|
409
|
+
return { twilioSid: data.sid };
|
|
410
|
+
}
|
|
411
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koloseum/utils",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
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.
|
|
65
|
+
"@koloseum/types": "^0.3.7",
|
|
66
66
|
"@playwright/test": "^1.55.0",
|
|
67
67
|
"@suprsend/web-components": "^0.4.0",
|
|
68
68
|
"@types/sanitize-html": "^2.16.0",
|