Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/client/GuildPassClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
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,
Expand Down Expand Up @@ -96,7 +96,7 @@
private readonly cache: CacheAdapter | undefined;
private readonly cacheTtl: number | undefined;
private readonly deduplication: boolean;
private readonly inFlightRequests = new Map<string, Promise<any>>();

Check warning on line 99 in src/client/GuildPassClient.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

// GuildPass SDK: Class member structure property or constructor.
constructor(config: GuildPassClientConfig) {
Expand Down Expand Up @@ -218,6 +218,7 @@
`${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)}:`,
Expand Down Expand Up @@ -464,6 +465,15 @@
return this.withCache(key, () => raw.getMembership(params, stripDeduplicate(options) as any), undefined, options?.deduplicate ?? (options?.signal ? false : undefined)) as any;
},
},
getHistory: {
value: async <O extends RequestOptions & { includeMeta?: boolean }>(params: GetHistoryParams, options?: O): Promise<O extends { includeMeta: true } ? { data: any; meta: ResponseMetadata } : any> => {
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 <O extends RequestOptions & { includeMeta?: boolean }>(params: MembershipParams, options?: O): Promise<O extends { includeMeta: true } ? { data: boolean; meta: ResponseMetadata } : boolean> => {
if (options?.includeMeta) {
Expand Down
76 changes: 73 additions & 3 deletions src/membership/membership.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<MembershipEvent>; meta: ResponseMetadata }>;
public async getHistory(params: GetHistoryParams & ({ cursor: string } | { limit: number }), options?: RequestOptions): Promise<PaginatedResult<MembershipEvent>>;
public async getHistory(params: GetHistoryParams, options: RequestOptions & { includeMeta: true }): Promise<{ data: MembershipEvent[]; meta: ResponseMetadata }>;
public async getHistory(params: GetHistoryParams, options?: RequestOptions): Promise<MembershipEvent[]>;
public async getHistory(params: GetHistoryParams, options?: RequestOptions): Promise<any> {
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<any>(path, reqOptions);
const hasPagination = cursor !== undefined || limit !== undefined;

return this.handlePaginatedResponse(result, options, hasPagination, isMembershipEventArray, 'MembershipEvent[]', `GET ${path}`);
}

private handlePaginatedResponse<T>(
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.
}
14 changes: 14 additions & 0 deletions src/membership/membership.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
};
12 changes: 11 additions & 1 deletion src/validation/requestGuards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -45,6 +45,16 @@ export const isMembershipParams: Validator<MembershipParams> = object({
guildId: nonEmptyString(),
});

/**
* Checks whether `value` conforms to the {@link GetHistoryParams} shape.
*/
export const isGetHistoryParams: Validator<GetHistoryParams> = 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
Expand Down
19 changes: 17 additions & 2 deletions src/validation/responseGuards.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -77,6 +77,21 @@ export const isMembership: Validator<Membership> = object({
expiresAt: optional(string()),
});

/**
* Checks whether `value` conforms to the {@link MembershipEvent} shape.
*/
export const isMembershipEvent: Validator<MembershipEvent> = object({
id: nonEmptyString(),
type: nonEmptyString(),
timestamp: nonEmptyString(),
data: optional((v: unknown): v is Record<string, any> => typeof v === 'object' && v !== null && !Array.isArray(v)),
});

/**
* Checks whether `value` is an array of {@link MembershipEvent} objects.
*/
export const isMembershipEventArray: Validator<MembershipEvent[]> = array(isMembershipEvent);

/**
* Checks whether `value` conforms to the {@link GuildRole} shape.
*/
Expand Down
39 changes: 39 additions & 0 deletions tests/membership.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading