From 6143d5716c9439e6e49d577f4f465f172138fef1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:45:00 +0000 Subject: [PATCH 1/3] Initial plan From 95bdff25678b2ef4b5bbd67a037a42de54f6aeb0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:00:55 +0000 Subject: [PATCH 2/3] feat: implement admin member management page with membersByCommunityId query --- .../components/member-list.container.graphql | 22 +++ .../components/member-list.container.tsx | 36 ++++ .../admin/components/member-list.stories.tsx | 145 ++++++++++++++ .../layouts/admin/components/member-list.tsx | 146 ++++++++++++++ .../src/components/layouts/admin/index.tsx | 14 +- .../layouts/admin/pages/members.tsx | 19 ++ .../src/contexts/community/member/index.ts | 3 + .../community/member/list-by-community-id.ts | 12 ++ .../types/features/member.resolvers.feature | 12 ++ .../graphql/src/schema/types/member.graphql | 2 +- .../src/schema/types/member.resolvers.test.ts | 184 ++++++++++++++++++ .../src/schema/types/member.resolvers.ts | 8 + 12 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 apps/ui-community/src/components/layouts/admin/components/member-list.container.graphql create mode 100644 apps/ui-community/src/components/layouts/admin/components/member-list.container.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/member-list.stories.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/member-list.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/pages/members.tsx create mode 100644 packages/ocom/application-services/src/contexts/community/member/list-by-community-id.ts diff --git a/apps/ui-community/src/components/layouts/admin/components/member-list.container.graphql b/apps/ui-community/src/components/layouts/admin/components/member-list.container.graphql new file mode 100644 index 000000000..c40454211 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/member-list.container.graphql @@ -0,0 +1,22 @@ +query AdminMemberListContainerMembersByCommunityId($communityId: ObjectID!) { + membersByCommunityId(communityId: $communityId) { + ...AdminMemberListContainerMemberFields + } +} + +fragment AdminMemberListContainerMemberFields on Member { + id + memberName + isAdmin + accounts { + id + firstName + lastName + statusCode + createdAt + } + profile { + email + avatarDocumentId + } +} diff --git a/apps/ui-community/src/components/layouts/admin/components/member-list.container.tsx b/apps/ui-community/src/components/layouts/admin/components/member-list.container.tsx new file mode 100644 index 000000000..11b4e857e --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/member-list.container.tsx @@ -0,0 +1,36 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { + AdminMemberListContainerMembersByCommunityIdDocument, + type AdminMemberListContainerMemberFieldsFragment, +} from '../../../../generated.tsx'; +import { MemberList, type MemberListProps } from './member-list.tsx'; + +export const MemberListContainer: React.FC = () => { + const params = useParams(); + // biome-ignore lint:useLiteralKeys + const communityId = params['communityId'] ?? ''; + const [searchValue, setSearchValue] = useState(''); + + const { data, loading, error } = useQuery(AdminMemberListContainerMembersByCommunityIdDocument, { + variables: { communityId }, + skip: !communityId, + }); + + const memberListProps: MemberListProps = { + data: (data?.membersByCommunityId ?? []) as AdminMemberListContainerMemberFieldsFragment[], + searchValue, + onSearchChange: setSearchValue, + }; + + return ( + } + error={error} + /> + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/member-list.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/member-list.stories.tsx new file mode 100644 index 000000000..943d0d3a7 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/member-list.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from 'storybook/test'; +import type { AdminMemberListContainerMemberFieldsFragment } from '../../../../generated.tsx'; +import { MemberList } from './member-list.tsx'; + +const mockMembers: AdminMemberListContainerMemberFieldsFragment[] = [ + { + __typename: 'Member', + id: '507f1f77bcf86cd799439011', + memberName: 'Smith Residence', + isAdmin: true, + accounts: [ + { + __typename: 'MemberAccount', + id: 'acc-001', + firstName: 'John', + lastName: 'Smith', + statusCode: 'ACCEPTED', + createdAt: '2024-01-10T12:00:00.000Z', + }, + ], + profile: { + __typename: 'MemberProfile', + email: 'john.smith@example.com', + avatarDocumentId: null, + }, + }, + { + __typename: 'Member', + id: '507f1f77bcf86cd799439012', + memberName: 'Johnson Residence', + isAdmin: false, + accounts: [ + { + __typename: 'MemberAccount', + id: 'acc-002', + firstName: 'Jane', + lastName: 'Johnson', + statusCode: 'CREATED', + createdAt: '2024-02-15T12:00:00.000Z', + }, + ], + profile: { + __typename: 'MemberProfile', + email: 'jane.johnson@example.com', + avatarDocumentId: null, + }, + }, + { + __typename: 'Member', + id: '507f1f77bcf86cd799439013', + memberName: 'Williams Residence', + isAdmin: false, + accounts: [ + { + __typename: 'MemberAccount', + id: 'acc-003', + firstName: 'Bob', + lastName: 'Williams', + statusCode: 'REJECTED', + createdAt: '2024-03-01T12:00:00.000Z', + }, + ], + profile: { + __typename: 'MemberProfile', + email: null, + avatarDocumentId: null, + }, + }, + { + __typename: 'Member', + id: '507f1f77bcf86cd799439014', + memberName: 'Davis Residence', + isAdmin: false, + accounts: [ + { + __typename: 'MemberAccount', + id: 'acc-004', + firstName: 'Alice', + lastName: 'Davis', + statusCode: 'ACCEPTED', + createdAt: '2024-01-20T12:00:00.000Z', + }, + { + __typename: 'MemberAccount', + id: 'acc-005', + firstName: 'Tom', + lastName: 'Davis', + statusCode: 'ACCEPTED', + createdAt: '2024-01-20T12:00:00.000Z', + }, + ], + profile: { + __typename: 'MemberProfile', + email: 'alice.davis@example.com', + avatarDocumentId: null, + }, + }, +]; + +const meta = { + title: 'Components/Layouts/Admin/MemberList', + component: MemberList, + parameters: { + layout: 'padded', + }, + argTypes: { + onSearchChange: { action: 'onSearchChange' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: mockMembers, + searchValue: '', + onSearchChange: fn(), + }, +}; + +export const WithSearch: Story = { + args: { + data: mockMembers, + searchValue: 'smith', + onSearchChange: fn(), + }, +}; + +export const Empty: Story = { + args: { + data: [], + searchValue: '', + onSearchChange: fn(), + }, +}; + +export const NoResults: Story = { + args: { + data: mockMembers, + searchValue: 'zzz-no-match', + onSearchChange: fn(), + }, +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/member-list.tsx b/apps/ui-community/src/components/layouts/admin/components/member-list.tsx new file mode 100644 index 000000000..c5bc8f8c6 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/member-list.tsx @@ -0,0 +1,146 @@ +import { Badge, Input, Table, Tag, Typography } from 'antd'; +import dayjs from 'dayjs'; +import type React from 'react'; +import type { AdminMemberListContainerMemberFieldsFragment } from '../../../../generated.tsx'; + +const { Title } = Typography; +const { Search } = Input; + +export interface MemberListProps { + data: AdminMemberListContainerMemberFieldsFragment[]; + searchValue: string; + onSearchChange: (value: string) => void; +} + +const getStatusColor = (statusCode?: string | null): 'success' | 'warning' | 'error' | 'default' => { + switch (statusCode) { + case 'ACCEPTED': + return 'success'; + case 'CREATED': + return 'warning'; + case 'REJECTED': + return 'error'; + default: + return 'default'; + } +}; + +const getStatusLabel = (statusCode?: string | null): string => { + switch (statusCode) { + case 'ACCEPTED': + return 'Active'; + case 'CREATED': + return 'Pending'; + case 'REJECTED': + return 'Rejected'; + default: + return statusCode ?? 'Unknown'; + } +}; + +export const MemberList: React.FC = (props) => { + const filteredMembers = props.data.filter((member) => { + const search = props.searchValue.toLocaleLowerCase(); + if (!search) return true; + const nameMatch = member.memberName?.toLocaleLowerCase().includes(search); + const emailMatch = member.profile?.email?.toLocaleLowerCase().includes(search); + const accountNameMatch = member.accounts.some( + (account) => + account.firstName.toLocaleLowerCase().includes(search) || account.lastName?.toLocaleLowerCase().includes(search), + ); + return nameMatch || emailMatch || accountNameMatch; + }); + + const columns = [ + { + title: 'Member Name', + key: 'memberName', + render: (_: unknown, record: AdminMemberListContainerMemberFieldsFragment) => ( + + {record.memberName ?? '—'} + {record.isAdmin && ( + + Admin + + )} + + ), + }, + { + title: 'Email', + key: 'email', + render: (_: unknown, record: AdminMemberListContainerMemberFieldsFragment) => record.profile?.email ?? '—', + }, + { + title: 'Accounts', + key: 'accounts', + render: (_: unknown, record: AdminMemberListContainerMemberFieldsFragment) => ( +
+ {record.accounts.map((account) => ( +
+ + {account.firstName} {account.lastName ?? ''} + + +
+ ))} +
+ ), + }, + { + title: 'Member Since', + key: 'createdAt', + render: (_: unknown, record: AdminMemberListContainerMemberFieldsFragment) => { + const earliest = record.accounts + .filter((a) => a.createdAt) + .map((a) => new Date(a.createdAt as string).getTime()) + .sort((a, b) => a - b)[0]; + return earliest ? dayjs(earliest).format('MM/DD/YYYY') : '—'; + }, + }, + ]; + + const tableData = filteredMembers.map((member) => ({ + ...member, + key: member.id, + })); + + return ( +
+
+ Community Members + props.onSearchChange(e.target.value)} + style={{ width: 280 }} + allowClear + /> +
+ {filteredMembers.length > 0 ? ( + + ) : ( + + No members found. + + )} + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/index.tsx b/apps/ui-community/src/components/layouts/admin/index.tsx index 07b046e10..f18af0f3a 100644 --- a/apps/ui-community/src/components/layouts/admin/index.tsx +++ b/apps/ui-community/src/components/layouts/admin/index.tsx @@ -1,7 +1,8 @@ -import { HomeOutlined, SettingOutlined } from '@ant-design/icons'; +import { HomeOutlined, SettingOutlined, TeamOutlined } from '@ant-design/icons'; import { Route, Routes } from 'react-router-dom'; import type { Member } from '../../../generated.tsx'; import { Home } from './pages/home.tsx'; +import { Members } from './pages/members.tsx'; import { Settings } from './pages/settings.tsx'; import { SectionLayoutContainer } from './section-layout.container.tsx'; @@ -22,6 +23,13 @@ export const Admin: React.FC = () => { icon: , id: 'ROOT', }, + { + path: '/community/:communityId/admin/:memberId/members', + title: 'Members', + icon: , + id: 3, + parent: 'ROOT', + }, { path: '/community/:communityId/admin/:memberId/settings/*', title: 'Settings', @@ -44,6 +52,10 @@ export const Admin: React.FC = () => { path="" element={} /> + } + /> } diff --git a/apps/ui-community/src/components/layouts/admin/pages/members.tsx b/apps/ui-community/src/components/layouts/admin/pages/members.tsx new file mode 100644 index 000000000..8dac9a873 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members.tsx @@ -0,0 +1,19 @@ +import { PageHeader } from '@ant-design/pro-layout'; +import { theme } from 'antd'; +import { MemberListContainer } from '../components/member-list.container.tsx'; +import { SubPageLayout } from '../sub-page-layout.tsx'; + +export const Members: React.FC = () => { + const { + token: { colorTextBase }, + } = theme.useToken(); + + return ( + Members} />} + > + + + ); +}; diff --git a/packages/ocom/application-services/src/contexts/community/member/index.ts b/packages/ocom/application-services/src/contexts/community/member/index.ts index 7360e1f40..ff0608e7c 100644 --- a/packages/ocom/application-services/src/contexts/community/member/index.ts +++ b/packages/ocom/application-services/src/contexts/community/member/index.ts @@ -2,15 +2,18 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; import { type MemberQueryByEndUserExternalIdCommand, queryByEndUserExternalId } from './query-by-end-user-external-id.ts'; import { determineIfAdmin, type MemberDetermineIfAdminCommand } from './determine-if-admin.ts'; +import { type MemberListByCommunityIdCommand, listByCommunityId } from './list-by-community-id.ts'; export interface MemberApplicationService { determineIfAdmin: (command: MemberDetermineIfAdminCommand) => Promise; queryByEndUserExternalId: (command: MemberQueryByEndUserExternalIdCommand) => Promise; + listByCommunityId: (command: MemberListByCommunityIdCommand) => Promise; } export const Member = (dataSources: DataSources): MemberApplicationService => { return { determineIfAdmin: determineIfAdmin(dataSources), queryByEndUserExternalId: queryByEndUserExternalId(dataSources), + listByCommunityId: listByCommunityId(dataSources), }; }; diff --git a/packages/ocom/application-services/src/contexts/community/member/list-by-community-id.ts b/packages/ocom/application-services/src/contexts/community/member/list-by-community-id.ts new file mode 100644 index 000000000..9e0467de3 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/community/member/list-by-community-id.ts @@ -0,0 +1,12 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface MemberListByCommunityIdCommand { + communityId: string; +} + +export const listByCommunityId = (dataSources: DataSources) => { + return async (command: MemberListByCommunityIdCommand): Promise => { + return await dataSources.readonlyDataSource.Community.Member.MemberReadRepo.getByCommunityId(command.communityId); + }; +}; diff --git a/packages/ocom/graphql/src/schema/types/features/member.resolvers.feature b/packages/ocom/graphql/src/schema/types/features/member.resolvers.feature index 4847f7bcc..6f17f7ba2 100644 --- a/packages/ocom/graphql/src/schema/types/features/member.resolvers.feature +++ b/packages/ocom/graphql/src/schema/types/features/member.resolvers.feature @@ -25,3 +25,15 @@ Feature: Member resolvers Given a user without a verified JWT When the membersForCurrentEndUser query is executed Then it should throw an "Unauthorized" error + + Scenario: Querying members by community ID + Given a signed in user with subject "user-sub-456" + And the member service can return members for community "community-abc" + When the membersByCommunityId query is executed with communityId "community-abc" + Then it should call Community.Member.listByCommunityId with communityId "community-abc" + And it should return the list of members for that community + + Scenario: Querying members by community ID without authentication + Given a user without a verified JWT + When the membersByCommunityId query is executed with communityId "community-abc" + Then it should throw an "Unauthorized" error diff --git a/packages/ocom/graphql/src/schema/types/member.graphql b/packages/ocom/graphql/src/schema/types/member.graphql index 364eb0bd6..7c38d1d43 100644 --- a/packages/ocom/graphql/src/schema/types/member.graphql +++ b/packages/ocom/graphql/src/schema/types/member.graphql @@ -41,7 +41,7 @@ type MemberProfile { extend type Query { # member(id: ObjectID!): Member! - # membersByCommunityId(communityId: ObjectID!): [Member!]! + membersByCommunityId(communityId: ObjectID!): [Member!]! # memberForCurrentCommunity: Member! membersForCurrentEndUser: [Member!]! } diff --git a/packages/ocom/graphql/src/schema/types/member.resolvers.test.ts b/packages/ocom/graphql/src/schema/types/member.resolvers.test.ts index f6ba987b9..67fb9a628 100644 --- a/packages/ocom/graphql/src/schema/types/member.resolvers.test.ts +++ b/packages/ocom/graphql/src/schema/types/member.resolvers.test.ts @@ -41,6 +41,7 @@ function makeMockGraphContext(overrides: Partial = {}): GraphConte Member: { determineIfAdmin: vi.fn(), queryByEndUserExternalId: vi.fn(), + listByCommunityId: vi.fn(), }, }, verifiedUser: { @@ -179,4 +180,187 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { // Assertion performed in When step }); }); + + Scenario('Querying members by community ID', ({ Given, And, When, Then }) => { + const resolvedMembers = [createMockMember({ id: 'member-99', communityId: 'community-abc' })]; + const domainMembers = resolvedMembers as unknown as MemberReference[]; + + Given('a signed in user with subject "user-sub-456"', () => { + if (context.applicationServices.verifiedUser?.verifiedJwt) { + context.applicationServices.verifiedUser.verifiedJwt.sub = 'user-sub-456'; + } + }); + + And('the member service can return members for community "community-abc"', () => { + vi.mocked(context.applicationServices.Community.Member.listByCommunityId).mockResolvedValue(domainMembers); + }); + + When('the membersByCommunityId query is executed with communityId "community-abc"', async () => { + membersResult = await (memberResolvers.Query?.membersByCommunityId as unknown as (parent: unknown, args: { communityId: string }, graphContext: GraphContext, info: unknown) => Promise)( + null, + { communityId: 'community-abc' }, + context, + {}, + ); + }); + + Then('it should call Community.Member.listByCommunityId with communityId "community-abc"', () => { + expect(context.applicationServices.Community.Member.listByCommunityId).toHaveBeenCalledWith({ + communityId: 'community-abc', + }); + }); + + And('it should return the list of members for that community', () => { + expect(membersResult as MemberEntity[]).toEqual(resolvedMembers); + }); + }); + + Scenario('Querying members by community ID without authentication', ({ Given, When, Then }) => { + Given('a user without a verified JWT', () => { + if (context.applicationServices.verifiedUser) { + context.applicationServices.verifiedUser.verifiedJwt = undefined; + } + }); + + When('the membersByCommunityId query is executed with communityId "community-abc"', async () => { + await expect( + (memberResolvers.Query?.membersByCommunityId as unknown as (parent: unknown, args: { communityId: string }, graphContext: GraphContext, info: unknown) => Promise)( + null, + { communityId: 'community-abc' }, + context, + {}, + ), + ).rejects.toThrow('Unauthorized'); + }); + + Then('it should throw an "Unauthorized" error', () => { + // Assertion performed in When step + }); + }); +}); + + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let context: GraphContext; + let member: MemberEntity; + let communityResult: unknown; + let booleanResult: boolean | null; + let membersResult: unknown; + + BeforeEachScenario(() => { + context = makeMockGraphContext(); + member = createMockMember(); + communityResult = null; + booleanResult = null; + membersResult = null; + vi.clearAllMocks(); + }); + + Scenario('Resolving the community for a member', ({ Given, And, When, Then }) => { + const resolvedCommunity = createMockCommunity(); + const domainCommunity = resolvedCommunity as unknown as CommunityReference; + + Given('a member with communityId "community-123"', () => { + member = createMockMember({ communityId: 'community-123' }); + }); + + And('the community service can return that community', () => { + vi.mocked(context.applicationServices.Community.Community.queryById).mockResolvedValue(domainCommunity); + }); + + When('the Member.community resolver is executed', async () => { + communityResult = await (memberResolvers.Member?.community as unknown as (parent: MemberEntity, args: Record, graphContext: GraphContext, info: unknown) => Promise)( + member, + {}, + context, + {}, + ); + }); + + Then("it should call Community.Community.queryById with the member's communityId", () => { + expect(context.applicationServices.Community.Community.queryById).toHaveBeenCalledWith({ + id: 'community-123', + }); + }); + + And('it should return the resolved community', () => { + expect(communityResult as CommunityEntity).toEqual(resolvedCommunity); + }); + }); + + Scenario('Checking if a member is an administrator', ({ Given, And, When, Then }) => { + Given('a member with id "member-1"', () => { + member = createMockMember({ id: 'member-1' }); + }); + + And('the member service indicates the member is an admin', () => { + vi.mocked(context.applicationServices.Community.Member.determineIfAdmin).mockResolvedValue(true); + }); + + When('the Member.isAdmin resolver is executed', async () => { + booleanResult = await (memberResolvers.Member?.isAdmin as unknown as (parent: MemberEntity, args: Record, graphContext: GraphContext, info: unknown) => Promise)(member, {}, context, {}); + }); + + Then("it should call Community.Member.determineIfAdmin with the member's id", () => { + expect(context.applicationServices.Community.Member.determineIfAdmin).toHaveBeenCalledWith({ + memberId: 'member-1', + }); + }); + + And('it should return true', () => { + expect(booleanResult).toBe(true); + }); + }); + + Scenario('Querying members for the current end user', ({ Given, And, When, Then }) => { + const resolvedMembers = [createMockMember({ id: 'member-42' })]; + const domainMembers = resolvedMembers as unknown as MemberReference[]; + + Given('a signed in user with subject "user-sub-123"', () => { + if (context.applicationServices.verifiedUser?.verifiedJwt) { + context.applicationServices.verifiedUser.verifiedJwt.sub = 'user-sub-123'; + } + }); + + And('the member service can return members for that subject', () => { + vi.mocked(context.applicationServices.Community.Member.queryByEndUserExternalId).mockResolvedValue(domainMembers); + }); + + When('the membersForCurrentEndUser query is executed', async () => { + membersResult = await (memberResolvers.Query?.membersForCurrentEndUser as unknown as (parent: unknown, args: Record, graphContext: GraphContext, info: unknown) => Promise)( + null, + {}, + context, + {}, + ); + }); + + Then('it should call Community.Member.queryByEndUserExternalId with the subject', () => { + expect(context.applicationServices.Community.Member.queryByEndUserExternalId).toHaveBeenCalledWith({ + externalId: 'user-sub-123', + }); + }); + + And('it should return the list of members', () => { + expect(membersResult as MemberEntity[]).toEqual(resolvedMembers); + }); + }); + + Scenario('Querying members for the current end user without authentication', ({ Given, When, Then }) => { + Given('a user without a verified JWT', () => { + if (context.applicationServices.verifiedUser) { + context.applicationServices.verifiedUser.verifiedJwt = undefined; + } + }); + + When('the membersForCurrentEndUser query is executed', async () => { + await expect( + (memberResolvers.Query?.membersForCurrentEndUser as unknown as (parent: unknown, args: Record, graphContext: GraphContext, info: unknown) => Promise)(null, {}, context, {}), + ).rejects.toThrow('Unauthorized'); + }); + + Then('it should throw an "Unauthorized" error', () => { + // Assertion performed in When step + }); + }); }); diff --git a/packages/ocom/graphql/src/schema/types/member.resolvers.ts b/packages/ocom/graphql/src/schema/types/member.resolvers.ts index e640d6c1b..d579cfb4b 100644 --- a/packages/ocom/graphql/src/schema/types/member.resolvers.ts +++ b/packages/ocom/graphql/src/schema/types/member.resolvers.ts @@ -28,6 +28,14 @@ const member: Resolvers = { externalId, }); }, + membersByCommunityId: async (_parent, args: { communityId: string }, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + return await context.applicationServices.Community.Member.listByCommunityId({ + communityId: args.communityId, + }); + }, }, }; From 29d1b4fb9eda5c5399b0736e60271aa910ff1657 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:38:33 +0000 Subject: [PATCH 3/3] Changes before error encountered Agent-Logs-Url: https://github.com/CellixJs/cellixjs/sessions/369b5d67-1026-4755-8b63-c41d4beb1d8b --- .npmrc | 2 +- .../components/member-list.container.tsx | 32 ++-- .../admin/components/member-list.stories.tsx | 17 ++ .../layouts/admin/components/member-list.tsx | 47 ++--- .../members-accounts-list.container.graphql | 29 +++ .../members-accounts-list.container.tsx | 55 ++++++ .../components/members-accounts-list.tsx | 93 +++++++++ .../members-create.container.graphql | 18 ++ .../components/members-create.container.tsx | 44 +++++ .../admin/components/members-create.tsx | 43 +++++ .../members-detail.container.graphql | 37 ++++ .../components/members-detail.container.tsx | 71 +++++++ .../admin/components/members-detail.tsx | 56 ++++++ .../src/components/layouts/admin/index.tsx | 2 +- .../layouts/admin/pages/members.tsx | 18 +- package.json | 2 +- .../contexts/community/member/add-account.ts | 36 ++++ .../src/contexts/community/member/create.ts | 25 +++ .../contexts/community/member/edit-account.ts | 40 ++++ .../contexts/community/member/get-by-id.ts | 12 ++ .../src/contexts/community/member/index.ts | 18 ++ .../community/member/remove-account.ts | 29 +++ .../src/contexts/community/member/update.ts | 27 +++ .../community/member/member.repository.ts | 1 + .../types/features/member.resolvers.feature | 36 ++++ .../graphql/src/schema/types/member.graphql | 45 ++++- .../src/schema/types/member.resolvers.test.ts | 178 +++++++++++------- .../src/schema/types/member.resolvers.ts | 100 +++++++++- .../community/member/member.repository.ts | 8 + 29 files changed, 1008 insertions(+), 113 deletions(-) create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.graphql create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-accounts-list.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-create.container.graphql create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-create.container.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-create.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-detail.container.graphql create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-detail.container.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-detail.tsx create mode 100644 packages/ocom/application-services/src/contexts/community/member/add-account.ts create mode 100644 packages/ocom/application-services/src/contexts/community/member/create.ts create mode 100644 packages/ocom/application-services/src/contexts/community/member/edit-account.ts create mode 100644 packages/ocom/application-services/src/contexts/community/member/get-by-id.ts create mode 100644 packages/ocom/application-services/src/contexts/community/member/remove-account.ts create mode 100644 packages/ocom/application-services/src/contexts/community/member/update.ts diff --git a/.npmrc b/.npmrc index 4fd021952..b6f27f135 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -engine-strict=true \ No newline at end of file +engine-strict=true diff --git a/apps/ui-community/src/components/layouts/admin/components/member-list.container.tsx b/apps/ui-community/src/components/layouts/admin/components/member-list.container.tsx index 11b4e857e..4a39a24c1 100644 --- a/apps/ui-community/src/components/layouts/admin/components/member-list.container.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/member-list.container.tsx @@ -1,11 +1,9 @@ import { useQuery } from '@apollo/client'; import { ComponentQueryLoader } from '@cellix/ui-core'; +import { Button } from 'antd'; import { useState } from 'react'; -import { useParams } from 'react-router-dom'; -import { - AdminMemberListContainerMembersByCommunityIdDocument, - type AdminMemberListContainerMemberFieldsFragment, -} from '../../../../generated.tsx'; +import { useNavigate, useParams } from 'react-router-dom'; +import { AdminMemberListContainerMembersByCommunityIdDocument, type AdminMemberListContainerMemberFieldsFragment } from '../../../../generated.tsx'; import { MemberList, type MemberListProps } from './member-list.tsx'; export const MemberListContainer: React.FC = () => { @@ -13,6 +11,7 @@ export const MemberListContainer: React.FC = () => { // biome-ignore lint:useLiteralKeys const communityId = params['communityId'] ?? ''; const [searchValue, setSearchValue] = useState(''); + const navigate = useNavigate(); const { data, loading, error } = useQuery(AdminMemberListContainerMembersByCommunityIdDocument, { variables: { communityId }, @@ -23,14 +22,25 @@ export const MemberListContainer: React.FC = () => { data: (data?.membersByCommunityId ?? []) as AdminMemberListContainerMemberFieldsFragment[], searchValue, onSearchChange: setSearchValue, + communityId, }; return ( - } - error={error} - /> +
+
+ +
+ } + error={error} + /> +
); }; diff --git a/apps/ui-community/src/components/layouts/admin/components/member-list.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/member-list.stories.tsx index 943d0d3a7..790ad74ab 100644 --- a/apps/ui-community/src/components/layouts/admin/components/member-list.stories.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/member-list.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { fn } from 'storybook/test'; import type { AdminMemberListContainerMemberFieldsFragment } from '../../../../generated.tsx'; import { MemberList } from './member-list.tsx'; @@ -107,6 +108,18 @@ const meta = { argTypes: { onSearchChange: { action: 'onSearchChange' }, }, + decorators: [ + (Story) => ( + + + } + /> + + + ), + ], } satisfies Meta; export default meta; @@ -117,6 +130,7 @@ export const Default: Story = { data: mockMembers, searchValue: '', onSearchChange: fn(), + communityId: 'community-123', }, }; @@ -125,6 +139,7 @@ export const WithSearch: Story = { data: mockMembers, searchValue: 'smith', onSearchChange: fn(), + communityId: 'community-123', }, }; @@ -133,6 +148,7 @@ export const Empty: Story = { data: [], searchValue: '', onSearchChange: fn(), + communityId: 'community-123', }, }; @@ -141,5 +157,6 @@ export const NoResults: Story = { data: mockMembers, searchValue: 'zzz-no-match', onSearchChange: fn(), + communityId: 'community-123', }, }; diff --git a/apps/ui-community/src/components/layouts/admin/components/member-list.tsx b/apps/ui-community/src/components/layouts/admin/components/member-list.tsx index c5bc8f8c6..bbb43990b 100644 --- a/apps/ui-community/src/components/layouts/admin/components/member-list.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/member-list.tsx @@ -1,15 +1,16 @@ -import { Badge, Input, Table, Tag, Typography } from 'antd'; +import { Badge, Button, Input, Table, Tag } from 'antd'; import dayjs from 'dayjs'; import type React from 'react'; +import { useNavigate } from 'react-router-dom'; import type { AdminMemberListContainerMemberFieldsFragment } from '../../../../generated.tsx'; -const { Title } = Typography; const { Search } = Input; export interface MemberListProps { data: AdminMemberListContainerMemberFieldsFragment[]; searchValue: string; onSearchChange: (value: string) => void; + communityId: string; } const getStatusColor = (statusCode?: string | null): 'success' | 'warning' | 'error' | 'default' => { @@ -39,19 +40,30 @@ const getStatusLabel = (statusCode?: string | null): string => { }; export const MemberList: React.FC = (props) => { + const navigate = useNavigate(); const filteredMembers = props.data.filter((member) => { const search = props.searchValue.toLocaleLowerCase(); if (!search) return true; const nameMatch = member.memberName?.toLocaleLowerCase().includes(search); const emailMatch = member.profile?.email?.toLocaleLowerCase().includes(search); - const accountNameMatch = member.accounts.some( - (account) => - account.firstName.toLocaleLowerCase().includes(search) || account.lastName?.toLocaleLowerCase().includes(search), - ); + const accountNameMatch = member.accounts.some((account) => account.firstName.toLocaleLowerCase().includes(search) || account.lastName?.toLocaleLowerCase().includes(search)); return nameMatch || emailMatch || accountNameMatch; }); const columns = [ + { + title: 'Action', + key: 'action', + render: (_: unknown, record: AdminMemberListContainerMemberFieldsFragment) => ( + + ), + }, { title: 'Member Name', key: 'memberName', @@ -117,7 +129,6 @@ export const MemberList: React.FC = (props) => { return (
- Community Members = (props) => { allowClear />
- {filteredMembers.length > 0 ? ( -
- ) : ( - - No members found. - - )} +
); }; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.graphql b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.graphql new file mode 100644 index 000000000..dce27d47d --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.graphql @@ -0,0 +1,29 @@ +query AdminMembersAccountsListContainerMember($id: ObjectID!) { + member(id: $id) { + ...AdminMembersAccountsListContainerMemberFields + } +} + +mutation AdminMembersAccountsListContainerMemberAccountRemove($input: MemberAccountRemoveInput!) { + memberAccountRemove(input: $input) { + status { + success + errorMessage + } + member { + ...AdminMembersAccountsListContainerMemberFields + } + } +} + +fragment AdminMembersAccountsListContainerMemberFields on Member { + id + accounts { + id + firstName + lastName + statusCode + createdAt + updatedAt + } +} diff --git a/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.tsx b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.tsx new file mode 100644 index 000000000..08e9cf619 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.tsx @@ -0,0 +1,55 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import { App } from 'antd'; +import { useParams } from 'react-router-dom'; +import { AdminMembersAccountsListContainerMemberAccountRemoveDocument, AdminMembersAccountsListContainerMemberDocument, type AdminMembersAccountsListContainerMemberFieldsFragment } from '../../../../generated.tsx'; +import { MembersAccountsList, type MembersAccountsListProps } from './members-accounts-list.tsx'; + +export const MembersAccountsListContainer: React.FC = () => { + const { message } = App.useApp(); + const params = useParams(); + // biome-ignore lint:useLiteralKeys + const memberId = params['memberId'] ?? ''; + + const { data, loading, error } = useQuery(AdminMembersAccountsListContainerMemberDocument, { + variables: { id: memberId }, + skip: !memberId, + }); + + const [removeAccount, { loading: removeLoading, error: removeError }] = useMutation(AdminMembersAccountsListContainerMemberAccountRemoveDocument); + + const handleRemove = async (accountId: string) => { + try { + const result = await removeAccount({ + variables: { + input: { + memberId, + accountId, + }, + }, + }); + if (result.data?.memberAccountRemove?.status?.success) { + message.success('Account removed'); + } else { + message.error(result.data?.memberAccountRemove?.status?.errorMessage ?? 'Unknown error'); + } + } catch (removeErr) { + message.error(`Error removing account: ${removeErr instanceof Error ? removeErr.message : JSON.stringify(removeErr)}`); + } + }; + + const listProps: MembersAccountsListProps = { + data: data?.member as AdminMembersAccountsListContainerMemberFieldsFragment, + onRemove: handleRemove, + removeLoading, + }; + + return ( + } + error={error ?? removeError} + /> + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.tsx b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.tsx new file mode 100644 index 000000000..888a4fef5 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.tsx @@ -0,0 +1,93 @@ +import { Badge, Button, Popconfirm, Table } from 'antd'; +import dayjs from 'dayjs'; +import type React from 'react'; +import type { AdminMembersAccountsListContainerMemberFieldsFragment } from '../../../../generated.tsx'; + +type AccountType = AdminMembersAccountsListContainerMemberFieldsFragment['accounts'][number]; + +const getStatusColor = (statusCode?: string | null): 'success' | 'warning' | 'error' | 'default' => { + switch (statusCode) { + case 'ACCEPTED': + return 'success'; + case 'CREATED': + return 'warning'; + case 'REJECTED': + return 'error'; + default: + return 'default'; + } +}; + +const getStatusLabel = (statusCode?: string | null): string => { + switch (statusCode) { + case 'ACCEPTED': + return 'Active'; + case 'CREATED': + return 'Pending'; + case 'REJECTED': + return 'Rejected'; + default: + return statusCode ?? 'Unknown'; + } +}; + +export interface MembersAccountsListProps { + data: AdminMembersAccountsListContainerMemberFieldsFragment; + onRemove: (accountId: string) => Promise; + removeLoading?: boolean; +} + +export const MembersAccountsList: React.FC = (props) => { + const columns = [ + { + title: 'Name', + key: 'name', + render: (_: unknown, record: AccountType) => `${record.firstName} ${record.lastName ?? ''}`.trim(), + }, + { + title: 'Status', + key: 'statusCode', + render: (_: unknown, record: AccountType) => ( + + ), + }, + { + title: 'Created', + key: 'createdAt', + render: (_: unknown, record: AccountType) => (record.createdAt ? dayjs(record.createdAt as string).format('MM/DD/YYYY') : '—'), + }, + { + title: 'Action', + key: 'action', + render: (_: unknown, record: AccountType) => ( + props.onRemove(record.id)} + okText="Yes" + cancelText="No" + > + + + ), + }, + ]; + + return ( +
({ ...a, key: a.id }))} + columns={columns} + pagination={false} + size="small" + /> + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-create.container.graphql b/apps/ui-community/src/components/layouts/admin/components/members-create.container.graphql new file mode 100644 index 000000000..b68c32e35 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-create.container.graphql @@ -0,0 +1,18 @@ +mutation AdminMembersCreateContainerMemberCreate($input: MemberCreateInput!) { + memberCreate(input: $input) { + status { + success + errorMessage + } + member { + ...AdminMembersCreateContainerMemberFields + } + } +} + +fragment AdminMembersCreateContainerMemberFields on Member { + id + memberName + createdAt + updatedAt +} diff --git a/apps/ui-community/src/components/layouts/admin/components/members-create.container.tsx b/apps/ui-community/src/components/layouts/admin/components/members-create.container.tsx new file mode 100644 index 000000000..d44d5cacd --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-create.container.tsx @@ -0,0 +1,44 @@ +import { useMutation } from '@apollo/client'; +import { App } from 'antd'; +import { useNavigate, useParams } from 'react-router-dom'; +import { AdminMembersCreateContainerMemberCreateDocument, type MemberCreateInput } from '../../../../generated.tsx'; +import { MembersCreate, type MembersCreateProps } from './members-create.tsx'; + +export const MembersCreateContainer: React.FC = () => { + const { message } = App.useApp(); + const navigate = useNavigate(); + const params = useParams(); + // biome-ignore lint:useLiteralKeys + const communityId = params['communityId'] ?? ''; + + const [memberCreate, { loading }] = useMutation(AdminMembersCreateContainerMemberCreateDocument); + + const handleSave = async (values: MemberCreateInput) => { + try { + const result = await memberCreate({ + variables: { + input: { + memberName: values.memberName, + communityId, + }, + }, + }); + if (result.data?.memberCreate?.status?.success && result.data.memberCreate.member) { + message.success('Member created'); + navigate(`../${result.data.memberCreate.member.id}`); + } else { + message.error(result.data?.memberCreate?.status?.errorMessage ?? 'Unknown error'); + } + } catch (createError) { + message.error(`Error creating member: ${createError instanceof Error ? createError.message : JSON.stringify(createError)}`); + } + }; + + const createProps: MembersCreateProps = { + communityId, + onSave: handleSave, + loading, + }; + + return ; +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-create.tsx b/apps/ui-community/src/components/layouts/admin/components/members-create.tsx new file mode 100644 index 000000000..4dee6ef79 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-create.tsx @@ -0,0 +1,43 @@ +import { Button, Form, Input } from 'antd'; +import type React from 'react'; +import type { MemberCreateInput } from '../../../../generated.tsx'; + +export interface MembersCreateProps { + communityId: string; + onSave: (values: MemberCreateInput) => Promise; + loading?: boolean; +} + +export const MembersCreate: React.FC = (props) => { + const [form] = Form.useForm(); + + return ( +
{ + props.onSave({ ...values, communityId: props.communityId }); + }} + > + + + + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-detail.container.graphql b/apps/ui-community/src/components/layouts/admin/components/members-detail.container.graphql new file mode 100644 index 000000000..25770a821 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-detail.container.graphql @@ -0,0 +1,37 @@ +query AdminMembersDetailContainerMember($id: ObjectID!) { + member(id: $id) { + ...AdminMembersDetailContainerMemberFields + } +} + +mutation AdminMembersDetailContainerMemberUpdate($input: MemberUpdateInput!) { + memberUpdate(input: $input) { + status { + success + errorMessage + } + member { + ...AdminMembersDetailContainerMemberFields + } + } +} + +fragment AdminMembersDetailContainerMemberFields on Member { + id + memberName + isAdmin + accounts { + id + firstName + lastName + statusCode + createdAt + updatedAt + } + profile { + email + avatarDocumentId + } + createdAt + updatedAt +} diff --git a/apps/ui-community/src/components/layouts/admin/components/members-detail.container.tsx b/apps/ui-community/src/components/layouts/admin/components/members-detail.container.tsx new file mode 100644 index 000000000..7035b0d8d --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-detail.container.tsx @@ -0,0 +1,71 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import { App, Divider, Typography } from 'antd'; +import { useParams } from 'react-router-dom'; +import { AdminMembersDetailContainerMemberDocument, AdminMembersDetailContainerMemberUpdateDocument, type AdminMembersDetailContainerMemberFieldsFragment, type MemberUpdateInput } from '../../../../generated.tsx'; +import { MembersAccountsListContainer } from './members-accounts-list.container.tsx'; +import { MembersDetail, type MembersDetailProps } from './members-detail.tsx'; + +const { Title } = Typography; + +export const MembersDetailContainer: React.FC = () => { + const { message } = App.useApp(); + const params = useParams(); + // biome-ignore lint:useLiteralKeys + const memberId = params['memberId'] ?? ''; + + const { data, loading, error } = useQuery(AdminMembersDetailContainerMemberDocument, { + variables: { id: memberId }, + skip: !memberId, + }); + + const [memberUpdate, { loading: mutationLoading, error: mutationError }] = useMutation(AdminMembersDetailContainerMemberUpdateDocument); + + const handleSave = async (values: MemberUpdateInput) => { + if (!data?.member?.id) { + message.error('Member not found'); + return; + } + try { + const result = await memberUpdate({ + variables: { + input: { + id: data.member.id, + memberName: values.memberName, + }, + }, + }); + if (result.data?.memberUpdate?.status?.success) { + message.success('Saved'); + } else { + message.error(result.data?.memberUpdate?.status?.errorMessage ?? 'Unknown error'); + } + } catch (saveError) { + message.error(`Error updating member: ${saveError instanceof Error ? saveError.message : JSON.stringify(saveError)}`); + } + }; + + const detailProps: MembersDetailProps = { + onSave: handleSave, + data: data?.member as AdminMembersDetailContainerMemberFieldsFragment, + loading: mutationLoading, + }; + + return ( + <> + } + error={error ?? mutationError} + /> + {data?.member && ( + <> + + Accounts + + + )} + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-detail.tsx b/apps/ui-community/src/components/layouts/admin/components/members-detail.tsx new file mode 100644 index 000000000..60de7c5c5 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-detail.tsx @@ -0,0 +1,56 @@ +import { Button, Descriptions, Form, Input } from 'antd'; +import dayjs from 'dayjs'; +import type React from 'react'; +import type { AdminMembersDetailContainerMemberFieldsFragment, MemberUpdateInput } from '../../../../generated.tsx'; + +export interface MembersDetailProps { + data: AdminMembersDetailContainerMemberFieldsFragment; + onSave: (values: MemberUpdateInput) => Promise; + loading?: boolean; +} + +export const MembersDetail: React.FC = (props) => { + const [form] = Form.useForm(); + const data = props.data; + + return ( + <> + + {data.id} + {data.createdAt ? dayjs(data.createdAt as string).format('MM/DD/YYYY') : '—'} + {data.updatedAt ? dayjs(data.updatedAt as string).format('MM/DD/YYYY') : '—'} + +
{ + props.onSave(values); + }} + > + + + + + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/index.tsx b/apps/ui-community/src/components/layouts/admin/index.tsx index f18af0f3a..2899d54f5 100644 --- a/apps/ui-community/src/components/layouts/admin/index.tsx +++ b/apps/ui-community/src/components/layouts/admin/index.tsx @@ -53,7 +53,7 @@ export const Admin: React.FC = () => { element={} /> } /> { @@ -13,7 +16,20 @@ export const Members: React.FC = () => { fixedHeader={false} header={Members} />} > - + + } + /> + } + /> + } + /> + ); }; diff --git a/package.json b/package.json index 083bfdb51..8917819fe 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,6 @@ } }, "engines": { - "node": "22.22.2" + "node": ">=22.22.1" } } diff --git a/packages/ocom/application-services/src/contexts/community/member/add-account.ts b/packages/ocom/application-services/src/contexts/community/member/add-account.ts new file mode 100644 index 000000000..31abe4c60 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/community/member/add-account.ts @@ -0,0 +1,36 @@ +import { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface MemberAddAccountCommand { + memberId: string; + firstName: string; + lastName: string; + userId: string; +} + +export const addAccount = (dataSources: DataSources) => { + return async (command: MemberAddAccountCommand): Promise => { + const user = await dataSources.readonlyDataSource.User.EndUser.EndUserReadRepo.getById(command.userId); + if (!user) { + throw new Error(`User not found for id ${command.userId}`); + } + let memberToReturn: Domain.Contexts.Community.Member.MemberEntityReference | undefined; + await dataSources.domainDataSource.Community.Member.MemberUnitOfWork.withScopedTransaction(async (repo) => { + const member = await repo.getById(command.memberId); + if (!member) { + throw new Error(`Member not found for id ${command.memberId}`); + } + const account = member.requestNewAccount(); + account.firstName = command.firstName; + account.lastName = command.lastName; + account.user = user; + account.createdBy = user; + account.statusCode = Domain.Contexts.Community.Member.MemberAccountStatusCodes.Created; + memberToReturn = await repo.save(member); + }); + if (!memberToReturn) { + throw new Error('member not found'); + } + return memberToReturn; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/community/member/create.ts b/packages/ocom/application-services/src/contexts/community/member/create.ts new file mode 100644 index 000000000..b84c5c127 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/community/member/create.ts @@ -0,0 +1,25 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface MemberCreateCommand { + memberName: string; + communityId: string; +} + +export const create = (dataSources: DataSources) => { + return async (command: MemberCreateCommand): Promise => { + const community = await dataSources.readonlyDataSource.Community.Community.CommunityReadRepo.getById(command.communityId); + if (!community) { + throw new Error(`Community not found for id ${command.communityId}`); + } + let memberToReturn: Domain.Contexts.Community.Member.MemberEntityReference | undefined; + await dataSources.domainDataSource.Community.Member.MemberUnitOfWork.withScopedTransaction(async (repo) => { + const newMember = await repo.getNewInstance(command.memberName, community); + memberToReturn = await repo.save(newMember); + }); + if (!memberToReturn) { + throw new Error('member not created'); + } + return memberToReturn; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/community/member/edit-account.ts b/packages/ocom/application-services/src/contexts/community/member/edit-account.ts new file mode 100644 index 000000000..e7bd1119f --- /dev/null +++ b/packages/ocom/application-services/src/contexts/community/member/edit-account.ts @@ -0,0 +1,40 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface MemberEditAccountCommand { + memberId: string; + accountId: string; + firstName?: string; + lastName?: string; + statusCode?: string; +} + +export const editAccount = (dataSources: DataSources) => { + return async (command: MemberEditAccountCommand): Promise => { + let memberToReturn: Domain.Contexts.Community.Member.MemberEntityReference | undefined; + await dataSources.domainDataSource.Community.Member.MemberUnitOfWork.withScopedTransaction(async (repo) => { + const member = await repo.getById(command.memberId); + if (!member) { + throw new Error(`Member not found for id ${command.memberId}`); + } + const account = member.accounts.find((a) => a.id === command.accountId); + if (!account) { + throw new Error(`Account not found for id ${command.accountId}`); + } + if (command.firstName !== undefined) { + account.firstName = command.firstName; + } + if (command.lastName !== undefined) { + account.lastName = command.lastName; + } + if (command.statusCode !== undefined) { + account.statusCode = command.statusCode; + } + memberToReturn = await repo.save(member); + }); + if (!memberToReturn) { + throw new Error('member not found'); + } + return memberToReturn; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/community/member/get-by-id.ts b/packages/ocom/application-services/src/contexts/community/member/get-by-id.ts new file mode 100644 index 000000000..c28a5dcf7 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/community/member/get-by-id.ts @@ -0,0 +1,12 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface MemberGetByIdCommand { + id: string; +} + +export const getById = (dataSources: DataSources) => { + return async (command: MemberGetByIdCommand): Promise => { + return await dataSources.readonlyDataSource.Community.Member.MemberReadRepo.getById(command.id); + }; +}; diff --git a/packages/ocom/application-services/src/contexts/community/member/index.ts b/packages/ocom/application-services/src/contexts/community/member/index.ts index ff0608e7c..9c6e594f3 100644 --- a/packages/ocom/application-services/src/contexts/community/member/index.ts +++ b/packages/ocom/application-services/src/contexts/community/member/index.ts @@ -3,11 +3,23 @@ import type { DataSources } from '@ocom/persistence'; import { type MemberQueryByEndUserExternalIdCommand, queryByEndUserExternalId } from './query-by-end-user-external-id.ts'; import { determineIfAdmin, type MemberDetermineIfAdminCommand } from './determine-if-admin.ts'; import { type MemberListByCommunityIdCommand, listByCommunityId } from './list-by-community-id.ts'; +import { type MemberGetByIdCommand, getById } from './get-by-id.ts'; +import { type MemberCreateCommand, create } from './create.ts'; +import { type MemberUpdateCommand, update } from './update.ts'; +import { type MemberAddAccountCommand, addAccount } from './add-account.ts'; +import { type MemberEditAccountCommand, editAccount } from './edit-account.ts'; +import { type MemberRemoveAccountCommand, removeAccount } from './remove-account.ts'; export interface MemberApplicationService { determineIfAdmin: (command: MemberDetermineIfAdminCommand) => Promise; queryByEndUserExternalId: (command: MemberQueryByEndUserExternalIdCommand) => Promise; listByCommunityId: (command: MemberListByCommunityIdCommand) => Promise; + getById: (command: MemberGetByIdCommand) => Promise; + create: (command: MemberCreateCommand) => Promise; + update: (command: MemberUpdateCommand) => Promise; + addAccount: (command: MemberAddAccountCommand) => Promise; + editAccount: (command: MemberEditAccountCommand) => Promise; + removeAccount: (command: MemberRemoveAccountCommand) => Promise; } export const Member = (dataSources: DataSources): MemberApplicationService => { @@ -15,5 +27,11 @@ export const Member = (dataSources: DataSources): MemberApplicationService => { determineIfAdmin: determineIfAdmin(dataSources), queryByEndUserExternalId: queryByEndUserExternalId(dataSources), listByCommunityId: listByCommunityId(dataSources), + getById: getById(dataSources), + create: create(dataSources), + update: update(dataSources), + addAccount: addAccount(dataSources), + editAccount: editAccount(dataSources), + removeAccount: removeAccount(dataSources), }; }; diff --git a/packages/ocom/application-services/src/contexts/community/member/remove-account.ts b/packages/ocom/application-services/src/contexts/community/member/remove-account.ts new file mode 100644 index 000000000..237a3408b --- /dev/null +++ b/packages/ocom/application-services/src/contexts/community/member/remove-account.ts @@ -0,0 +1,29 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface MemberRemoveAccountCommand { + memberId: string; + accountId: string; +} + +export const removeAccount = (dataSources: DataSources) => { + return async (command: MemberRemoveAccountCommand): Promise => { + let memberToReturn: Domain.Contexts.Community.Member.MemberEntityReference | undefined; + await dataSources.domainDataSource.Community.Member.MemberUnitOfWork.withScopedTransaction(async (repo) => { + const member = await repo.getById(command.memberId); + if (!member) { + throw new Error(`Member not found for id ${command.memberId}`); + } + const accountProps = member.props.accounts.items.find((a: { id: string }) => a.id === command.accountId); + if (!accountProps) { + throw new Error(`Account not found for id ${command.accountId}`); + } + member.requestRemoveAccount(accountProps); + memberToReturn = await repo.save(member); + }); + if (!memberToReturn) { + throw new Error('member not found'); + } + return memberToReturn; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/community/member/update.ts b/packages/ocom/application-services/src/contexts/community/member/update.ts new file mode 100644 index 000000000..14e59ba3f --- /dev/null +++ b/packages/ocom/application-services/src/contexts/community/member/update.ts @@ -0,0 +1,27 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface MemberUpdateCommand { + id: string; + memberName?: string; +} + +export const update = (dataSources: DataSources) => { + return async (command: MemberUpdateCommand): Promise => { + let memberToReturn: Domain.Contexts.Community.Member.MemberEntityReference | undefined; + await dataSources.domainDataSource.Community.Member.MemberUnitOfWork.withScopedTransaction(async (repo) => { + const member = await repo.getById(command.id); + if (!member) { + throw new Error(`Member not found for id ${command.id}`); + } + if (command.memberName !== undefined) { + member.memberName = command.memberName; + } + memberToReturn = await repo.save(member); + }); + if (!memberToReturn) { + throw new Error('member not found'); + } + return memberToReturn; + }; +}; diff --git a/packages/ocom/domain/src/domain/contexts/community/member/member.repository.ts b/packages/ocom/domain/src/domain/contexts/community/member/member.repository.ts index 9f2963527..4a080bbf5 100644 --- a/packages/ocom/domain/src/domain/contexts/community/member/member.repository.ts +++ b/packages/ocom/domain/src/domain/contexts/community/member/member.repository.ts @@ -7,4 +7,5 @@ export interface MemberRepository extends Repository< getById(id: string): Promise>; getAssignedToRole(roleId: string): Promise[]>; getAll(): Promise[]>; + getByCommunityId(communityId: string): Promise[]>; } diff --git a/packages/ocom/graphql/src/schema/types/features/member.resolvers.feature b/packages/ocom/graphql/src/schema/types/features/member.resolvers.feature index 6f17f7ba2..007333e2c 100644 --- a/packages/ocom/graphql/src/schema/types/features/member.resolvers.feature +++ b/packages/ocom/graphql/src/schema/types/features/member.resolvers.feature @@ -37,3 +37,39 @@ Feature: Member resolvers Given a user without a verified JWT When the membersByCommunityId query is executed with communityId "community-abc" Then it should throw an "Unauthorized" error + + Scenario: Getting a member by ID + Given a signed in user with subject "user-sub-789" + And the member service can return a member for id "member-777" + When the member query is executed with id "member-777" + Then it should call Community.Member.getById with id "member-777" + And it should return the member + + Scenario: Getting a member by ID without authentication + Given a user without a verified JWT + When the member query is executed with id "member-777" + Then it should throw an "Unauthorized" error + + Scenario: Creating a member + Given a signed in user with subject "user-sub-create" + And the member service can create a member + When the memberCreate mutation is executed with memberName "New Member" and communityId "community-create" + Then it should call Community.Member.create with memberName "New Member" and communityId "community-create" + And it should return a successful mutation result with the created member + + Scenario: Creating a member without authentication + Given a user without a verified JWT + When the memberCreate mutation is executed with memberName "New Member" and communityId "community-create" + Then it should throw an "Unauthorized" error + + Scenario: Updating a member + Given a signed in user with subject "user-sub-update" + And the member service can update a member + When the memberUpdate mutation is executed with id "member-upd" and memberName "Updated Name" + Then it should call Community.Member.update with id "member-upd" and memberName "Updated Name" + And it should return a successful mutation result with the updated member + + Scenario: Updating a member without authentication + Given a user without a verified JWT + When the memberUpdate mutation is executed with id "member-upd" and memberName "Updated Name" + Then it should throw an "Unauthorized" error diff --git a/packages/ocom/graphql/src/schema/types/member.graphql b/packages/ocom/graphql/src/schema/types/member.graphql index 7c38d1d43..16f913b60 100644 --- a/packages/ocom/graphql/src/schema/types/member.graphql +++ b/packages/ocom/graphql/src/schema/types/member.graphql @@ -39,9 +39,52 @@ type MemberProfile { showProperties: Boolean } +type MemberMutationResult implements MutationResult { + status: MutationStatus! + member: Member +} + extend type Query { - # member(id: ObjectID!): Member! + member(id: ObjectID!): Member membersByCommunityId(communityId: ObjectID!): [Member!]! # memberForCurrentCommunity: Member! membersForCurrentEndUser: [Member!]! } + +extend type Mutation { + memberCreate(input: MemberCreateInput!): MemberMutationResult! + memberUpdate(input: MemberUpdateInput!): MemberMutationResult! + memberAccountAdd(input: MemberAccountAddInput!): MemberMutationResult! + memberAccountEdit(input: MemberAccountEditInput!): MemberMutationResult! + memberAccountRemove(input: MemberAccountRemoveInput!): MemberMutationResult! +} + +input MemberCreateInput { + memberName: String! + communityId: ObjectID! +} + +input MemberUpdateInput { + id: ObjectID! + memberName: String +} + +input MemberAccountAddInput { + memberId: ObjectID! + userId: ObjectID! + firstName: String! + lastName: String +} + +input MemberAccountEditInput { + memberId: ObjectID! + accountId: ObjectID! + firstName: String + lastName: String + statusCode: String +} + +input MemberAccountRemoveInput { + memberId: ObjectID! + accountId: ObjectID! +} diff --git a/packages/ocom/graphql/src/schema/types/member.resolvers.test.ts b/packages/ocom/graphql/src/schema/types/member.resolvers.test.ts index 67fb9a628..760dd8123 100644 --- a/packages/ocom/graphql/src/schema/types/member.resolvers.test.ts +++ b/packages/ocom/graphql/src/schema/types/member.resolvers.test.ts @@ -42,6 +42,12 @@ function makeMockGraphContext(overrides: Partial = {}): GraphConte determineIfAdmin: vi.fn(), queryByEndUserExternalId: vi.fn(), listByCommunityId: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + addAccount: vi.fn(), + editAccount: vi.fn(), + removeAccount: vi.fn(), }, }, verifiedUser: { @@ -63,6 +69,8 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { let communityResult: unknown; let booleanResult: boolean | null; let membersResult: unknown; + let memberResult: unknown; + let mutationResult: unknown; BeforeEachScenario(() => { context = makeMockGraphContext(); @@ -70,6 +78,8 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { communityResult = null; booleanResult = null; membersResult = null; + memberResult = null; + mutationResult = null; vi.clearAllMocks(); }); @@ -237,125 +247,163 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { // Assertion performed in When step }); }); -}); - - -test.for(feature, ({ Scenario, BeforeEachScenario }) => { - let context: GraphContext; - let member: MemberEntity; - let communityResult: unknown; - let booleanResult: boolean | null; - let membersResult: unknown; - - BeforeEachScenario(() => { - context = makeMockGraphContext(); - member = createMockMember(); - communityResult = null; - booleanResult = null; - membersResult = null; - vi.clearAllMocks(); - }); - Scenario('Resolving the community for a member', ({ Given, And, When, Then }) => { - const resolvedCommunity = createMockCommunity(); - const domainCommunity = resolvedCommunity as unknown as CommunityReference; + Scenario('Getting a member by ID', ({ Given, And, When, Then }) => { + const resolvedMember = createMockMember({ id: 'member-777' }); + const domainMember = resolvedMember as unknown as MemberReference; - Given('a member with communityId "community-123"', () => { - member = createMockMember({ communityId: 'community-123' }); + Given('a signed in user with subject "user-sub-789"', () => { + if (context.applicationServices.verifiedUser?.verifiedJwt) { + context.applicationServices.verifiedUser.verifiedJwt.sub = 'user-sub-789'; + } }); - And('the community service can return that community', () => { - vi.mocked(context.applicationServices.Community.Community.queryById).mockResolvedValue(domainCommunity); + And('the member service can return a member for id "member-777"', () => { + vi.mocked(context.applicationServices.Community.Member.getById).mockResolvedValue(domainMember); }); - When('the Member.community resolver is executed', async () => { - communityResult = await (memberResolvers.Member?.community as unknown as (parent: MemberEntity, args: Record, graphContext: GraphContext, info: unknown) => Promise)( - member, - {}, + When('the member query is executed with id "member-777"', async () => { + memberResult = await (memberResolvers.Query?.member as unknown as (parent: unknown, args: { id: string }, graphContext: GraphContext, info: unknown) => Promise)( + null, + { id: 'member-777' }, context, {}, ); }); - Then("it should call Community.Community.queryById with the member's communityId", () => { - expect(context.applicationServices.Community.Community.queryById).toHaveBeenCalledWith({ - id: 'community-123', + Then('it should call Community.Member.getById with id "member-777"', () => { + expect(context.applicationServices.Community.Member.getById).toHaveBeenCalledWith({ + id: 'member-777', }); }); - And('it should return the resolved community', () => { - expect(communityResult as CommunityEntity).toEqual(resolvedCommunity); + And('it should return the member', () => { + expect(memberResult as MemberEntity).toEqual(resolvedMember); }); }); - Scenario('Checking if a member is an administrator', ({ Given, And, When, Then }) => { - Given('a member with id "member-1"', () => { - member = createMockMember({ id: 'member-1' }); + Scenario('Getting a member by ID without authentication', ({ Given, When, Then }) => { + Given('a user without a verified JWT', () => { + if (context.applicationServices.verifiedUser) { + context.applicationServices.verifiedUser.verifiedJwt = undefined; + } }); - And('the member service indicates the member is an admin', () => { - vi.mocked(context.applicationServices.Community.Member.determineIfAdmin).mockResolvedValue(true); + When('the member query is executed with id "member-777"', async () => { + await expect( + (memberResolvers.Query?.member as unknown as (parent: unknown, args: { id: string }, graphContext: GraphContext, info: unknown) => Promise)(null, { id: 'member-777' }, context, {}), + ).rejects.toThrow('Unauthorized'); }); - When('the Member.isAdmin resolver is executed', async () => { - booleanResult = await (memberResolvers.Member?.isAdmin as unknown as (parent: MemberEntity, args: Record, graphContext: GraphContext, info: unknown) => Promise)(member, {}, context, {}); + Then('it should throw an "Unauthorized" error', () => { + // Assertion performed in When step }); + }); - Then("it should call Community.Member.determineIfAdmin with the member's id", () => { - expect(context.applicationServices.Community.Member.determineIfAdmin).toHaveBeenCalledWith({ - memberId: 'member-1', + Scenario('Creating a member', ({ Given, And, When, Then }) => { + const createdMember = createMockMember({ id: 'member-new', communityId: 'community-create' }); + const domainMember = createdMember as unknown as MemberReference; + + Given('a signed in user with subject "user-sub-create"', () => { + if (context.applicationServices.verifiedUser?.verifiedJwt) { + context.applicationServices.verifiedUser.verifiedJwt.sub = 'user-sub-create'; + } + }); + + And('the member service can create a member', () => { + vi.mocked(context.applicationServices.Community.Member.create).mockResolvedValue(domainMember); + }); + + When('the memberCreate mutation is executed with memberName "New Member" and communityId "community-create"', async () => { + mutationResult = await (memberResolvers.Mutation?.memberCreate as unknown as (parent: unknown, args: { input: { memberName: string; communityId: string } }, graphContext: GraphContext) => Promise)( + null, + { input: { memberName: 'New Member', communityId: 'community-create' } }, + context, + ); + }); + + Then('it should call Community.Member.create with memberName "New Member" and communityId "community-create"', () => { + expect(context.applicationServices.Community.Member.create).toHaveBeenCalledWith({ + memberName: 'New Member', + communityId: 'community-create', }); }); - And('it should return true', () => { - expect(booleanResult).toBe(true); + And('it should return a successful mutation result with the created member', () => { + expect(mutationResult).toEqual({ status: { success: true }, member: createdMember }); }); }); - Scenario('Querying members for the current end user', ({ Given, And, When, Then }) => { - const resolvedMembers = [createMockMember({ id: 'member-42' })]; - const domainMembers = resolvedMembers as unknown as MemberReference[]; + Scenario('Creating a member without authentication', ({ Given, When, Then }) => { + Given('a user without a verified JWT', () => { + if (context.applicationServices.verifiedUser) { + context.applicationServices.verifiedUser.verifiedJwt = undefined; + } + }); - Given('a signed in user with subject "user-sub-123"', () => { + When('the memberCreate mutation is executed with memberName "New Member" and communityId "community-create"', async () => { + await expect( + (memberResolvers.Mutation?.memberCreate as unknown as (parent: unknown, args: { input: { memberName: string; communityId: string } }, graphContext: GraphContext) => Promise)( + null, + { input: { memberName: 'New Member', communityId: 'community-create' } }, + context, + ), + ).rejects.toThrow('Unauthorized'); + }); + + Then('it should throw an "Unauthorized" error', () => { + // Assertion performed in When step + }); + }); + + Scenario('Updating a member', ({ Given, And, When, Then }) => { + const updatedMember = createMockMember({ id: 'member-upd' }); + const domainMember = updatedMember as unknown as MemberReference; + + Given('a signed in user with subject "user-sub-update"', () => { if (context.applicationServices.verifiedUser?.verifiedJwt) { - context.applicationServices.verifiedUser.verifiedJwt.sub = 'user-sub-123'; + context.applicationServices.verifiedUser.verifiedJwt.sub = 'user-sub-update'; } }); - And('the member service can return members for that subject', () => { - vi.mocked(context.applicationServices.Community.Member.queryByEndUserExternalId).mockResolvedValue(domainMembers); + And('the member service can update a member', () => { + vi.mocked(context.applicationServices.Community.Member.update).mockResolvedValue(domainMember); }); - When('the membersForCurrentEndUser query is executed', async () => { - membersResult = await (memberResolvers.Query?.membersForCurrentEndUser as unknown as (parent: unknown, args: Record, graphContext: GraphContext, info: unknown) => Promise)( + When('the memberUpdate mutation is executed with id "member-upd" and memberName "Updated Name"', async () => { + mutationResult = await (memberResolvers.Mutation?.memberUpdate as unknown as (parent: unknown, args: { input: { id: string; memberName: string } }, graphContext: GraphContext) => Promise)( null, - {}, + { input: { id: 'member-upd', memberName: 'Updated Name' } }, context, - {}, ); }); - Then('it should call Community.Member.queryByEndUserExternalId with the subject', () => { - expect(context.applicationServices.Community.Member.queryByEndUserExternalId).toHaveBeenCalledWith({ - externalId: 'user-sub-123', + Then('it should call Community.Member.update with id "member-upd" and memberName "Updated Name"', () => { + expect(context.applicationServices.Community.Member.update).toHaveBeenCalledWith({ + id: 'member-upd', + memberName: 'Updated Name', }); }); - And('it should return the list of members', () => { - expect(membersResult as MemberEntity[]).toEqual(resolvedMembers); + And('it should return a successful mutation result with the updated member', () => { + expect(mutationResult).toEqual({ status: { success: true }, member: updatedMember }); }); }); - Scenario('Querying members for the current end user without authentication', ({ Given, When, Then }) => { + Scenario('Updating a member without authentication', ({ Given, When, Then }) => { Given('a user without a verified JWT', () => { if (context.applicationServices.verifiedUser) { context.applicationServices.verifiedUser.verifiedJwt = undefined; } }); - When('the membersForCurrentEndUser query is executed', async () => { + When('the memberUpdate mutation is executed with id "member-upd" and memberName "Updated Name"', async () => { await expect( - (memberResolvers.Query?.membersForCurrentEndUser as unknown as (parent: unknown, args: Record, graphContext: GraphContext, info: unknown) => Promise)(null, {}, context, {}), + (memberResolvers.Mutation?.memberUpdate as unknown as (parent: unknown, args: { input: { id: string; memberName: string } }, graphContext: GraphContext) => Promise)( + null, + { input: { id: 'member-upd', memberName: 'Updated Name' } }, + context, + ), ).rejects.toThrow('Unauthorized'); }); diff --git a/packages/ocom/graphql/src/schema/types/member.resolvers.ts b/packages/ocom/graphql/src/schema/types/member.resolvers.ts index d579cfb4b..6904ed545 100644 --- a/packages/ocom/graphql/src/schema/types/member.resolvers.ts +++ b/packages/ocom/graphql/src/schema/types/member.resolvers.ts @@ -1,6 +1,21 @@ import type { GraphQLResolveInfo } from 'graphql'; import type { GraphContext } from '../context.ts'; -import type { Resolvers } from '../builder/generated.ts'; +import type { MemberCreateInput, MemberUpdateInput, MemberAccountAddInput, MemberAccountEditInput, MemberAccountRemoveInput, Resolvers } from '../builder/generated.ts'; + +const MemberMutationResolver = async (getMember: Promise) => { + try { + return { + status: { success: true }, + member: await getMember, + }; + } catch (error) { + console.error('Member > Mutation:', error); + const { message } = error as Error; + return { + status: { success: false, errorMessage: message }, + }; + } +}; const member: Resolvers = { Member: { @@ -9,9 +24,6 @@ const member: Resolvers = { id: parent.communityId, }); }, - // role: async (parent, _args: unknown, _context: GraphContext, _info: GraphQLResolveInfo) => { - // return await parent.loadRole(); - // }, isAdmin: async (parent, _args: unknown, context: GraphContext, _info: GraphQLResolveInfo) => { return await context.applicationServices.Community.Member.determineIfAdmin({ memberId: parent.id, @@ -19,22 +31,90 @@ const member: Resolvers = { }, }, Query: { + member: async (_parent, args: { id: string }, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + return await context.applicationServices.Community.Member.getById({ id: args.id }); + }, membersForCurrentEndUser: async (_parent, _args: unknown, context: GraphContext, _info: GraphQLResolveInfo) => { if (!context.applicationServices.verifiedUser?.verifiedJwt) { throw new Error('Unauthorized'); } const externalId = context.applicationServices.verifiedUser.verifiedJwt.sub; - return await context.applicationServices.Community.Member.queryByEndUserExternalId({ - externalId, - }); + return await context.applicationServices.Community.Member.queryByEndUserExternalId({ externalId }); }, membersByCommunityId: async (_parent, args: { communityId: string }, context: GraphContext, _info: GraphQLResolveInfo) => { if (!context.applicationServices.verifiedUser?.verifiedJwt) { throw new Error('Unauthorized'); } - return await context.applicationServices.Community.Member.listByCommunityId({ - communityId: args.communityId, - }); + return await context.applicationServices.Community.Member.listByCommunityId({ communityId: args.communityId }); + }, + }, + Mutation: { + memberCreate: async (_parent, args: { input: MemberCreateInput }, context: GraphContext) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + return await MemberMutationResolver( + context.applicationServices.Community.Member.create({ + memberName: args.input.memberName, + communityId: args.input.communityId, + }), + ); + }, + memberUpdate: async (_parent, args: { input: MemberUpdateInput }, context: GraphContext) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + const memberName = args.input.memberName !== null && args.input.memberName !== undefined ? args.input.memberName : undefined; + return await MemberMutationResolver( + context.applicationServices.Community.Member.update({ + id: args.input.id, + ...(memberName !== undefined ? { memberName } : {}), + }), + ); + }, + memberAccountAdd: async (_parent, args: { input: MemberAccountAddInput }, context: GraphContext) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + return await MemberMutationResolver( + context.applicationServices.Community.Member.addAccount({ + memberId: args.input.memberId, + userId: args.input.userId, + firstName: args.input.firstName, + lastName: args.input.lastName ?? '', + }), + ); + }, + memberAccountEdit: async (_parent, args: { input: MemberAccountEditInput }, context: GraphContext) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + const firstName = args.input.firstName !== null && args.input.firstName !== undefined ? args.input.firstName : undefined; + const lastName = args.input.lastName !== null && args.input.lastName !== undefined ? args.input.lastName : undefined; + const statusCode = args.input.statusCode !== null && args.input.statusCode !== undefined ? args.input.statusCode : undefined; + return await MemberMutationResolver( + context.applicationServices.Community.Member.editAccount({ + memberId: args.input.memberId, + accountId: args.input.accountId, + ...(firstName !== undefined ? { firstName } : {}), + ...(lastName !== undefined ? { lastName } : {}), + ...(statusCode !== undefined ? { statusCode } : {}), + }), + ); + }, + memberAccountRemove: async (_parent, args: { input: MemberAccountRemoveInput }, context: GraphContext) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + return await MemberMutationResolver( + context.applicationServices.Community.Member.removeAccount({ + memberId: args.input.memberId, + accountId: args.input.accountId, + }), + ); }, }, }; diff --git a/packages/ocom/persistence/src/datasources/domain/community/member/member.repository.ts b/packages/ocom/persistence/src/datasources/domain/community/member/member.repository.ts index b8ddcdd7d..1dd8b14cb 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/member/member.repository.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/member/member.repository.ts @@ -26,6 +26,14 @@ export class MemberRepository //< return mongoMembers.map((member) => this.typeConverter.toDomain(member, this.passport)); } + async getByCommunityId(communityId: string): Promise[]> { + const mongoMembers = await this.model + .find({ community: new MongooseSeedwork.ObjectId(communityId) }) + .populate(['community']) + .exec(); + return mongoMembers.map((member) => this.typeConverter.toDomain(member, this.passport)); + } + async getAssignedToRole(roleId: string): Promise[]> { const mongoMembers = await this.model .find({ role: new MongooseSeedwork.ObjectId(roleId) })