Repository: achievements-app/psn-api
Pull request #199: feat(user): add getUserRegion function to retrieve user region
Author: login: Dev-R
Labels: name: released
Created: 2025-05-11T01:33:37Z | Merged: 2025-05-22T22:55:35Z

This PR resolves https://github.com/achievements-app/psn-api/issues/191.


Diff:
diff --git a/README.md b/README.md
index 79d970a..8aa11e9 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,7 @@ Click the function names to open their complete docs on the docs site.
   of `accountId` values present on a target account's friends list.
 - [`getBasicPresence()`](https://psn-api.achievements.app/api-docs/users#getbasicpresence) - Get a user's basic presence
   information.
+- [`getUserRegion()`](https://psn-api.achievements.app/api-docs/users#getuserregion) - Get a user's region information based on their username.
 
 ### Trophies
 
diff --git a/src/models/index.ts b/src/models/index.ts
index 2189eed..a6b94a6 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -23,6 +23,7 @@ export * from "./trophy-type.model";
 export * from "./universal-search-domains.model";
 export * from "./universal-search-response.model";
 export * from "./user-played-games-response.model";
+export * from "./user-region-info.model";
 export * from "./user-thin-trophy.model";
 export * from "./user-titles-response.model";
 export * from "./user-trophies-earned-for-title-response.model";
diff --git a/src/models/user-region-info.model.ts b/src/models/user-region-info.model.ts
new file mode 100644
index 0000000..4d56a59
--- /dev/null
+++ b/src/models/user-region-info.model.ts
@@ -0,0 +1,25 @@
+/**
+ * Represents region information for a PlayStation Network user.
+ */
+export interface UserRegionInfo {
+  /**
+   * The two-letter country code (ISO 3166-1 alpha-2)
+   *
+   * @example "US"
+   */
+  code: RegionCode;
+
+  /**
+   * The full name of the country in English
+   *
+   * @example "United States"
+   */
+  name?: string;
+}
+
+/**
+ * Represents a two-letter uppercase ISO 3166-1 alpha-2 region code.
+ *
+ * @example "US"
+ */
+export type RegionCode = `${Uppercase<string>}${Uppercase<string>}`;
diff --git a/src/user/USER_BASE_URL.ts b/src/user/USER_BASE_URL.ts
index 364bf15..1ef85a6 100644
--- a/src/user/USER_BASE_URL.ts
+++ b/src/user/USER_BASE_URL.ts
@@ -3,3 +3,6 @@ export const USER_BASE_URL =
 
 export const USER_GAMES_BASE_URL =
   "https://m.np.playstation.com/api/gamelist/v2/users";
+
+export const USER_LEGACY_BASE_URL =
+  "https://us-prof.np.community.playstation.net/userProfile/v1/users";
diff --git a/src/user/getProfileFromUserName.test.ts b/src/user/getProfileFromUserName.test.ts
index 5215d03..e6ecab3 100644
--- a/src/user/getProfileFromUserName.test.ts
+++ b/src/user/getProfileFromUserName.test.ts
@@ -5,6 +5,7 @@ import type {
   ProfileFromUserNameResponse
 } from "../models";
 import { getProfileFromUserName } from "./getProfileFromUserName";
+import { USER_LEGACY_BASE_URL } from "./USER_BASE_URL";
 
 describe("Function: getProfileFromUserName", () => {
   afterEach(() => {
@@ -53,9 +54,12 @@ describe("Function: getProfileFromUserName", () => {
         consoleAvailability: { availabilityStatus: "offline" }
       }
     };
+    const baseUrlObj = new URL(USER_LEGACY_BASE_URL);
+    const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`;
+    const basePath = baseUrlObj.pathname;
 
-    nock("https://us-prof.np.community.playstation.net")
-      .get("/userProfile/v1/users/xelnia/profile2")
+    nock(baseUrl)
+      .get(`${basePath}/xelnia/profile2`)
       .query(true)
       .reply(200, mockResponse);
 
@@ -75,9 +79,12 @@ describe("Function: getProfileFromUserName", () => {
     const mockResponse = {
       error: { code: 2_105_356, message: "User not found (user: 'xeln12ia')" }
     };
+    const baseUrlObj = new URL(USER_LEGACY_BASE_URL);
+    const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`;
+    const basePath = baseUrlObj.pathname;
 
-    nock("https://us-prof.np.community.playstation.net")
-      .get("/userProfile/v1/users/xeln12ia/profile2")
+    nock(baseUrl)
+      .get(`${basePath}/xeln12ia/profile2`)
       .query(true)
       .reply(200, mockResponse);
 
diff --git a/src/user/getProfileFromUserName.ts b/src/user/getProfileFromUserName.ts
index 4d1d6e4..d0785a2 100644
--- a/src/user/getProfileFromUserName.ts
+++ b/src/user/getProfileFromUserName.ts
@@ -2,7 +2,9 @@ import type {
   AuthorizationPayload,
   ProfileFromUserNameResponse
 } from "../models";
+import { buildRequestUrl } from "../utils/buildRequestUrl";
 import { call } from "../utils/call";
+import { USER_LEGACY_BASE_URL } from "./USER_BASE_URL";
 
 /**
  * A call to this function will retrieve the profile of the username being requested.
@@ -21,7 +23,33 @@ export const getProfileFromUserName = async (
   authorization: AuthorizationPayload,
   userName: string
 ): Promise<ProfileFromUserNameResponse> => {
-  const url = `https://us-prof.np.community.playstation.net/userProfile/v1/users/${userName}/profile2?fields=npId,onlineId,accountId,avatarUrls,plus,aboutMe,languagesUsed,trophySummary(@default,level,progress,earnedTrophies),isOfficiallyVerified,personalDetail(@default,profilePictureUrls),personalDetailSharing,personalDetailSharingRequestMessageFlag,primaryOnlineStatus,presences(@default,@titleInfo,platform,lastOnlineDate,hasBroadcastData),requestMessageFlag,blocking,friendRelation,following,consoleAvailability`;
+  const basicFields =
+    "npId,onlineId,accountId,avatarUrls,plus,aboutMe,languagesUsed";
+  const trophyFields = "trophySummary(@default,level,progress,earnedTrophies)";
+  const profileFields =
+    "isOfficiallyVerified,personalDetail(@default,profilePictureUrls),personalDetailSharing,personalDetailSharingRequestMessageFlag";
+  const statusFields =
+    "primaryOnlineStatus,presences(@default,@titleInfo,platform,lastOnlineDate,hasBroadcastData)";
+  const socialFields =
+    "requestMessageFlag,blocking,friendRelation,following,consoleAvailability";
+
+  const fields = [
+    basicFields,
+    trophyFields,
+    profileFields,
+    statusFields,
+    socialFields
+  ].join(",");
+
+  const url = buildRequestUrl(
+    USER_LEGACY_BASE_URL,
+    ":userName/profile2",
+    {},
+    {
+      userName,
+      fields
+    }
+  );
 
   const response = await call<ProfileFromUserNameResponse>(
     { url },
diff --git a/src/user/getUserRegion.test.ts b/src/user/getUserRegion.test.ts
new file mode 100644
index 0000000..85220ae
--- /dev/null
+++ b/src/user/getUserRegion.test.ts
@@ -0,0 +1,96 @@
+import nock from "nock";
+
+import type {
+  AuthorizationPayload,
+  ProfileFromUserNameResponse
+} from "../models";
+import { getUserRegion } from "./getUserRegion";
+import { USER_LEGACY_BASE_URL } from "./USER_BASE_URL";
+
+describe("Function: getUserRegion", () => {
+  afterEach(() => {
+    nock.cleanAll();
+  });
+
+  it("is defined #sanity", () => {
+    // ASSERT
+    expect(getUserRegion).toBeDefined();
+  });
+
+  it("returns the region for a given username", async () => {
+    // ARRANGE
+    const mockAuthorization: AuthorizationPayload = {
+      accessToken: "mockAccessToken"
+    };
+
+    const mockResponse: ProfileFromUserNameResponse = {
+      profile: {
+        onlineId: "xelnia",
+        accountId: "asdf",
+        npId: "eGVsbmlhQGM2LnVz", // This decodes to xelnia@c6.us
+        avatarUrls: [],
+        plus: 1,
+        aboutMe: "",
+        languagesUsed: ["en"],
+        trophySummary: {
+          level: 421,
+          progress: 53,
+          earnedTrophies: { bronze: 1, silver: 2, gold: 3, platinum: 4 }
+        },
+        isOfficiallyVerified: false,
+        personalDetail: {
+          firstName: "asdf",
+          lastName: "asdf",
+          profilePictureUrls: []
+        },
+        personalDetailSharing: "shared",
+        personalDetailSharingRequestMessageFlag: false,
+        primaryOnlineStatus: "offline",
+        presences: [],
+        friendRelation: "friend",
+        requestMessageFlag: false,
+        blocking: false,
+        following: true,
+        consoleAvailability: { availabilityStatus: "offline" }
+      }
+    }; // Extract the base URL and path from USER_LEGACY_BASE_URL
+    const baseUrlObj = new URL(USER_LEGACY_BASE_URL);
+    const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`;
+    const basePath = baseUrlObj.pathname;
+
+    nock(baseUrl)
+      .get(`${basePath}/xelnia/profile2`)
+      .query(true)
+      .reply(200, mockResponse);
+
+    // ACT
+    const region = await getUserRegion(mockAuthorization, "xelnia");
+
+    // ASSERT
+    expect(region?.code).toEqual("US");
+  });
+
+  it("throws an error if there's a problem with the request", async () => {
+    // ARRANGE
+    const mockAuthorization: AuthorizationPayload = {
+      accessToken: "mockAccessToken"
+    };
+
+    const mockResponse = {
+      error: { code: 2_105_356, message: "User not found (user: 'xeln12ia')" }
+    }; // Extract the base URL and path from USER_LEGACY_BASE_URL
+    const baseUrlObj = new URL(USER_LEGACY_BASE_URL);
+    const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`;
+    const basePath = baseUrlObj.pathname;
+
+    nock(baseUrl)
+      .get(`${basePath}/xeln12ia/profile2`)
+      .query(true)
+      .reply(200, mockResponse);
+
+    // ASSERT
+    await expect(
+      getUserRegion(mockAuthorization, "xeln12ia")
+    ).rejects.toThrow();
+  });
+});
diff --git a/src/user/getUserRegion.ts b/src/user/getUserRegion.ts
new file mode 100644
index 0000000..53042f6
--- /dev/null
+++ b/src/user/getUserRegion.ts
@@ -0,0 +1,60 @@
+import type {
+  AuthorizationPayload,
+  RegionCode,
+  UserRegionInfo
+} from "../models";
+import { extractRegionFromNpId } from "../utils/extractRegionFromNpId";
+import { getProfileFromUserName } from "./getProfileFromUserName";
+
+/**
+ * Retrieves the region of a PlayStation Network user based on their username.
+ *
+ * This function uses the npId from the user's profile to determine their region.
+ * The region is extracted from the base64-encoded npId and returned as an object
+ * containing both the two-letter country code (ISO 3166-1 alpha-2) and the full
+ * country name.
+ *
+ * @param authorization An object containing your access token, typically retrieved with `exchangeAccessCodeForAuthTokens()`.
+ * @param userName The username for the user whose region you want to determine.
+ * @param locales Optional. A string with a BCP 47 language tag, or an array of such strings.
+ *                Defaults to ['en'] (English) if not specified.
+ * @returns A promise that resolves to a RegionInfo object with the country code and name, or null if the region cannot be determined.
+ *
+ * @throws Will throw an error if the user cannot be found or if there are any issues accessing the profile.
+ */
+export const getUserRegion = async (
+  authorization: AuthorizationPayload,
+  userName: string,
+  locales: Intl.LocalesArgument = ["en"]
+): Promise<UserRegionInfo | null> => {
+  const profileData = await getProfileFromUserName(authorization, userName);
+
+  const npId = profileData?.profile?.npId;
+  if (!npId) {
+    // User profile not found, or npId is missing
+    return null;
+  }
+
+  const regionCode: RegionCode | null = extractRegionFromNpId(npId);
+
+  if (!regionCode) {
+    // Region could not be determined from the npId
+    return null;
+  }
+  try {
+    // Use Intl.DisplayNames to get the region name in the specified locale
+    const regionNames = new Intl.DisplayNames(locales, { type: "region" });
+    const regionName = regionNames.of(regionCode); // `of` expects a string region code
+
+    return {
+      code: regionCode,
+      name: regionName
+    };
+  } catch {
+    // If Intl.DisplayNames fails, return just the code with an unknown name
+    return {
+      code: regionCode,
+      name: "Unknown"
+    };
+  }
+};
diff --git a/src/user/index.ts b/src/user/index.ts
index e6bd396..dc07699 100644
--- a/src/user/index.ts
+++ b/src/user/index.ts
@@ -3,3 +3,4 @@ export * from "./getProfileFromAccountId";
 export * from "./getProfileFromUserName";
 export * from "./getUserFriendsAccountIds";
 export * from "./getUserPlayedGames";
+export * from "./getUserRegion";
diff --git a/src/utils/decodeBase64.ts b/src/utils/decodeBase64.ts
new file mode 100644
index 0000000..07b5826
--- /dev/null
+++ b/src/utils/decodeBase64.ts
@@ -0,0 +1,16 @@
+/**
+ * Cross-platform base64 decoding function
+ */
+export function decodeBase64(base64String: string): string {
+  try {
+    if (typeof window !== "undefined" && typeof window.atob === "function") {
+      return window.atob(base64String); // Browser decoding
+    } else if (typeof Buffer !== "undefined") {
+      return Buffer.from(base64String, "base64").toString("utf8"); // Node.js decoding
+    } else {
+      throw new TypeError("Base64 decoding unsupported in this environment");
+    }
+  } catch (error) {
+    throw new Error(`Base64 decoding error: ${error}`);
+  }
+}
diff --git a/src/utils/encodeBase64.ts b/src/utils/encodeBase64.ts
new file mode 100644
index 0000000..902e69e
--- /dev/null
+++ b/src/utils/encodeBase64.ts
@@ -0,0 +1,16 @@
+/**
+ * Cross-platform base64 encoding function
+ */
+export function encodeBase64(str: string): string {
+  try {
+    if (typeof window !== "undefined" && typeof window.btoa === "function") {
+      return window.btoa(str); // Browser encoding
+    } else if (typeof Buffer !== "undefined") {
+      return Buffer.from(str, "utf8").toString("base64"); // Node.js encoding
+    } else {
+      throw new TypeError("Base64 encoding unsupported in this environment");
+    }
+  } catch (error) {
+    throw new Error(`Base64 encoding error: ${error}`);
+  }
+}
diff --git a/src/utils/extractRegionFromNpId.test.ts b/src/utils/extractRegionFromNpId.test.ts
new file mode 100644
index 0000000..5481f5b
--- /dev/null
+++ b/src/utils/extractRegionFromNpId.test.ts
@@ -0,0 +1,106 @@
+import { encodeBase64 } from "./encodeBase64";
+import { extractRegionFromNpId } from "./extractRegionFromNpId";
+
+describe("Function: extractRegionFromNpId", () => {
+  it("is defined #sanity", () => {
+    // ASSERT
+    expect(extractRegionFromNpId).toBeDefined();
+  });
+
+  it("returns null if npId is empty or undefined", () => {
+    // ASSERT
+    expect(extractRegionFromNpId("")).toBeNull();
+    expect(extractRegionFromNpId(undefined as unknown as string)).toBeNull();
+  });
+
+  it("extracts US region code from a valid NPID", () => {
+    // ARRANGE
+    // This base64 string decodes to something like "username@domain.us"
+    const mockNpId = "eGVsbmlhQGM2LnVz"; // Decodes to "xelnia@c6.us"
+
+    // ACT
+    const result = extractRegionFromNpId(mockNpId);
+
+    // ASSERT
+    expect(result).toEqual("US");
+  });
+
+  it("extracts JP region code from a valid NPID", () => {
+    // ARRANGE
+    // This base64 string decodes to something like "username@domain.jp"
+    const mockNpId = encodeBase64("testuser@psn.jp");
+
+    // ACT
+    const result = extractRegionFromNpId(mockNpId);
+
+    // ASSERT
+    expect(result).toEqual("JP");
+  });
+
+  it("extracts GB region code from a valid NPID", () => {
+    // ARRANGE
+    // This base64 string decodes to something like "username@domain.gb"
+    const mockNpId = encodeBase64("britishuser@psn.gb");
+
+    // ACT
+    const result = extractRegionFromNpId(mockNpId);
+
+    // ASSERT
+    expect(result).toEqual("GB");
+  });
+
+  it("returns region code in uppercase regardless of input case", () => {
+    // ARRANGE
+    const mockNpId = encodeBase64("lowercaseuser@psn.fr");
+
+    // ACT
+    const result = extractRegionFromNpId(mockNpId);
+
+    // ASSERT
+    expect(result).toEqual("FR"); // Ensure uppercase even though "fr" is lowercase in the input
+  });
+
+  it("returns null for npId with invalid format (missing @)", () => {
+    // ARRANGE
+    const mockNpId = encodeBase64("invalid-format.us");
+
+    // ACT
+    const result = extractRegionFromNpId(mockNpId);
+
+    // ASSERT
+    expect(result).toBeNull();
+  });
+
+  it("returns null for npId with invalid format (missing .)", () => {
+    // ARRANGE
+    const mockNpId = encodeBase64("invalid@formatcom");
+
+    // ACT
+    const result = extractRegionFromNpId(mockNpId);
+
+    // ASSERT
+    expect(result).toBeNull();
+  });
+
+  it("returns null for npId with region code longer than 2 characters", () => {
+    // ARRANGE
+    const mockNpId = encodeBase64("user@domain.usa");
+
+    // ACT
+    const result = extractRegionFromNpId(mockNpId);
+
+    // ASSERT
+    expect(result).toBeNull();
+  });
+
+  it("returns null for npId with region code containing non-alphabetic characters", () => {
+    // ARRANGE
+    const mockNpId = encodeBase64("user@domain.u1");
+
+    // ACT
+    const result = extractRegionFromNpId(mockNpId);
+
+    // ASSERT
+    expect(result).toBeNull();
+  });
+});
diff --git a/src/utils/extractRegionFromNpId.ts b/src/utils/extractRegionFromNpId.ts
new file mode 100644
index 0000000..9a94d62
--- /dev/null
+++ b/src/utils/extractRegionFromNpId.ts
@@ -0,0 +1,46 @@
+import { RegionCode } from "../models";
+import { decodeBase64 } from "./decodeBase64";
+
+/**
+ * Extracts the region code from a base64-encoded NPID string and returns the region code.
+ *
+ * @param npId The base64-encoded NPID string, which is decoded to extract the region code.
+ * @returns The ISO 3166-1 alpha-2 region code (e.g., "US", "JP", "GB") as a RegionCode,
+ * or null if extraction fails or the NPID is invalid.
+ *
+ * This function assumes a valid NPID format (e.g., "VaultTec-Co@b7.us"). It splits the string to get
+ * the region code (e.g., "us"), which is then returned as uppercase.
+ */
+export const extractRegionFromNpId = (npId: string): RegionCode | null => {
+  if (!npId) {
+    return null;
+  }
+
+  try {
+    // Decode the base64-encoded NPID
+    // Note: Buffer is Node.js specific. For cross-platform (browser/Node)
+    // consider alternatives if needed, though NPID format might be ASCII-safe for atob.
+    const decodedNpId = decodeBase64(npId);
+
+    // Assuming a decoded npid format (e.g. VaultTec-Co@b7.us), extract the region part.
+    if (decodedNpId.includes("@") && decodedNpId.includes(".")) {
+      const parts = decodedNpId.split(".");
+      const regionCandidate = parts.pop(); // Get the last part, which should be the region
+
+      // Validate that the candidate is a two-letter alphabetic string
+      if (
+        regionCandidate &&
+        regionCandidate.length === 2 &&
+        /^[A-Za-z]+$/.test(regionCandidate)
+      ) {
+        return regionCandidate.toUpperCase() as RegionCode; // Return as uppercase RegionCode
+      }
+    }
+
+    return null; // Return null if format is not as expected or validation fails
+  } catch (error) {
+    // Catch errors from base64 decoding or other unexpected issues
+    console.error("Failed to extract region from NPID:", error);
+    return null;
+  }
+};
diff --git a/website/docs/api-docs/users.md b/website/docs/api-docs/users.md
index 4377708..a2ea4f2 100644
--- a/website/docs/api-docs/users.md
+++ b/website/docs/api-docs/users.md
@@ -331,3 +331,49 @@ These are the possible values that can be in the `options` object (the third par
 ### Source
 
 [user/getUserPlayedGames.ts](https://github.com/achievements-app/psn-api/blob/main/src/user/getUserPlayedGames.ts)
+
+---
+
+## getUserRegion
+
+A call to this function will retrieve the region information of a PlayStation Network user based on their username. The region is extracted from the base64-encoded npId in the user's profile and returned as an object containing both the two-letter country code (ISO 3166-1 alpha-2) and the full country name.
+
+### Examples
+
+#### Get a user's region
+
+```ts
+import { getUserRegion } from "psn-api";
+
+const region = await getUserRegion(authorization, "xelnia");
+console.log(region); // { code: "US", name: "United States" }
+```
+
+#### Get a user's region with a specific locale
+
+```ts
+import { getUserRegion } from "psn-api";
+
+// Get the region name in French
+const region = await getUserRegion(authorization, "xelnia", "fr");
+console.log(region); // { code: "US", name: "États-Unis" }
+```
+
+### Returns
+
+| Name   | Type     | Description                                                         |
+| :----- | :------- | :------------------------------------------------------------------ |
+| `code` | `string` | The two-letter country code (ISO 3166-1 alpha-2) of the user.       |
+| `name` | `string` | The full name of the country in the locale specified (default: en). |
+
+### Parameters
+
+| Name            | Type                                                                                                                             | Description                                                                                                                |
+| :-------------- | :------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
+| `authorization` | [`AuthorizationPayload`](/api-docs/data-models/authorization-payload)                                                            | An object that must contain an `accessToken`. See [this page](/authentication/authenticating-manually) for how to get one. |
+| `userName`      | `string`                                                                                                                         | The username for the user whose region you want to determine.                                                              |
+| `locales`       | [`Intl.LocalesArgument`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locales_argument) | Optional. A string with a BCP 47 language tag, or an array of such strings. Defaults to ['en'] (English) if not specified. |
+
+### Source
+
+[user/getUserRegion.ts](https://github.com/achievements-app/psn-api/blob/main/src/user/getUserRegion.ts)


Reviews:
user: login: wescopeland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-11T14:03:10Z
user: login: Dev-R
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T03:22:22Z
user: login: wescopeland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T21:57:37Z
user: login: Dev-R
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T03:04:15Z
user: login: wescopeland
body: Thank you!
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T22:55:13Z
user: login: wescopeland
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-22T22:55:20Z

Review comments:
diff_hunk: @@ -0,0 +1,20 @@
+/**
+ * Interface representing region information for a PlayStation Network user.
path: src/models/user-region-info.model.ts
user: login: wescopeland
body: nit: A consumer won't necessarily be interested in a type vs interface implementation detail.

```suggestion
 * Represents region information for a PlayStation Network user.
```
created_at: 2025-05-11T13:51:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,20 @@
+/**
+ * Interface representing region information for a PlayStation Network user.
+ */
+export interface UserRegionInfo {
+  /**
+   * The two-letter country code (ISO 3166-1 alpha-2)
+   */
path: src/models/user-region-info.model.ts
user: login: wescopeland
body: Recommend adding an `@example` so it shows up in IntelliSense in the user's IDE, eg:
```ts
/**
 * The two-letter country code (ISO 3166-1 alpha-2).
 * @example "US"
 */
code: RegionCode;
```
created_at: 2025-05-11T13:52:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,20 @@
+/**
+ * Interface representing region information for a PlayStation Network user.
+ */
+export interface UserRegionInfo {
+  /**
+   * The two-letter country code (ISO 3166-1 alpha-2)
+   */
+  code: RegionCode;
+
+  /**
+   * The full name of the country in English
+   */
+  name?: string;
path: src/models/user-region-info.model.ts
user: login: wescopeland
body: An `@example` would be beneficial here too, I think.
created_at: 2025-05-11T13:53:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,20 @@
+/**
+ * Interface representing region information for a PlayStation Network user.
+ */
+export interface UserRegionInfo {
+  /**
+   * The two-letter country code (ISO 3166-1 alpha-2)
+   */
+  code: RegionCode;
+
+  /**
+   * The full name of the country in English
+   */
+  name?: string;
+}
+
+/**
+ * Represents a two-letter uppercase ISO 3166-1 alpha-2 region code.
+ * For example: "US", "JP", "GB".
+ */
+export type RegionCode = string;
path: src/models/user-region-info.model.ts
user: login: wescopeland
body: A template literal may be a better fit:
```ts
export type RegionCode = `${Uppercase<string>}${Uppercase<string>}`;
```
created_at: 2025-05-11T13:54:59Z
author_association: MEMBER
path: src/utils/extractRegionFromNpId.ts
user: login: wescopeland
body: Recommend adding unit tests for this file. Right now it's tested implicitly through `getUserRegion`, but a future maintainer may be caught by surprise if getUserRegion's implementation details change and this file loses coverage.
created_at: 2025-05-11T13:59:22Z
author_association: MEMBER
path: src/utils/extractRegionFromNpId.ts
user: login: wescopeland
body: Rather than `Buffer.from()`, can we use `atob()`?, ie:
```ts
const decodedNpId = atob(npId);
```
If this change works, then the code remains cross-platform compatible.
created_at: 2025-05-11T14:02:56Z
author_association: MEMBER
path: src/utils/extractRegionFromNpId.ts
user: login: Dev-R
body: I've done some research. Are you sure that atob is cross-platform compatible? From my findings, atob() and btoa() are global functions built into browsers, but they are not natively available in Node.js.

Maybe we can use something like this for cross-platform compatibility ([source](https://github.com/brunobat/jwt-js-decode/blob/16cc53832e224ef1166227d9a6a59fa7d116d723/src/jwt-js-decode.ts#L263)):
```ts
/**
 * Converts string to base64 string
 *
 * @param {string} str - data string to convert
 *
 * @returns {string} decoded data string
 */
export function b2s(str: string): string {
    try{
        if(typeof window === 'object' && typeof window.atob === 'function') {
            return window.atob(str);
        } else if(typeof Buffer !== 'undefined') {
            return new Buffer(str, 'base64').toString('binary')
        } else throw new Error(ILLEGAL_ARGUMENT);
    } catch(e) {
        throw new Error(e);
    }
}

```
created_at: 2025-05-12T03:22:22Z
author_association: CONTRIBUTOR
path: src/utils/extractRegionFromNpId.ts
user: login: wescopeland
body: Ah, I actually didn't know that. I naively assumed those functions were available in Node. I like your idea of the `b2s` function.
created_at: 2025-05-12T21:57:37Z
author_association: MEMBER
path: src/utils/extractRegionFromNpId.ts
user: login: Dev-R
body: I just pushed a new commit with the cross-platform base64 utils. Let me know!
created_at: 2025-05-16T03:04:15Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-11T01:33:42Z
body: [vc]: #TpsXTypD5TrKwaNOgNYSxv12mDn4NB+XpuDvM1WHLRE=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJwc24tYXBpIiwicm9vdERpcmVjdG9yeSI6IndlYnNpdGUiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vYWNoaWV2ZW1lbnRzLWFwcC9wc24tYXBpL0JCUFhUUkRLZHV4SEQ2VXBCb0NXSGJaSjg3YkwiLCJwcmV2aWV3VXJsIjoicHNuLWFwaS1naXQtZm9yay1kZXYtci1tYWluLWFjaGlldmVtZW50cy1hcHAudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJwc24tYXBpLWdpdC1mb3JrLWRldi1yLW1haW4tYWNoaWV2ZW1lbnRzLWFwcC52ZXJjZWwuYXBwIn19XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **psn-api** | ✅ Ready ([Inspect](https://vercel.com/achievements-app/psn-api/BBPXTRDKduxHD6UpBoCWHbZJ87bL)) | [Visit Preview](https://psn-api-git-fork-dev-r-main-achievements-app.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/psn-api-git-fork-dev-r-main-achievements-app.vercel.app?via=pr-comment-feedback-link) | May 20, 2025 5:12am |


author_association: NONE
user: login: wescopeland
created_at: 2025-05-11T14:04:57Z
body: Is any update to the _README.md_ file needed to point to the new function's docs page?
author_association: MEMBER
user: login: wescopeland
created_at: 2025-05-16T23:29:03Z
body: Hey @Dev-R 👋  Looks like the last thing to clear up is we have a check failing! See: https://github.com/achievements-app/psn-api/actions/runs/15059698339/job/42332384656?pr=199
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-05-22T22:56:47Z
body: :tada: This PR is included in version 2.12.0 :tada:

The release is available on:
- [npm package (@latest dist-tag)](https://www.npmjs.com/package/psn-api/v/2.12.0)
- [GitHub release](https://github.com/achievements-app/psn-api/releases/tag/v2.12.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE