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.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..4a39a24c1 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/member-list.container.tsx @@ -0,0 +1,46 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import { Button } from 'antd'; +import { useState } from 'react'; +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 = () => { + const params = useParams(); + // biome-ignore lint:useLiteralKeys + const communityId = params['communityId'] ?? ''; + const [searchValue, setSearchValue] = useState(''); + const navigate = useNavigate(); + + const { data, loading, error } = useQuery(AdminMemberListContainerMembersByCommunityIdDocument, { + variables: { communityId }, + skip: !communityId, + }); + + const memberListProps: MemberListProps = { + data: (data?.membersByCommunityId ?? []) as AdminMemberListContainerMemberFieldsFragment[], + searchValue, + onSearchChange: setSearchValue, + communityId, + }; + + 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..790ad74ab --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/member-list.stories.tsx @@ -0,0 +1,162 @@ +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'; + +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' }, + }, + decorators: [ + (Story) => ( + + + } + /> + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: mockMembers, + searchValue: '', + onSearchChange: fn(), + communityId: 'community-123', + }, +}; + +export const WithSearch: Story = { + args: { + data: mockMembers, + searchValue: 'smith', + onSearchChange: fn(), + communityId: 'community-123', + }, +}; + +export const Empty: Story = { + args: { + data: [], + searchValue: '', + onSearchChange: fn(), + communityId: 'community-123', + }, +}; + +export const NoResults: Story = { + args: { + 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 new file mode 100644 index 000000000..bbb43990b --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/member-list.tsx @@ -0,0 +1,149 @@ +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 { Search } = Input; + +export interface MemberListProps { + data: AdminMemberListContainerMemberFieldsFragment[]; + searchValue: string; + onSearchChange: (value: string) => void; + communityId: string; +} + +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 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)); + return nameMatch || emailMatch || accountNameMatch; + }); + + const columns = [ + { + title: 'Action', + key: 'action', + render: (_: unknown, record: AdminMemberListContainerMemberFieldsFragment) => ( + + ), + }, + { + 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 ( +
+
+ props.onSearchChange(e.target.value)} + style={{ width: 280 }} + allowClear + /> +
+ + + ); +}; 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 07b046e10..2899d54f5 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..7872aa9c2 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members.tsx @@ -0,0 +1,35 @@ +import { PageHeader } from '@ant-design/pro-layout'; +import { theme } from 'antd'; +import { Route, Routes } from 'react-router-dom'; +import { MemberListContainer } from '../components/member-list.container.tsx'; +import { MembersCreateContainer } from '../components/members-create.container.tsx'; +import { MembersDetailContainer } from '../components/members-detail.container.tsx'; +import { SubPageLayout } from '../sub-page-layout.tsx'; + +export const Members: React.FC = () => { + const { + token: { colorTextBase }, + } = theme.useToken(); + + return ( + 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 7360e1f40..9c6e594f3 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,36 @@ 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'; +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 => { return { 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/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/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 4847f7bcc..007333e2c 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,51 @@ 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 + + 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 364eb0bd6..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! - # membersByCommunityId(communityId: 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 f6ba987b9..760dd8123 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,13 @@ function makeMockGraphContext(overrides: Partial = {}): GraphConte Member: { 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: { @@ -62,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(); @@ -69,6 +78,8 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { communityResult = null; booleanResult = null; membersResult = null; + memberResult = null; + mutationResult = null; vi.clearAllMocks(); }); @@ -179,4 +190,225 @@ 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 + }); + }); + + 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 signed in user with subject "user-sub-789"', () => { + if (context.applicationServices.verifiedUser?.verifiedJwt) { + context.applicationServices.verifiedUser.verifiedJwt.sub = 'user-sub-789'; + } + }); + + And('the member service can return a member for id "member-777"', () => { + vi.mocked(context.applicationServices.Community.Member.getById).mockResolvedValue(domainMember); + }); + + 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.Member.getById with id "member-777"', () => { + expect(context.applicationServices.Community.Member.getById).toHaveBeenCalledWith({ + id: 'member-777', + }); + }); + + And('it should return the member', () => { + expect(memberResult as MemberEntity).toEqual(resolvedMember); + }); + }); + + 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; + } + }); + + 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'); + }); + + Then('it should throw an "Unauthorized" error', () => { + // Assertion performed in When step + }); + }); + + 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 a successful mutation result with the created member', () => { + expect(mutationResult).toEqual({ status: { success: true }, member: createdMember }); + }); + }); + + 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; + } + }); + + 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-update'; + } + }); + + And('the member service can update a member', () => { + vi.mocked(context.applicationServices.Community.Member.update).mockResolvedValue(domainMember); + }); + + 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.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 a successful mutation result with the updated member', () => { + expect(mutationResult).toEqual({ status: { success: true }, member: updatedMember }); + }); + }); + + 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 memberUpdate mutation is executed with id "member-upd" and memberName "Updated Name"', async () => { + await expect( + (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'); + }); + + 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..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,14 +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 }); + }, + }, + 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) })