@koloseum/utils 0.1.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.
package/README.md ADDED
File without changes
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@koloseum/utils",
3
+ "version": "0.1.1",
4
+ "author": "Koloseum Technologies Limited",
5
+ "description": "Utility logic for use across Koloseum web apps (TypeScript)",
6
+ "keywords": [
7
+ "Koloseum"
8
+ ],
9
+ "homepage": "https://github.com/koloseum-technologies/npm-utils#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/koloseum-technologies/npm-utils/issues"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/koloseum-technologies/npm-utils.git"
16
+ },
17
+ "main": "./src/utils.ts",
18
+ "exports": {
19
+ "./src": "./src/utils.ts"
20
+ },
21
+ "files": [
22
+ "./src/**/*.ts"
23
+ ],
24
+ "scripts": {
25
+ "test": "vitest --run"
26
+ },
27
+ "dependencies": {
28
+ "@koloseum/types": "^0.1.0",
29
+ "@supabase/supabase-js": "^2.48.1",
30
+ "@sveltejs/kit": "^2.17.1",
31
+ "any-date-parser": "^2.0.3",
32
+ "sanitize-html": "^2.14.0",
33
+ "validator": "^13.12.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/sanitize-html": "^2.13.0",
37
+ "@types/validator": "^13.12.2",
38
+ "typescript": "^5.0.0"
39
+ }
40
+ }
@@ -0,0 +1,191 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { Status, Utility } from "./utils";
3
+
4
+ describe("Status helper", () => {
5
+ // Destructure the Status object
6
+ const { ERROR, LOADING } = Status;
7
+
8
+ // Test each helper
9
+ it("contains an error message", () => {
10
+ expect(ERROR).toBe("An unexpected error occurred. Kindly try again.");
11
+ });
12
+
13
+ it("contains a loading message", () => {
14
+ expect(LOADING).toBe("Please wait…");
15
+ });
16
+ });
17
+
18
+ describe("Utility helper", () => {
19
+ // Destructure the Utility object
20
+ const {
21
+ capitalise,
22
+ customError,
23
+ formatDate,
24
+ formatSocialMediaPlatform,
25
+ getAge,
26
+ minimumMinorBirthDate,
27
+ parseLoadError,
28
+ parsePostgrestError,
29
+ sanitiseHtml,
30
+ validateE164,
31
+ validateSocialMediaHandle
32
+ } = Utility;
33
+
34
+ // Test each helper
35
+ it("capitalises a word", () => {
36
+ expect(capitalise("word")).toBe("Word");
37
+ expect(capitalise("WORD")).toBe("Word");
38
+ expect(capitalise("wOrD")).toBe("Word");
39
+ });
40
+
41
+ it("generates a custom error", () => {
42
+ expect(customError(200, "OK")).toEqual({ code: 500, message: Status.ERROR });
43
+ expect(customError(400, "Bad Request")).toEqual({ code: 400, message: "Bad Request" });
44
+ expect(customError(500, "Internal Server Error")).toEqual({ code: 500, message: "Internal Server Error" });
45
+ expect(customError(700, "Unknown")).toEqual({ code: 500, message: Status.ERROR });
46
+ });
47
+
48
+ it("formats dates", () => {
49
+ expect(formatDate("31st December 2021")).toBe("31/12/2021");
50
+ expect(formatDate("31st December 2021", true)).toBe("2021-12-31");
51
+ expect(formatDate("31st December 2021", false)).toBe("31/12/2021");
52
+ });
53
+
54
+ it("formats social media platforms", () => {
55
+ expect(formatSocialMediaPlatform("facebook")).toBe("Facebook");
56
+ expect(formatSocialMediaPlatform("instagram")).toBe("Instagram");
57
+ expect(formatSocialMediaPlatform("kick")).toBe("Kick");
58
+ expect(formatSocialMediaPlatform("tiktok")).toBe("TikTok");
59
+ expect(formatSocialMediaPlatform("twitter")).toBe("X (formerly Twitter)");
60
+ expect(formatSocialMediaPlatform("youtube")).toBe("YouTube");
61
+ });
62
+
63
+ it("gets the age of a person", () => {
64
+ // Set system time to 1 January 2024
65
+ vi.useFakeTimers();
66
+ vi.setSystemTime(new Date("2024-01-01"));
67
+
68
+ // Test age calculation
69
+ expect(getAge("2000-01-01")).toBe(24);
70
+ expect(getAge("2000-12-31")).toBe(23);
71
+ expect(getAge("2024-01-03")).toBe(-1);
72
+
73
+ // Reset timers
74
+ vi.useRealTimers();
75
+ });
76
+
77
+ it("gets the minimum birth date for a minor", () => {
78
+ // Calculate expected minimum birth date
79
+ const today = new Date();
80
+ const tomorrow = new Date();
81
+ tomorrow.setUTCDate(today.getDate() + 1);
82
+
83
+ const tomorrow18YearsAgo = tomorrow.getFullYear() - 18;
84
+ tomorrow.setFullYear(tomorrow18YearsAgo);
85
+
86
+ const expectedMinimumMinorBirthDate = tomorrow.toISOString().split("T")[0];
87
+
88
+ // Test minimum birth date calculation
89
+ expect(minimumMinorBirthDate).toBe(expectedMinimumMinorBirthDate);
90
+ });
91
+
92
+ it("parses a SvelteKit load error", () => {
93
+ const error = { code: 500, message: "Internal Server Error" };
94
+ expect(parseLoadError(error)).toEqual(new Error("Internal Server Error"));
95
+ // add assertion to check for SvelteKit `error` function?
96
+ });
97
+
98
+ it("parses a Postgrest error", () => {
99
+ expect(parsePostgrestError(null)).toEqual({ error: undefined });
100
+ expect(
101
+ parsePostgrestError({
102
+ code: "42501",
103
+ name: "insufficient_privilege",
104
+ message: 'Insufficient privileges: permission denied for relation "restricted_table"',
105
+ details: 'User does not have the necessary permissions to access the relation "restricted_table".',
106
+ hint: ""
107
+ })
108
+ ).toEqual({ error: { code: 403, message: Status.ERROR } });
109
+ expect(
110
+ parsePostgrestError({
111
+ code: "42883",
112
+ name: "undefined_function",
113
+ message: "Undefined function: function non_existent_function() does not exist",
114
+ details: "Function non_existent_function() does not exist.",
115
+ hint: "No function matches the given name and argument types. You might need to add explicit type casts."
116
+ })
117
+ ).toEqual({ error: { code: 404, message: Status.ERROR } });
118
+ expect(
119
+ parsePostgrestError({
120
+ code: "25006",
121
+ name: "read_only_sql_transaction",
122
+ message: "Read only SQL transaction: cannot execute INSERT in a read-only transaction",
123
+ details: "Cannot execute INSERT in a read-only transaction.",
124
+ hint: ""
125
+ })
126
+ ).toEqual({ error: { code: 405, message: Status.ERROR } });
127
+ expect(
128
+ parsePostgrestError({
129
+ code: "23505",
130
+ name: "unique_violation",
131
+ message: 'Uniqueness violation: duplicate key value violates unique constraint "users_username_key"',
132
+ details: "Key (username)=(john_doe) already exists.",
133
+ hint: ""
134
+ })
135
+ ).toEqual({ error: { code: 409, message: Status.ERROR } });
136
+ expect(
137
+ parsePostgrestError({
138
+ code: "53400",
139
+ name: "configuration_file_error",
140
+ message: "invalid page header in block 0 of relation base/12345/67890",
141
+ details: "The page header is corrupted. This may indicate hardware or file system issues.",
142
+ hint: ""
143
+ })
144
+ ).toEqual({ error: { code: 500, message: Status.ERROR } });
145
+ expect(
146
+ parsePostgrestError({
147
+ code: "08003",
148
+ name: "connection_does_not_exist",
149
+ message: "connection does not exist",
150
+ details: "The connection to the database was lost or never established.",
151
+ hint: ""
152
+ })
153
+ ).toEqual({ error: { code: 503, message: Status.ERROR } });
154
+ expect(
155
+ parsePostgrestError({
156
+ code: "P0001",
157
+ name: "raise_exception",
158
+ message: "I'm a teapot!",
159
+ details: "Pretty simple",
160
+ hint: "418"
161
+ })
162
+ ).toEqual({ error: { code: 418, message: "I'm a teapot!" } });
163
+ });
164
+
165
+ it("sanitises HTML", () => {
166
+ expect(sanitiseHtml("<script>alert('Hello!');</script>")).toBe("");
167
+ expect(sanitiseHtml("<p>Hello!</p>")).toBe("Hello!");
168
+ expect(sanitiseHtml("<p>Hello!</p><script>alert('Hello!');</script>")).toBe("Hello!");
169
+ });
170
+
171
+ it("validates E.164 phone numbers", () => {
172
+ // Valid phone number
173
+ expect(validateE164("+254712345678")).toBe(true);
174
+ expect(validateE164("+254712345678", "en-KE")).toBe(true);
175
+ expect(validateE164("+254712345678", "en-RW")).toBe(false);
176
+
177
+ // Invalid phone numbers
178
+ expect(validateE164("+254812345678")).toBe(false);
179
+ expect(validateE164("254812345678")).toBe(false);
180
+ expect(validateE164("+2548123456789")).toBe(false);
181
+ expect(validateE164("2548123456789")).toBe(false);
182
+ expect(validateE164("08123456789")).toBe(false);
183
+ });
184
+
185
+ it("validates social media handles", () => {
186
+ expect(validateSocialMediaHandle("username")).toBe(true);
187
+ expect(validateSocialMediaHandle("@username")).toBe(false);
188
+ expect(validateSocialMediaHandle("/username")).toBe(false);
189
+ expect(validateSocialMediaHandle("https://www.facebook.com/username")).toBe(false);
190
+ });
191
+ });
package/src/utils.ts ADDED
@@ -0,0 +1,264 @@
1
+ import type { Database } from "@koloseum/types/database";
2
+ import type { CustomError, SocialMediaPlatform } from "@koloseum/types/public/auth.js";
3
+
4
+ import type { PostgrestError, SupabaseClient } from "@supabase/supabase-js";
5
+ import type { MobilePhoneLocale } from "validator";
6
+
7
+ import { FunctionsFetchError, FunctionsHttpError, FunctionsRelayError } from "@supabase/supabase-js";
8
+ import { error as svelteError } from "@sveltejs/kit";
9
+
10
+ import parser from "any-date-parser";
11
+ import sanitize from "sanitize-html";
12
+ import validator from "validator";
13
+
14
+ /* Helper functions */
15
+ const { isMobilePhone, isURL } = validator;
16
+
17
+ /* Status messages */
18
+ export const Status = {
19
+ /**
20
+ * A generic error message.
21
+ */
22
+ ERROR: "An unexpected error occurred. Kindly try again.",
23
+ /**
24
+ * A generic loading message.
25
+ */
26
+ LOADING: "Please wait…",
27
+ /**
28
+ * A generic password reset request message.
29
+ */
30
+ PASSWORD_RESET_REQUESTED: "If the provided email address is registered, you will receive a password reset link shortly.",
31
+ };
32
+
33
+ /* Utility functions */
34
+ export const Utility = {
35
+ /**
36
+ * Calls an Edge function.
37
+ * @param {SupabaseClient} supabase - The Supabase client
38
+ * @param {string} path - The path to the Edge function
39
+ * @param {"GET" | "POST"} [method="GET"] - The HTTP method to use
40
+ * @returns The response from the Edge function
41
+ */
42
+ callEdgeFunction: async (supabase: SupabaseClient<Database>, path: string, method: "GET" | "POST" = "GET"): Promise<{ data?: any; error?: string }> => {
43
+ // Fetch response
44
+ const { data, error } = await supabase.functions.invoke(path, { method });
45
+
46
+ // Return error if any
47
+ if (error instanceof FunctionsHttpError) return { error: await error.context.json() };
48
+ else if (error instanceof FunctionsRelayError) return { error: error.message };
49
+ else if (error instanceof FunctionsFetchError) return { error: error.message };
50
+
51
+ // Return response
52
+ return { data };
53
+ },
54
+ /**
55
+ * Capitalises a given word.
56
+ * @param {string} word - The word to be capitalised
57
+ * @returns The capitalised word
58
+ */
59
+ capitalise: (word: string) =>
60
+ word.charAt(0).toUpperCase() +
61
+ word
62
+ .slice(1)
63
+ .split("")
64
+ .map((c) => c.toLowerCase())
65
+ .join(""),
66
+ /**
67
+ * Returns a custom error object.
68
+ * @param {number} code - The error code; defaults to `500` if not provided or invalid
69
+ * @param {string} message - The error message
70
+ * @returns An object with the error `code` and `message`
71
+ */
72
+ customError: (code: number | undefined, message: string): CustomError => (!code || code < 400 || code > 599 ? { code: 500, message: Status.ERROR } : { code, message }),
73
+ /**
74
+ * A regular expression for E.164 phone numbers.
75
+ *
76
+ * - Source: https://www.twilio.com/docs/glossary/what-e164
77
+ */
78
+ e164Regex: /^\+?[1-9]\d{1,14}$/,
79
+ /**
80
+ * Formats a date in the format DD/MM/YYYY (by default) or YYYY-MM-DD (for client).
81
+ * @param {string} date - Date to be formatted
82
+ * @param {boolean} forClient - Specify whether the data is for displaying on the client; defaults to `false`
83
+ * @returns The formatted date string, or `null` if invalid input or in case of an error
84
+ */
85
+ formatDate: (date: string, forClient: boolean = false) => {
86
+ if (date === "") return null;
87
+ try {
88
+ const formattedDate = new Intl.DateTimeFormat(forClient ? "fr-CA" : "en-KE", {
89
+ day: "2-digit",
90
+ month: "2-digit",
91
+ year: "numeric",
92
+ // @ts-ignore: .fromString method exists but is not typed in `any-date-parser` package
93
+ }).format(parser.fromString(date));
94
+
95
+ return formattedDate;
96
+ } catch (error) {
97
+ return null;
98
+ }
99
+ },
100
+ /**
101
+ * Formats a social media platform for display.
102
+ * @param {SocialMediaPlatform} platform - The social media platform to be formatted
103
+ * @returns The formatted social media platform string
104
+ */
105
+ formatSocialMediaPlatform: (platform: SocialMediaPlatform) => {
106
+ const platforms = ["facebook", "instagram", "kick", "tiktok", "twitch", "twitter", "youtube"];
107
+ if (!platforms.includes(platform)) return "";
108
+
109
+ if (platform === "tiktok") return "TikTok";
110
+ if (platform === "twitter") return "X (formerly Twitter)";
111
+ if (platform === "youtube") return "YouTube";
112
+
113
+ return Utility.capitalise(platform);
114
+ },
115
+ /**
116
+ * Returns an age in years given a birth date.
117
+ * @param {string} dateOfBirth - The date of birth as a string, e.g. `DD-MM-YYYY`
118
+ * @returns The age in years, or `NaN` if the input is invalid
119
+ */
120
+ getAge: (dateOfBirth: string) => {
121
+ if (dateOfBirth === "" || typeof dateOfBirth !== "string") return NaN;
122
+ dateOfBirth = Utility.formatDate(dateOfBirth, true) as string;
123
+
124
+ const birthDate = new Date(dateOfBirth);
125
+ const currentDate = new Date();
126
+
127
+ let age = currentDate.getFullYear() - birthDate.getFullYear();
128
+ const monthDiff = currentDate.getMonth() - birthDate.getMonth();
129
+
130
+ return monthDiff < 0 || (monthDiff === 0 && currentDate.getDate() < birthDate.getDate()) ? --age : age;
131
+ },
132
+ /**
133
+ * A regular expression for Koloseum Lounge Branch IDs. It covers the following rules:
134
+ * - 9 characters long
135
+ * - begins with "KLB" followed by 7 digits
136
+ */
137
+ loungeBranchIdRegex: /^KLB\d{7}$/,
138
+ /**
139
+ * A regular expression for Koloseum Lounge IDs. It covers the following rules:
140
+ * - 9 characters long
141
+ * - begins with "KL" followed by 7 digits
142
+ */
143
+ loungeIdRegex: /^KL\d{7}$/,
144
+ /**
145
+ * The minimum birth date (from today's date) for a user who is a minor, i.e. `YYYY-MM-DD`
146
+ */
147
+ minimumMinorBirthDate: (() => {
148
+ const today = new Date();
149
+ const tomorrow = new Date();
150
+ tomorrow.setUTCDate(today.getDate() + 1);
151
+
152
+ const tomorrow18YearsAgo = tomorrow.getFullYear() - 18;
153
+ tomorrow.setFullYear(tomorrow18YearsAgo);
154
+
155
+ return tomorrow.toISOString().split("T")[0];
156
+ })(),
157
+ /**
158
+ * Parses a `CustomError` object within a page/layout server load function and returns an error to be thrown.
159
+ * @param {CustomError} error - The error object
160
+ * @returns A new `Error` object if the error is unexpected, or a Svelte error otherwise
161
+ */
162
+ parseLoadError: (error: CustomError) => (error.code === 500 ? new Error(error.message) : svelteError(error.code, { message: error.message })),
163
+ /**
164
+ * Parses a `PostgrestError` object and returns a custom error object if any has occurred.
165
+ * @param {PostgrestError | null} postgrestError - The `PostgrestError` object, or `null` if no error occurred
166
+ * @returns An object with an `error` if any has occurred
167
+ */
168
+ parsePostgrestError: (postgrestError: PostgrestError | null): { error?: CustomError } => {
169
+ // Return undefined if no error occurred
170
+ let error: CustomError | undefined;
171
+ if (!postgrestError) return { error };
172
+
173
+ // Return custom error if hint is a number between 400 and 599
174
+ const customErrorCode = Number(postgrestError.hint);
175
+ if (!isNaN(customErrorCode) && customErrorCode >= 400 && customErrorCode <= 599) {
176
+ error = Utility.customError(customErrorCode, postgrestError.message);
177
+ return { error };
178
+ }
179
+
180
+ // Map Postgrest error codes to custom error codes
181
+ const errorMap = [
182
+ { code: "08", status: 503 },
183
+ { code: "09", status: 500 },
184
+ { code: "0L", status: 403 },
185
+ { code: "0P", status: 403 },
186
+ { code: "23503", status: 409 },
187
+ { code: "23505", status: 409 },
188
+ { code: "25006", status: 405 },
189
+ { code: "25", status: 500 },
190
+ { code: "28", status: 403 },
191
+ { code: "2D", status: 500 },
192
+ { code: "38", status: 500 },
193
+ { code: "39", status: 500 },
194
+ { code: "3B", status: 500 },
195
+ { code: "40", status: 500 },
196
+ { code: "53400", status: 500 },
197
+ { code: "53", status: 503 },
198
+ { code: "54", status: 500 },
199
+ { code: "55", status: 500 },
200
+ { code: "57", status: 500 },
201
+ { code: "58", status: 500 },
202
+ { code: "F0", status: 500 },
203
+ { code: "HV", status: 500 },
204
+ { code: "P0001", status: 400 },
205
+ { code: "P0", status: 500 },
206
+ { code: "XX", status: 500 },
207
+ { code: "42883", status: 404 },
208
+ { code: "42P01", status: 404 },
209
+ { code: "42P17", status: 500 },
210
+ { code: "42501", status: 403 },
211
+ ];
212
+
213
+ // Return custom error if Postgrest error code is found
214
+ for (const { code, status } of errorMap)
215
+ if (postgrestError.code === code || postgrestError.code.startsWith(code)) {
216
+ error = Utility.customError(status, Status.ERROR);
217
+ return { error };
218
+ }
219
+
220
+ // Return generic error
221
+ error = Utility.customError(500, Status.ERROR);
222
+ return { error };
223
+ },
224
+ /**
225
+ * A regular expression for user passwords to match during authentication. It covers the following rules:
226
+ * - at least 8 characters long
227
+ * - at least one digit
228
+ * - at least one lowercase letter
229
+ * - at least one uppercase letter
230
+ * - at least one symbol
231
+ */
232
+ passwordRegex: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*()-_+=|{}[\]:;'<>,.?/~]).{8,}$/,
233
+ /**
234
+ * A regular expression for Koloseum Player IDs. It covers the following rules:
235
+ * - 9 characters long
236
+ * - begins with "KP" followed by 7 digits
237
+ */
238
+ playerIdRegex: /^KP\d{7}$/,
239
+ /**
240
+ * Sanitises any potential HTML injected into user input.
241
+ * @param {string} input - The input to be sanitised
242
+ * @returns A sanitised string, or an empty string if the input is invalid
243
+ */
244
+ sanitiseHtml: (input: string) => (typeof input !== "string" ? "" : sanitize(input, { allowedTags: [], allowedAttributes: {} })),
245
+ /**
246
+ * A regular expression for social media handles, without a leading slash or @ character.
247
+ *
248
+ * - Source: https://stackoverflow.com/a/74579651
249
+ */
250
+ socialMediaHandleRegex: /^([A-Za-z0-9_.]{3,25})$/gm,
251
+ /**
252
+ * Validates an E.164 phone number.
253
+ * @param {string} phoneNumber - The phone number to be validated
254
+ * @param {"any" | MobilePhoneLocale | MobilePhoneLocale[]} [locale="any"] - The locale(s) to validate the phone number against
255
+ * @returns `true` if the phone number is valid; `false` otherwise
256
+ */
257
+ validateE164: (phoneNumber: string, locale: "any" | MobilePhoneLocale | MobilePhoneLocale[] = "en-KE") => isMobilePhone(phoneNumber, locale) && Boolean(phoneNumber.match(Utility.e164Regex)),
258
+ /**
259
+ * Validates a social media handle.
260
+ * @param {string} handle - The social media handle to be validated
261
+ * @returns `true` if the handle is valid; `false` otherwise
262
+ */
263
+ validateSocialMediaHandle: (handle: string) => !isURL(handle, { require_protocol: false }) && !handle.startsWith("@") && Boolean(handle.match(Utility.socialMediaHandleRegex)),
264
+ };