diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0ddf28b..0f282b1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,35 +9,12 @@ on: - main jobs: - lint: - runs-on: ubuntu-latest - - name: Lint - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - - name: Install dependencies - run: | - npm install - - - name: Run lint - run: | - npm run lint - test: runs-on: ubuntu-latest strategy: matrix: - node: [18, 20] + node: [18, 20, 22] name: Node ${{ matrix.node }} @@ -55,6 +32,10 @@ jobs: run: | npm install + - name: Lint + run: | + npm run lint + - name: Run tests run: | npm run test diff --git a/src/agency.ts b/src/agency.ts index 57a7440..772b31e 100644 --- a/src/agency.ts +++ b/src/agency.ts @@ -1,4 +1,4 @@ -import { Brand, Domains, Projects, Roles } from './resources/agency' +import { Brand, Domains, Members, Projects, Roles } from './resources/agency' import type { Blutui } from './blutui' import type { GetOptions, PostOptions } from './types' @@ -6,6 +6,7 @@ import type { GetOptions, PostOptions } from './types' export class Agency { readonly brand = new Brand(this) readonly domains = new Domains(this) + readonly members = new Members(this) readonly projects = new Projects(this) readonly roles = new Roles(this) @@ -15,7 +16,7 @@ export class Agency { ) {} async get(path: string, options: GetOptions = {}) { - return this.blutui.get(this.getAgencyPath(path), options) + return await this.blutui.get(this.getAgencyPath(path), options) } async post( @@ -23,7 +24,7 @@ export class Agency { entity: Entity, options: PostOptions = {} ) { - return this.blutui.post( + return await this.blutui.post( this.getAgencyPath(path), entity, options @@ -35,7 +36,7 @@ export class Agency { entity: Entity, options: PostOptions = {} ) { - return this.blutui.patch( + return await this.blutui.patch( this.getAgencyPath(path), entity, options @@ -43,7 +44,7 @@ export class Agency { } async delete(path: string, options: PostOptions = {}) { - return this.blutui.delete(this.getAgencyPath(path), options) + return await this.blutui.delete(this.getAgencyPath(path), options) } /** diff --git a/src/blutui.ts b/src/blutui.ts index 879a548..6c43bcb 100644 --- a/src/blutui.ts +++ b/src/blutui.ts @@ -1,6 +1,6 @@ import { Agency } from './agency' import { - type FetchException, + FetchException, GenericServerException, NoAccessTokenProvidedException, NotFoundException, @@ -122,7 +122,7 @@ export class Blutui { options: PatchOptions = {} ): Promise<{ data: Result }> { try { - return this.client.patch(path, entity, { + return await this.client.patch(path, entity, { params: options.query, }) } catch (error) { @@ -148,6 +148,10 @@ export class Blutui { } private handleFetchError({ path, error }: { path: string; error: unknown }) { + if (!(error instanceof FetchException)) { + throw new Error(`Unexpected error: ${error}`) + } + const { response } = error as FetchException if (response) { diff --git a/src/resources/agency/domains/serializers/domain.serializer.ts b/src/resources/agency/domains/serializers/domain.serializer.ts index d6e1d6e..0c671b4 100644 --- a/src/resources/agency/domains/serializers/domain.serializer.ts +++ b/src/resources/agency/domains/serializers/domain.serializer.ts @@ -1,8 +1,8 @@ import { deserializePaginationMeta } from '@/utils/serializers' +import { deserializeProject } from '../../projects/serializers' import type { List, ListResponse } from '@/types' import type { Domain, DomainResponse } from '../interfaces' -import { deserializeProject } from '../../projects/serializers' export const deserializeDomain = (domain: DomainResponse): Domain => ({ id: domain.id, diff --git a/src/resources/agency/index.ts b/src/resources/agency/index.ts index 9b212cb..d17fd52 100644 --- a/src/resources/agency/index.ts +++ b/src/resources/agency/index.ts @@ -1,4 +1,5 @@ export { Brand } from './brand/brand' export { Domains } from './domains/domains' +export { Members } from './members/members' export { Projects } from './projects/projects' export { Roles } from './roles/roles' diff --git a/src/resources/agency/members/fixtures/member-list.json b/src/resources/agency/members/fixtures/member-list.json new file mode 100644 index 0000000..6ee7db3 --- /dev/null +++ b/src/resources/agency/members/fixtures/member-list.json @@ -0,0 +1,31 @@ +{ + "object": "list", + "data": [ + { + "id": "99bc147e-3ad5-4b09-a2e2-b21bd680ad05", + "object": "member", + "name": "Mara Schuppe", + "avatar_url": null, + "email": "mara@blutui.dev", + "two_factor_enabled": false, + "has_full_access": true, + "role": { + "id": 1, + "name": "Owner", + "description": null, + "is_super": true + }, + "created_at": 1690330767, + "updated_at": 1720481455 + } + ], + "meta": { + "hasMore": false, + "currentPage": 1, + "from": 1, + "to": 1, + "perPage": 10, + "total": 1, + "lastPage": 1 + } +} diff --git a/src/resources/agency/members/fixtures/member.json b/src/resources/agency/members/fixtures/member.json new file mode 100644 index 0000000..22d9c18 --- /dev/null +++ b/src/resources/agency/members/fixtures/member.json @@ -0,0 +1,20 @@ +{ + "id": "99bc147e-3ad5-4b09-a2e2-b21bd680ad05", + "object": "member", + "name": "Mara Schuppe", + "avatar_url": null, + "email": "mara@blutui.dev", + "two_factor_enabled": false, + "has_full_access": true, + "role": { + "id": 1, + "object": "role", + "name": "Owner", + "description": null, + "is_super": true, + "created_at": 1690330767, + "updated_at": 1690330767 + }, + "created_at": 1690330767, + "updated_at": 1720481455 +} diff --git a/src/resources/agency/members/interfaces/index.ts b/src/resources/agency/members/interfaces/index.ts new file mode 100644 index 0000000..18772b1 --- /dev/null +++ b/src/resources/agency/members/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './member.interface' +export * from './update-member-options.interface' diff --git a/src/resources/agency/members/interfaces/member.interface.ts b/src/resources/agency/members/interfaces/member.interface.ts new file mode 100644 index 0000000..79eb1cc --- /dev/null +++ b/src/resources/agency/members/interfaces/member.interface.ts @@ -0,0 +1,27 @@ +import type { Role, RoleResponse } from '../../roles/interfaces' + +export interface Member { + id: string + object: 'member' + name: string + avatar: string | null + email: string + twoFactorEnabled: boolean + hasFullAccess: boolean + role: Omit + createdAt: number + updatedAt: number +} + +export interface MemberResponse { + id: string + object: 'member' + name: string + avatar: string | null + email: string + two_factor_enabled: boolean + has_full_access: boolean + role: Omit + created_at: number + updated_at: number +} diff --git a/src/resources/agency/members/interfaces/update-member-options.interface.ts b/src/resources/agency/members/interfaces/update-member-options.interface.ts new file mode 100644 index 0000000..acea4d5 --- /dev/null +++ b/src/resources/agency/members/interfaces/update-member-options.interface.ts @@ -0,0 +1,9 @@ +export interface UpdateMemberOptions { + role?: number + hasFullAccess?: boolean +} + +export interface SerializedUpdateMemberOptions { + role?: number + has_full_access?: boolean +} diff --git a/src/resources/agency/members/members.spec.ts b/src/resources/agency/members/members.spec.ts new file mode 100644 index 0000000..e5fbc6b --- /dev/null +++ b/src/resources/agency/members/members.spec.ts @@ -0,0 +1,85 @@ +import fetch from 'jest-fetch-mock' +import { Blutui } from '@/blutui' +import { fetchOnce, fetchURL } from '@/utils/testing' + +import memberFixture from './fixtures/member.json' +import memberListFixture from './fixtures/member-list.json' +import { ValidationException } from '@/exceptions' + +const accessToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' +const blutui = new Blutui(accessToken) + +describe('Member', () => { + beforeEach(() => fetch.resetMocks()) + + describe('list', () => { + it('can retrieve a list of members', async () => { + fetchOnce(memberListFixture) + const members = await blutui.agency('foo').members.list() + + expect(fetchURL()).toBe(`${blutui.baseURL}/v1/agencies/foo/members`) + expect(members).toMatchObject({ + object: 'list', + data: [{ hasFullAccess: true }], + }) + }) + }) + + describe('get', () => { + it('can retrieve a member', async () => { + fetchOnce(memberFixture) + const member = await blutui.agency('foo').members.get(memberFixture.id) + + expect(fetchURL()).toBe( + `${blutui.baseURL}/v1/agencies/foo/members/${memberFixture.id}` + ) + expect(member).toMatchObject({ + object: 'member', + }) + }) + }) + + describe('update', () => { + it('can update a member', async () => { + fetchOnce(memberFixture) + const member = await blutui + .agency('foo') + .members.update(memberFixture.id, { role: 2 }) + + expect(fetchURL()).toBe( + `${blutui.baseURL}/v1/agencies/foo/members/${memberFixture.id}` + ) + expect(member).toMatchObject({ + object: 'member', + }) + }) + + it('can not update the current users member information', async () => { + fetchOnce( + { message: 'You can not update your own agency access.' }, + { status: 422 } + ) + + await expect( + blutui.agency('foo').members.update('my-id', { role: 2 }) + ).rejects.toThrow(ValidationException) + }) + }) + + describe('remove', () => { + it('can remove a member from an agency', async () => { + fetchOnce({ id: memberFixture.id, object: 'member', deleted: true }) + const removedMember = await blutui + .agency('foo') + .members.remove(memberFixture.id) + + expect(fetchURL()).toBe( + `${blutui.baseURL}/v1/agencies/foo/members/${memberFixture.id}` + ) + expect(removedMember).toMatchObject({ + object: 'member', + }) + }) + }) +}) diff --git a/src/resources/agency/members/members.ts b/src/resources/agency/members/members.ts new file mode 100644 index 0000000..1ece1e8 --- /dev/null +++ b/src/resources/agency/members/members.ts @@ -0,0 +1,65 @@ +import { + deserializeMember, + deserializeMemberList, + serializeUpdateMemberOptions, +} from './serializers' + +import type { Agency } from '@/agency' +import type { + Member, + MemberResponse, + SerializedUpdateMemberOptions, + UpdateMemberOptions, +} from './interfaces' +import type { + DeletedResponse, + List, + ListResponse, + PaginationOptions, +} from '@/types' + +export class Members { + constructor(private readonly agency: Agency) {} + + /** + * Get a list of members for the current agency. + */ + async list(options?: PaginationOptions): Promise> { + const { data } = await this.agency.get>( + 'members', + { query: options } + ) + + return deserializeMemberList(data) + } + + /** + * Get a member from the current agency. + */ + async get(id: string): Promise { + const { data } = await this.agency.get(`members/${id}`) + + return deserializeMember(data) + } + + /** + * Update a member of the current agency. + */ + async update(id: string, payload: UpdateMemberOptions): Promise { + const { data } = await this.agency.patch< + MemberResponse, + SerializedUpdateMemberOptions + >(`members/${id}`, serializeUpdateMemberOptions(payload)) + + return deserializeMember(data) + } + + /** + * Remove a member from the current agency. + */ + async remove(id: string): Promise { + const { data } = await this.agency.delete(`members/${id}`) + + return data + } +} diff --git a/src/resources/agency/members/serializers/index.ts b/src/resources/agency/members/serializers/index.ts new file mode 100644 index 0000000..7306a1d --- /dev/null +++ b/src/resources/agency/members/serializers/index.ts @@ -0,0 +1,2 @@ +export * from './member.serializer' +export * from './update-member-options.serializer' diff --git a/src/resources/agency/members/serializers/member.serializer.ts b/src/resources/agency/members/serializers/member.serializer.ts new file mode 100644 index 0000000..e8716e2 --- /dev/null +++ b/src/resources/agency/members/serializers/member.serializer.ts @@ -0,0 +1,26 @@ +import { deserializePaginationMeta } from '@/utils/serializers' +import { deserializeRole } from '../../roles/serializers' + +import type { List, ListResponse } from '@/types' +import type { Member, MemberResponse } from '../interfaces' + +export const deserializeMember = (member: MemberResponse): Member => ({ + id: member.id, + object: member.object, + name: member.name, + avatar: member.avatar, + email: member.email, + twoFactorEnabled: member.two_factor_enabled, + hasFullAccess: member.has_full_access, + role: deserializeRole(member.role), + createdAt: member.created_at, + updatedAt: member.updated_at, +}) + +export const deserializeMemberList = ( + members: ListResponse +): List => ({ + object: 'list', + data: members.data.map(deserializeMember), + meta: deserializePaginationMeta(members.meta), +}) diff --git a/src/resources/agency/members/serializers/update-member-options.serializer.ts b/src/resources/agency/members/serializers/update-member-options.serializer.ts new file mode 100644 index 0000000..782c562 --- /dev/null +++ b/src/resources/agency/members/serializers/update-member-options.serializer.ts @@ -0,0 +1,11 @@ +import type { + SerializedUpdateMemberOptions, + UpdateMemberOptions, +} from '../interfaces' + +export const serializeUpdateMemberOptions = ( + options: UpdateMemberOptions +): SerializedUpdateMemberOptions => ({ + role: options.role, + has_full_access: options.hasFullAccess, +}) diff --git a/src/resources/agency/roles/interfaces/role.interface.ts b/src/resources/agency/roles/interfaces/role.interface.ts index 3adb533..f8a88b4 100644 --- a/src/resources/agency/roles/interfaces/role.interface.ts +++ b/src/resources/agency/roles/interfaces/role.interface.ts @@ -24,7 +24,7 @@ export interface Role { description: string isSuper: boolean usersCount?: number - permissions: { [key in Permission]: boolean } + permissions?: { [key in Permission]: boolean } createdAt: number updatedAt: number } @@ -36,7 +36,7 @@ export interface RoleResponse { description: string is_super: boolean users_count?: number - permissions: { [key in Permission]: boolean } + permissions?: { [key in Permission]: boolean } created_at: number updated_at: number } diff --git a/src/resources/agency/roles/roles.spec.ts b/src/resources/agency/roles/roles.spec.ts index e702340..0a3fc63 100644 --- a/src/resources/agency/roles/roles.spec.ts +++ b/src/resources/agency/roles/roles.spec.ts @@ -15,10 +15,10 @@ describe('Role', () => { describe('list', () => { it('can retrieve a list of roles', async () => { fetchOnce(roleListFixture) - const domains = await blutui.agency('foo').roles.list() + const roles = await blutui.agency('foo').roles.list() expect(fetchURL()).toBe(`${blutui.baseURL}/v1/agencies/foo/roles`) - expect(domains).toMatchObject({ + expect(roles).toMatchObject({ object: 'list', }) }) diff --git a/src/resources/agency/roles/serializers/role.serializer.ts b/src/resources/agency/roles/serializers/role.serializer.ts index 14ac59a..e5bd77f 100644 --- a/src/resources/agency/roles/serializers/role.serializer.ts +++ b/src/resources/agency/roles/serializers/role.serializer.ts @@ -3,31 +3,17 @@ import { deserializePaginationMeta } from '@/utils/serializers' import type { List, ListResponse } from '@/types' import type { Role, RoleResponse } from '../interfaces' -export const deserializeRole = (role: RoleResponse): Role => { - const { - id, - object, - name, - description, - is_super: isSuper, - permissions, - created_at: createdAt, - updated_at: updatedAt, - users_count: usersCount, - } = role - - return { - id, - object, - name, - description, - isSuper, - permissions, - createdAt, - updatedAt, - ...(usersCount !== undefined && { usersCount }), - } -} +export const deserializeRole = (role: RoleResponse): Role => ({ + id: role.id, + object: role.object, + name: role.name, + description: role.description, + isSuper: role.is_super, + ...(role.users_count !== undefined && { usersCount: role.users_count }), + ...(role.permissions !== undefined && { permissions: role.permissions }), + createdAt: role.created_at, + updatedAt: role.updated_at, +}) export const deserializeRoleList = ( roles: ListResponse diff --git a/src/utils/testing.ts b/src/utils/testing.ts index 2b7e64a..1ca319f 100644 --- a/src/utils/testing.ts +++ b/src/utils/testing.ts @@ -15,6 +15,10 @@ export function fetchURL() { return fetch.mock.calls[0][0] } +export function fetchSearchParams() { + return Object.fromEntries(new URL(String(fetchURL())).searchParams) +} + export function fetchHeaders() { return fetch.mock.calls[0][1]?.headers }