diff --git a/src/client/GuildPassClient.ts b/src/client/GuildPassClient.ts index ff6af31..b079913 100644 --- a/src/client/GuildPassClient.ts +++ b/src/client/GuildPassClient.ts @@ -21,7 +21,7 @@ import { normaliseAddress } from '../utils/address'; import { validateAddress } from '../utils/validation'; import { encodePathSegment } from '../utils/formatting'; import type { AccessCheckParams, RoleAccessCheckParams, AccessCheckBatchOptions, AccessCheckBatchResult, AccessCheckBatchByResourceParams, AccessCheckBatchByResourceResult, AccessCheckResult } from '../access/access.types'; -import type { MembershipParams } from '../membership/membership.types'; +import type { MembershipParams, GetHistoryParams } from '../membership/membership.types'; import type { GetRolesParams, GetUserRolesParams, HasRoleParams } from '../roles/roles.types'; import type { GetGuildParams, @@ -218,6 +218,7 @@ export class GuildPassClient { `${buildCacheKey('access', 'checkAccess', guildId)}:`, `${buildCacheKey('access', 'checkRoleAccess', guildId)}:`, `${buildCacheKey('membership', 'getMembership', guildId)}:`, + `${buildCacheKey('membership', 'getHistory', guildId)}:`, buildCacheKey('roles', 'getRoles', guildId), `${buildCacheKey('roles', 'getRoles', guildId)}:`, `${buildCacheKey('roles', 'getUserRoles', guildId)}:`, @@ -464,6 +465,15 @@ export class GuildPassClient { return this.withCache(key, () => raw.getMembership(params, stripDeduplicate(options) as any), undefined, options?.deduplicate ?? (options?.signal ? false : undefined)) as any; }, }, + getHistory: { + value: async (params: GetHistoryParams, options?: O): Promise => { + const wallet = normaliseAddress(params.walletAddress); + const cursorKey = params.cursor ?? ''; + const limitKey = params.limit !== undefined ? String(params.limit) : ''; + const key = buildCacheKey('membership', 'getHistory', params.guildId, wallet, cursorKey, limitKey); + return this.withCache(key, () => raw.getHistory(params, stripDeduplicate(options) as any), undefined, options?.deduplicate ?? (options?.signal ? false : undefined)) as any; + }, + }, isMember: { value: async (params: MembershipParams, options?: O): Promise => { if (options?.includeMeta) { diff --git a/src/membership/membership.service.ts b/src/membership/membership.service.ts index 64ad554..8e83d25 100644 --- a/src/membership/membership.service.ts +++ b/src/membership/membership.service.ts @@ -3,14 +3,16 @@ import { HttpClient } from '../http/httpClient'; // GuildPass SDK: Pull in package or module bindings. import { validateAddress, validateGuildId } from '../utils/validation'; import { normaliseAddress } from '../utils/address'; +import { encodePathSegment } from '../utils/formatting'; import { assertValidResponse } from '../validation/assertResponse'; import { assertValidRequest } from '../validation/assertRequest'; -import { isMembership } from '../validation/responseGuards'; -import { isMembershipParams } from '../validation/requestGuards'; +import { isMembership, isMembershipEventArray } from '../validation/responseGuards'; +import { isMembershipParams, isGetHistoryParams } from '../validation/requestGuards'; import type { RequestOptions } from '../types/common'; import type { ResponseMetadata } from '../http/http.types'; // GuildPass SDK: Import external module dependencies. -import { Membership, MembershipParams } from './membership.types'; +import { Membership, MembershipParams, MembershipEvent, GetHistoryParams } from './membership.types'; +import { PaginatedResult } from '../utils/pagination'; // GuildPass SDK: Core operational type definition. export class MembershipService { @@ -89,5 +91,73 @@ export class MembershipService { return (result as any).isActive as any; // GuildPass SDK: End of logic containment structure block. } + + /** + * Fetches historical membership events for a wallet. + */ + public async getHistory(params: GetHistoryParams & ({ cursor: string } | { limit: number }), options: RequestOptions & { includeMeta: true }): Promise<{ data: PaginatedResult; meta: ResponseMetadata }>; + public async getHistory(params: GetHistoryParams & ({ cursor: string } | { limit: number }), options?: RequestOptions): Promise>; + public async getHistory(params: GetHistoryParams, options: RequestOptions & { includeMeta: true }): Promise<{ data: MembershipEvent[]; meta: ResponseMetadata }>; + public async getHistory(params: GetHistoryParams, options?: RequestOptions): Promise; + public async getHistory(params: GetHistoryParams, options?: RequestOptions): Promise { + assertValidRequest(params, isGetHistoryParams, 'GetHistoryParams', { endpoint: 'GET /guilds/:id/members/:address/history' }); + const { walletAddress, guildId, cursor, limit } = params; + + validateAddress(walletAddress, { strict: this.strictAddressChecksum }); + validateGuildId(guildId); + + const path = `/guilds/${encodePathSegment(guildId)}/members/${encodePathSegment(normaliseAddress(walletAddress))}/history`; + + const reqOptions: any = { ...options }; + if (cursor !== undefined || limit !== undefined) { + reqOptions.params = { ...reqOptions.params, ...(cursor !== undefined && { cursor }), ...(limit !== undefined && { limit }) }; + } + + const result = await this.http.get(path, reqOptions); + const hasPagination = cursor !== undefined || limit !== undefined; + + return this.handlePaginatedResponse(result, options, hasPagination, isMembershipEventArray, 'MembershipEvent[]', `GET ${path}`); + } + + private handlePaginatedResponse( + result: any, + options: RequestOptions | undefined, + hasPaginationParams: boolean, + guard: (val: unknown) => val is T[], + typeName: string, + endpoint?: string + ): any { + const responseData = options?.includeMeta ? result.data : result; + const meta = options?.includeMeta ? result.meta : undefined; + + let finalData; + + if (hasPaginationParams) { + if (Array.isArray(responseData)) { + finalData = { items: responseData, hasMore: false }; + } else { + finalData = responseData; + } + if (this.validateResponses) { + assertValidResponse(finalData.items, guard, typeName, { endpoint }); + } + } else { + if (Array.isArray(responseData)) { + finalData = responseData; + } else if (responseData && Array.isArray(responseData.items)) { + finalData = responseData.items; + } else { + finalData = responseData; + } + if (this.validateResponses) { + assertValidResponse(finalData, guard, typeName, { endpoint }); + } + } + + if (options?.includeMeta) { + return { data: finalData, meta }; + } + return finalData; + } // GuildPass SDK: End of logic containment structure block. } diff --git a/src/membership/membership.types.ts b/src/membership/membership.types.ts index 3f0c37d..73ff379 100644 --- a/src/membership/membership.types.ts +++ b/src/membership/membership.types.ts @@ -15,3 +15,17 @@ export type Membership = { expiresAt?: string; // GuildPass SDK: End of logic containment structure block. }; + +export type GetHistoryParams = { + walletAddress: string; + guildId: string; + cursor?: string; + limit?: number; +}; + +export type MembershipEvent = { + id: string; + type: string; + timestamp: string; + data?: Record; +}; diff --git a/src/validation/requestGuards.ts b/src/validation/requestGuards.ts index b3c22e2..a242a0d 100644 --- a/src/validation/requestGuards.ts +++ b/src/validation/requestGuards.ts @@ -15,7 +15,7 @@ import { object, nonEmptyString, number, string, optional, type Validator } from './schema'; import type { AccessCheckParams, RoleAccessCheckParams } from '../access/access.types'; -import type { MembershipParams } from '../membership/membership.types'; +import type { MembershipParams, GetHistoryParams } from '../membership/membership.types'; import type { GetRolesParams, GetUserRolesParams } from '../roles/roles.types'; import type { GetGuildParams } from '../guilds/guilds.types'; @@ -45,6 +45,16 @@ export const isMembershipParams: Validator = object({ guildId: nonEmptyString(), }); +/** + * Checks whether `value` conforms to the {@link GetHistoryParams} shape. + */ +export const isGetHistoryParams: Validator = object({ + walletAddress: nonEmptyString(), + guildId: nonEmptyString(), + cursor: optional(string()), + limit: optional(number()), +}); + /** * Checks whether `value` conforms to the {@link GetRolesParams} shape. * `cursor`/`limit` are checked as plain `string`/`number` (not diff --git a/src/validation/responseGuards.ts b/src/validation/responseGuards.ts index bdf4f15..d3120c0 100644 --- a/src/validation/responseGuards.ts +++ b/src/validation/responseGuards.ts @@ -1,4 +1,4 @@ -/** +/** * Runtime shape guards for the SDK's core public response types. * * Built on the composable schema DSL from `schema.ts` — zero runtime @@ -23,7 +23,7 @@ import { } from './schema'; import type { AccessCheckResult } from '../access/access.types'; -import type { Membership } from '../membership/membership.types'; +import type { Membership, MembershipEvent } from '../membership/membership.types'; import type { GuildRole } from '../roles/roles.types'; import type { Guild, GuildConfig } from '../guilds/guilds.types'; import type { AccessRequirement } from '../types/common'; @@ -77,6 +77,21 @@ export const isMembership: Validator = object({ expiresAt: optional(string()), }); +/** + * Checks whether `value` conforms to the {@link MembershipEvent} shape. + */ +export const isMembershipEvent: Validator = object({ + id: nonEmptyString(), + type: nonEmptyString(), + timestamp: nonEmptyString(), + data: optional((v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v)), +}); + +/** + * Checks whether `value` is an array of {@link MembershipEvent} objects. + */ +export const isMembershipEventArray: Validator = array(isMembershipEvent); + /** * Checks whether `value` conforms to the {@link GuildRole} shape. */ diff --git a/tests/membership.service.test.ts b/tests/membership.service.test.ts index 9aa734d..c279963 100644 --- a/tests/membership.service.test.ts +++ b/tests/membership.service.test.ts @@ -81,4 +81,43 @@ describe('MembershipService request options forwarding', () => { params: { address: validAddress, guildId: 'guild_1' }, }); }); +}); + +describe('MembershipService getHistory', () => { + it('fetches historical membership events without pagination params', async () => { + const mockEvents = [ + { id: '1', type: 'JOIN', timestamp: '2026-01-01T00:00:00Z' } + ]; + const { get, service } = createService(mockEvents); + + const result = await service.getHistory({ walletAddress: validAddress, guildId: 'guild_1' }); + + expect(get).toHaveBeenCalledWith(`/guilds/guild_1/members/${validAddress}/history`, {}); + expect(result).toEqual(mockEvents); + }); + + it('fetches historical membership events with pagination params', async () => { + const mockResponse = { + items: [{ id: '1', type: 'JOIN', timestamp: '2026-01-01T00:00:00Z' }], + nextCursor: 'abc', + hasMore: true + }; + const { get, service } = createService(mockResponse); + + const result = await service.getHistory({ walletAddress: validAddress, guildId: 'guild_1', cursor: '123', limit: 10 }); + + expect(get).toHaveBeenCalledWith(`/guilds/guild_1/members/${validAddress}/history`, { + params: { cursor: '123', limit: 10 } + }); + expect(result).toEqual(mockResponse); + }); + + it('throws a GuildPassConfigError for an invalid wallet address', async () => { + const { get, service } = createService([]); + + await expect( + service.getHistory({ walletAddress: 'not-an-address', guildId: 'guild_1' }), + ).rejects.toBeInstanceOf(GuildPassConfigError); + expect(get).not.toHaveBeenCalled(); + }); }); \ No newline at end of file