diff --git a/src/resources/users.ts b/src/resources/users.ts index e29bea7..972f3d8 100644 --- a/src/resources/users.ts +++ b/src/resources/users.ts @@ -1,12 +1,17 @@ import { HttpClient } from "../http"; +import { OpenCloudError } from "../errors"; import type { AssetQuotasPage, + GameJoinRestriction, GenerateThumbnailOptions, InventoryItemsPage, ListOptions, User, UserNotificationBody, UserNotificationResponse, + UserRestrictionLogsPage, + UserRestrictionResponse, + UserRestrictionsPage, UserThumbnail, } from "../types"; @@ -227,4 +232,182 @@ export class Users { }, ); } + + /** + * List user restrictions for users that have ever been banned in either a universe or a specific place. + * + * *Requires `universe.user-restriction:read` scope.* + * + * @param universeId - The universe ID. (numeric string) + * @param options - List options including pagination and filtering + * @param options.maxPageSize - Maximum items per page (default set by API) + * @param options.pageToken - Token from previous response for next page + * @returns Promise resolving to the user restriction response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universeId is invalid or other API error occurs + * + * @example + * ```typescript + * const userRestriction = await client.users.listUserRestrictions('123456789', { maxPageSize: 50 }); + * console.log(userRestriction); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_UpdateUserRestriction__Using_Universes_Places + */ + async listUserRestrictions( + universeId: string, + options: ListOptions = {}, + ): Promise { + const searchParams = new URLSearchParams(); + if (options.maxPageSize) + searchParams.set("maxPageSize", options.maxPageSize.toString()); + if (options.pageToken) searchParams.set("pageToken", options.pageToken); + + return this.http.request( + `/cloud/v2/universes/${universeId}/user-restrictions`, + { + method: "GET", + searchParams, + }, + ); + } + + /** + * Get the user restriction. + * + * *Requires `universe.user-restriction:read` scope.* + * + * @param universeId - The universe ID. (numeric string) + * @param userRestrictionId - The user ID. (numeric string) + * @returns Promise resolving to the user restriction response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the user is not found or other API error occurs + * + * @example + * ```typescript + * const userRestriction = await client.users.getUserRestriction('123456789', '123456789'); + * console.log(userRestriction); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_UpdateUserRestriction__Using_Universes_Places + */ + async getUserRestriction( + universeId: string, + userRestrictionId: string, + ): Promise { + return this.http.request( + `/cloud/v2/universes/${universeId}/user-restrictions/${userRestrictionId}`, + { + method: "GET", + }, + ); + } + + /** + * Update the user restriction. + * + * *Requires `universe.user-restriction:write` scope.* + * + * @param universeId - The universe ID. (numeric string) + * @param userRestrictionId - The user ID. (numeric string) + * @param body - The user restriction body containing the payload + * @param updateMask - The list of fields to update; only "game_join_restriction" is supported + * @returns Promise resolving to the user restriction response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If updateMask is invalid or an API error occurs + * + * @example + * ```typescript + * const userRestrction = await client.users.updateUserRestriction('123456789', '123456789', { + * active: true, + * duration: "3s", + * privateReason: "some private reason", + * displayReason: "some display reason", + * excludeAltAccounts: true + * }); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_UpdateUserRestriction__Using_Universes_Places + */ + async updateUserRestriction( + universeId: string, + userRestrictionId: string, + body: GameJoinRestriction, + updateMask?: string, + ): Promise { + const idempotencyKey = crypto.randomUUID(); + const firstSent = new Date().toISOString(); + + if (updateMask && updateMask !== "game_join_restriction") { + throw new OpenCloudError( + `Invalid update mask: "${updateMask}". Only "game_join_restriction" or none is supported.`, + 400, + "INVALID_ARGUMENT", + { + field: "game_join_restriction", + allowed: ["game_join_restriction"], + }, + ); + } + + const searchParams = new URLSearchParams({ + "idempotencyKey.key": idempotencyKey, + "idempotencyKey.firstSent": firstSent, + }); + if (updateMask) searchParams.append("updateMask", updateMask); + + return this.http.request( + `/cloud/v2/universes/${universeId}/user-restrictions/${userRestrictionId}`, + { + method: "PATCH", + body: JSON.stringify(body), + searchParams, + }, + ); + } + + /** + * List changes to UserRestriction resources within a given universe. This includes both universe-level and place-level restrictions. + * For universe-level restriction logs, the place field will be empty. + * + * *Requires `universe.user-restriction:read` scope.* + * + * @param universeId - The universe ID. (numeric string) + * @param options - List options including pagination and filtering + * @param options.maxPageSize - Maximum items per page (default set by API) + * @param options.pageToken - Token from previous response for next page + * @param options.filter - Filter expression (e.g., "user == 'users/123'" && "place == 'places/456'") + * @returns Promise resolving to the user restriction response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universeId is invalid or other API error occurs + * + * @example + * ```typescript + * const userRestriction = await client.users.listUserRestrictions('123456789', { + * maxPageSize: 50, + * filter: `"user == 'users/123'" && "place == 'places/456'"` + * }); + * console.log(userRestriction); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_UpdateUserRestriction__Using_Universes_Places + */ + async listUserRestrictionLogs( + universeId: string, + options: ListOptions & { filter?: string } = {}, + ): Promise { + const searchParams = new URLSearchParams(); + if (options.maxPageSize) + searchParams.set("maxPageSize", options.maxPageSize.toString()); + if (options.pageToken) searchParams.set("pageToken", options.pageToken); + if (options.filter) searchParams.set("filter", options.filter); + + return this.http.request( + `/cloud/v2/universes/${universeId}/user-restrictions:listLogs`, + { + method: "GET", + searchParams, + }, + ); + } } diff --git a/src/types/users.ts b/src/types/users.ts index 18d7c05..7f286f5 100644 --- a/src/types/users.ts +++ b/src/types/users.ts @@ -251,3 +251,42 @@ export interface UserNotificationBody { source: UserNotificationSource; payload: UserNotificationPayload; } + +export type UserRestrictionsPage = Page< + UserRestrictionResponse, + "userRestrictions" +>; +export type UserRestrictionLogsPage = Page; + +export interface GameJoinRestriction { + active: boolean; + duration: string; + privateReason: string; + displayReason: string; + excludeAltAccounts: boolean; +} + +export interface GameJoinRestrictionResponse extends GameJoinRestriction { + startTime: string; + inherited: boolean; +} + +export interface UserRestrictionLog extends GameJoinRestriction { + user: string; + place: string; + moderator: { + robloxUser: string; + }; + createTime: string; + startTime: string; + restrictionType: { + gameJoinRestriction: GameJoinRestriction; + }; +} + +export interface UserRestrictionResponse { + path: string; + updateTime: string; + user: string; + gameJoinRestriction: GameJoinRestrictionResponse; +} diff --git a/test/users.test.ts b/test/users.test.ts index 28ec29d..380cb93 100644 --- a/test/users.test.ts +++ b/test/users.test.ts @@ -7,6 +7,10 @@ import type { AssetQuotasPage, UserThumbnail, UserNotificationResponse, + UserRestrictionsPage, + UserRestrictionLogsPage, + GameJoinRestriction, + UserRestrictionResponse, } from "../src/types"; const baseUrl = "https://apis.roblox.com"; @@ -257,4 +261,187 @@ describe("Users", () => { expect(calls[0]?.init?.body).toContain("universes/96623001"); expect(calls[0]?.init?.body).toContain("bronze egg"); }); + + it("GET /universes/{id}/user-restrictions", async () => { + const mockResponse: UserRestrictionsPage = { + userRestrictions: [ + { + path: "universes/123/user-restrictions/123", + user: "users/1", + updateTime: "2023-07-05T12:34:56Z", + gameJoinRestriction: { + active: true, + duration: "3s", + privateReason: "some private reason", + displayReason: "some display reason", + excludeAltAccounts: false, + startTime: "2023-07-05T12:34:56Z", + inherited: false, + }, + }, + ], + nextPageToken: "token123", + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockResponse }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.users.listUserRestrictions("123", { + maxPageSize: 10, + }); + + expect(result).toEqual(mockResponse); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123/user-restrictions?maxPageSize=10`, + ); + }); + + it("GET /universes/{id}/user-restrictions/{userId}", async () => { + const mockResponse: UserRestrictionResponse = { + path: "universes/123/user-restrictions/123", + user: "users/156", + updateTime: "2023-07-05T12:34:56Z", + gameJoinRestriction: { + active: true, + duration: "3s", + privateReason: "some private reason", + displayReason: "some display reason", + excludeAltAccounts: false, + startTime: "2023-07-05T12:34:56Z", + inherited: false, + }, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockResponse }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.users.getUserRestriction("123", "1"); + + expect(result).toEqual(mockResponse); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123/user-restrictions/1`, + ); + }); + + it("PATCH /universes/{id}/user-restrictions/{userId} with valid updateMask", async () => { + const body: GameJoinRestriction = { + active: true, + duration: "3s", + privateReason: "some private reason", + displayReason: "some display reason", + excludeAltAccounts: true, + }; + + const mockResponse: UserRestrictionResponse = { + path: "universes/123/user-restrictions/123", + user: "users/1", + updateTime: "2023-07-05T12:34:56Z", + gameJoinRestriction: { + ...body, + startTime: "2023-07-05T12:34:56Z", + inherited: false, + }, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockResponse }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.users.updateUserRestriction( + "123", + "1", + body, + "game_join_restriction", + ); + + expect(result).toEqual(mockResponse); + const url = new URL(calls[0]?.url.toString()); + expect(url.pathname).toBe(`/cloud/v2/universes/123/user-restrictions/1`); + expect(url.searchParams.get("updateMask")).toBe("game_join_restriction"); + }); + + it("PATCH /universes/{id}/user-restrictions/{userId} invalid updateMask throws", async () => { + const body: GameJoinRestriction = { + active: true, + duration: "3s", + privateReason: "some private reason", + displayReason: "some display reason", + excludeAltAccounts: true, + }; + + const { fetchMock } = makeFetchMock([]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + await expect( + openCloud.users.updateUserRestriction("123", "1", body, "invalid"), + ).rejects.toThrow(); + }); + + it("GET /universes/{id}/user-restrictions:listLogs", async () => { + const mockResponse: UserRestrictionLogsPage = { + logs: [ + { + user: "users/156", + place: "places/456", + moderator: { robloxUser: "users/156" }, + createTime: "2023-07-05T12:34:56Z", + startTime: "2023-07-05T12:34:56Z", + active: true, + duration: "3s", + privateReason: "some private reason", + displayReason: "some display reason", + excludeAltAccounts: true, + restrictionType: { + gameJoinRestriction: { + active: true, + duration: "3s", + privateReason: "some private reason", + displayReason: "some display reason", + excludeAltAccounts: true, + }, + }, + }, + ], + nextPageToken: "def", + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockResponse }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.users.listUserRestrictionLogs("123", { + maxPageSize: 10, + }); + + expect(result).toEqual(mockResponse); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123/user-restrictions:listLogs?maxPageSize=10`, + ); + }); });