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 (
+
+
+
+
+
+ );
+};
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') : '—'}
+
+
+
+
+
+
+ >
+ );
+};
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) })