diff --git a/apps/ui-community/mock-oidc.users.json b/apps/ui-community/mock-oidc.users.json index f5518cba9..5215d6985 100644 --- a/apps/ui-community/mock-oidc.users.json +++ b/apps/ui-community/mock-oidc.users.json @@ -34,5 +34,29 @@ "family_name": "Owner", "tid": "test-tenant-id" } + }, + { + "username": "member@test.example", + "sub": "aaaaaaaa-bbbb-1ccc-9ddd-eeeeeeeeee02", + "password": "password", + "oidcConfigName": "end-user", + "claims": { + "email": "member@test.example", + "given_name": "Test", + "family_name": "Member", + "tid": "test-tenant-id" + } + }, + { + "username": "other.owner@test.example", + "sub": "aaaaaaaa-bbbb-1ccc-9ddd-eeeeeeeeee03", + "password": "password", + "oidcConfigName": "end-user", + "claims": { + "email": "other.owner@test.example", + "given_name": "Other", + "family_name": "Owner", + "tid": "test-tenant-id" + } } ] diff --git a/apps/ui-community/package.json b/apps/ui-community/package.json index 1055de59f..7c3edfd4c 100644 --- a/apps/ui-community/package.json +++ b/apps/ui-community/package.json @@ -27,6 +27,7 @@ "@ocom/local-dev-config": "workspace:*", "@ocom/ui-community-route-accounts": "workspace:*", "@ocom/ui-community-route-admin": "workspace:*", + "@ocom/ui-community-route-member": "workspace:*", "@ocom/ui-community-route-root": "workspace:*", "@ocom/ui-community-shared": "workspace:*", "antd": "catalog:", diff --git a/apps/ui-community/src/App.tsx b/apps/ui-community/src/App.tsx index 83cd0fd0f..dc515fca5 100644 --- a/apps/ui-community/src/App.tsx +++ b/apps/ui-community/src/App.tsx @@ -1,6 +1,7 @@ import { RequireAuth } from '@cellix/ui-core'; import { Accounts } from '@ocom/ui-community-route-accounts'; import { Admin } from '@ocom/ui-community-route-admin'; +import { Member } from '@ocom/ui-community-route-member'; import { Root } from '@ocom/ui-community-route-root'; import { Route, Routes } from 'react-router-dom'; import './App.css'; @@ -31,6 +32,10 @@ export default function App() { path="/:communityId/admin/:memberId/*" element={} /> + } + /> ); diff --git a/codegen.yml b/codegen.yml index 201ef3192..f056e1fb2 100644 --- a/codegen.yml +++ b/codegen.yml @@ -98,6 +98,21 @@ generates: - typescript-operations - typed-document-node +# UI community member-route client types + './packages/ocom/ui-community-route-member/src/generated.tsx': + documents: + - './packages/ocom/ui-community-route-member/src/**/**.graphql' + config: + withHooks: true + withHOC: false + withComponent: false + useTypeImports: true + enumsAsTypes: true + plugins: + - typescript + - typescript-operations + - typed-document-node + # UI community client types './packages/ocom/ui-community-route-accounts/src/generated.tsx': documents: diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/notes/member-property-notes.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/notes/member-property-notes.ts new file mode 100644 index 000000000..a22b19645 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/notes/member-property-notes.ts @@ -0,0 +1,21 @@ +type MemberPropertyOperationStatus = 'SUCCESS' | 'REJECTED' | 'ERROR' | null; +type MemberPropertyReadStatus = 'FOUND' | 'MISSING' | 'ERROR' | null; + +/** Scenario-local state for member Property API behavior. */ +export interface MemberPropertyNotes { + activeCommunityId: string; + activeCommunityName: string; + actingMemberId: string; + managerMemberId: string; + foreignMemberId: string | null; + knownPropertyIds: Record; + listedPropertyNames: string[]; + directoryStatus: MemberPropertyOperationStatus; + directoryError: string | null; + lastOperationStatus: MemberPropertyOperationStatus; + lastOperationError: string | null; + lastReadStatus: MemberPropertyReadStatus; + lastReadError: string | null; + lastReadPropertyId: string | null; + baselinePropertyNames: string[]; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/questions/member-property-state.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/questions/member-property-state.ts new file mode 100644 index 000000000..159dde6d5 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/questions/member-property-state.ts @@ -0,0 +1,37 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; +import { PROPERTIES_BY_COMMUNITY_ID_QUERY, type PropertyResult } from '../../../shared/graphql/property-operations.ts'; +import type { MemberPropertyNotes } from '../notes/member-property-notes.ts'; + +export const MemberPropertyOperationStatus = () => Question.about('the member Property operation status', async (actor) => await actor.answer(notes().get('lastOperationStatus'))); + +export const MemberPropertyOperationError = () => Question.about('the member Property operation error', async (actor) => await actor.answer(notes().get('lastOperationError'))); + +export const MemberPropertyDirectoryStatus = () => Question.about('the member Property directory status', async (actor) => await actor.answer(notes().get('directoryStatus'))); + +export const MemberPropertyDirectoryNames = () => Question.about('the member Property directory names', async (actor) => await actor.answer(notes().get('listedPropertyNames'))); + +export const MemberPropertyReadStatus = () => Question.about('the member Property read status', async (actor) => await actor.answer(notes().get('lastReadStatus'))); + +export const MemberPropertyReadError = () => Question.about('the member Property read error', async (actor) => await actor.answer(notes().get('lastReadError'))); + +/** Reads a property by name from the actor's active member Property community. */ +export class MemberPropertyNamed extends Question> { + static called(propertyName: string): MemberPropertyNamed { + return new MemberPropertyNamed(propertyName); + } + + private constructor(private readonly propertyName: string) { + super(`the member Property named "${propertyName}"`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const communityId = await actor.answer(notes().get('activeCommunityId')); + if (!communityId) { + throw new Error('No active member Property community is available to query a property by name'); + } + const response = await GraphQLClient.as(actor as unknown as Actor).execute(PROPERTIES_BY_COMMUNITY_ID_QUERY, { communityId }); + const properties = response.data.propertiesByCommunityId as PropertyResult[]; + return properties.find((property) => property.propertyName === this.propertyName); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/step-definitions/index.ts new file mode 100644 index 000000000..6da6a6691 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/step-definitions/index.ts @@ -0,0 +1 @@ +import './member-property.steps.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/step-definitions/member-property.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/step-definitions/member-property.steps.ts new file mode 100644 index 000000000..869e84ddb --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/step-definitions/member-property.steps.ts @@ -0,0 +1,371 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { type Actor, actorCalled, notes } from '@serenity-js/core'; +import type { MemberPropertyNotes } from '../notes/member-property-notes.ts'; +import { + MemberPropertyDirectoryNames, + MemberPropertyDirectoryStatus, + MemberPropertyNamed, + MemberPropertyOperationError, + MemberPropertyOperationStatus, + MemberPropertyReadError, + MemberPropertyReadStatus, +} from '../questions/member-property-state.ts'; +import { ArrangeMemberProperty, MakeForeignMemberAvailable } from '../tasks/arrange-member-property.ts'; +import { BecomeSeparateOwnPropertyMember } from '../tasks/become-separate-own-property-member.ts'; +import { EstablishMemberPropertyCommunity } from '../tasks/establish-member-property-community.ts'; +import { memberPropertyCommunityOf, memberPropertyIdKnownTo } from '../tasks/member-property-context.ts'; +import { + AttemptMemberPropertyCreateWithOwner, + CreateMemberProperty, + DeleteMemberProperty, + type MemberPropertyUpdateInput, + OpenMemberPropertyDirectory, + ReadMemberProperty, + UpdateMemberProperty, +} from '../tasks/member-property-operations.ts'; +import { BecomeMemberPropertyManager, BecomeMemberPropertyVisitor, type MemberPropertyVisitor } from '../tasks/member-property-personas.ts'; + +const MAYA = 'Maya'; +const INSPECTOR = 'MemberPropertyInspector'; +const UNKNOWN_PROPERTY_ID = 'ffffffffffffffffffffffff'; +const SCHEMA_LEVEL_ERROR = /cannot query field|unknown argument|unknown type|is not defined by type/i; + +let lastActorName = MAYA; + +async function mayaCommunity() { + return await memberPropertyCommunityOf(actorCalled(MAYA)); +} + +async function managerInspector(): Promise { + const actor = actorCalled(INSPECTOR); + await actor.attemptsTo(BecomeMemberPropertyManager.inCommunity(await mayaCommunity())); + return actor; +} + +async function propertyIdFor(actor: Actor, propertyName: string): Promise { + const knownForActor = await memberPropertyIdKnownTo(actor, propertyName); + if (knownForActor) { + return knownForActor; + } + const knownForMaya = await memberPropertyIdKnownTo(actorCalled(MAYA), propertyName); + if (knownForMaya) { + return knownForMaya; + } + throw new Error(`No member Property id is recorded for "${propertyName}". Did a fixture arrange it first?`); +} + +async function ownerIdFor(actor: Actor, value: string): Promise { + if (value === 'self') { + const memberId = await actor.answer(notes().get('actingMemberId')); + if (!memberId) { + throw new Error('No acting member id is available for the supplied self ownerId'); + } + return memberId; + } + if (value === 'foreign') { + const foreignMemberId = await actor.answer(notes().get('foreignMemberId')); + if (!foreignMemberId) { + throw new Error('No foreign member id is available for the supplied ownerId'); + } + return foreignMemberId; + } + throw new Error(`Unsupported supplied member Property owner id selector "${value}"`); +} + +async function updateInputFor(actor: Actor, dataTable: DataTable): Promise { + const raw = GherkinDataTable.from(dataTable).rowsHash>(); + const input: MemberPropertyUpdateInput = {}; + if (raw.propertyName !== undefined) { + input.propertyName = raw.propertyName; + } + if (raw.propertyType !== undefined) { + input.propertyType = raw.propertyType.trim() === '' ? null : raw.propertyType; + } + if (raw.ownerId !== undefined) { + input.ownerId = raw.ownerId === 'clear' ? null : await ownerIdFor(actor, raw.ownerId); + } + if (raw.listedInDirectory !== undefined) { + if (raw.listedInDirectory !== 'true' && raw.listedInDirectory !== 'false') { + throw new Error(`The listedInDirectory value must be "true" or "false", not "${raw.listedInDirectory}"`); + } + input.listedInDirectory = raw.listedInDirectory === 'true'; + } + return input; +} + +async function expectOperationSucceeded(): Promise { + const actor = actorCalled(lastActorName); + const status = await actor.answer(MemberPropertyOperationStatus()); + if (status !== 'SUCCESS') { + const error = await actor.answer(MemberPropertyOperationError()); + throw new Error(`Expected the member Property operation to succeed, but it was "${status}"${error ? `: ${error}` : ''}`); + } +} + +async function expectOperationRejected(): Promise { + const actor = actorCalled(lastActorName); + const status = await actor.answer(MemberPropertyOperationStatus()); + if (status === 'SUCCESS') { + throw new Error('Expected the member Property operation to be rejected, but it succeeded'); + } + const error = await actor.answer(MemberPropertyOperationError()); + if (!error) { + throw new Error('Expected a rejected member Property operation to record an error'); + } + if (SCHEMA_LEVEL_ERROR.test(error)) { + throw new Error(`Member Property behavior could not be verified because the GraphQL schema rejected the operation: "${error}"`); + } + return error; +} + +async function expectDirectoryContains(propertyName: string, expected: boolean): Promise { + const actor = actorCalled(lastActorName); + const status = await actor.answer(MemberPropertyDirectoryStatus()); + if (status !== 'SUCCESS') { + const error = await actor.answer(MemberPropertyOperationError()); + throw new Error(`Expected the member Property directory to load before checking "${propertyName}", but it was "${status}"${error ? `: ${error}` : ''}`); + } + const names = await actor.answer(MemberPropertyDirectoryNames()); + const found = names.includes(propertyName); + if (found !== expected) { + throw new Error(`Expected the member Property directory to ${expected ? 'include' : 'exclude'} "${propertyName}", but it listed: ${names.join(', ') || 'none'}`); + } +} + +async function expectDetailsAvailable(): Promise { + const actor = actorCalled(lastActorName); + const status = await actor.answer(MemberPropertyReadStatus()); + if (status !== 'FOUND') { + const error = await actor.answer(MemberPropertyReadError()); + throw new Error(`Expected the member Property details to be available, but they were "${status ?? 'not read'}"${error ? `: ${error}` : ''}`); + } +} + +async function inspectProperty(propertyName: string) { + const inspector = await managerInspector(); + return await inspector.answer(MemberPropertyNamed.called(propertyName)); +} + +Given('{word} is an accepted own-property member in a member Property community', async (actorName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(EstablishMemberPropertyCommunity.forOwnPropertyMember()); +}); + +Given('a foreign same-community member is available for member Property ownership', async () => { + await actorCalled(MAYA).attemptsTo(MakeForeignMemberAvailable.inCurrentCommunity()); +}); + +Given('a foreign same-community member owns the member Property {string}', async (propertyName: string) => { + const actor = actorCalled(MAYA); + await actor.attemptsTo(MakeForeignMemberAvailable.inCurrentCommunity(), ArrangeMemberProperty.ownedByForeignMember(propertyName)); +}); + +Given('{word} owns the member Property {string}', async (actorName: string, propertyName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(ArrangeMemberProperty.ownedByCurrentMember(propertyName)); +}); + +Given("{word} is the property manager of Maya's member Property community", async (actorName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(BecomeMemberPropertyManager.inCommunity(await mayaCommunity())); +}); + +Given("{word} is a {string} member Property API visitor in Maya's community", async (actorName: string, visitor: string) => { + if (!['no-permission', 'no-role', 'created', 'rejected', 'guest'].includes(visitor)) { + throw new Error(`Unsupported member Property API visitor "${visitor}"`); + } + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(BecomeMemberPropertyVisitor.inCommunity(visitor as MemberPropertyVisitor, await mayaCommunity())); +}); + +Given('{word} is an accepted own-property member of a separate member Property community', async (actorName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(BecomeSeparateOwnPropertyMember.inNewCommunity()); +}); + +When('{word} opens the member Property directory', async (actorName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(OpenMemberPropertyDirectory.currentCommunity()); +}); + +When('{word} creates a member Property named {string}', async (actorName: string, propertyName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(CreateMemberProperty.named(propertyName)); +}); + +When('{word} attempts to create a member Property named {string}', async (actorName: string, propertyName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(CreateMemberProperty.named(propertyName)); +}); + +When('{word} sets the member Property {string} listing flag {string} to {string}', async (actorName: string, propertyName: string, flag: string, value: string) => { + if (flag !== 'listedInDirectory') { + throw new Error(`Only the representative member-editable flag "listedInDirectory" is supported, not "${flag}"`); + } + if (value !== 'true' && value !== 'false') { + throw new Error(`The listing flag value must be "true" or "false", not "${value}"`); + } + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(UpdateMemberProperty.with(propertyName, await propertyIdFor(actor, propertyName), { listedInDirectory: value === 'true' })); +}); + +When('{word} attempts to set the member Property {string} listing flag {string} to {string}', async (actorName: string, propertyName: string, flag: string, value: string) => { + if (flag !== 'listedInDirectory') { + throw new Error(`Only the representative member-editable flag "listedInDirectory" is supported, not "${flag}"`); + } + if (value !== 'true' && value !== 'false') { + throw new Error(`The listing flag value must be "true" or "false", not "${value}"`); + } + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(UpdateMemberProperty.with(propertyName, await propertyIdFor(actor, propertyName), { listedInDirectory: value === 'true' })); +}); + +When('{word} attempts to update the member Property {string} with:', async (actorName: string, propertyName: string, dataTable: DataTable) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(UpdateMemberProperty.with(propertyName, await propertyIdFor(actor, propertyName), await updateInputFor(actor, dataTable))); +}); + +When('{word} attempts to delete the member Property {string}', async (actorName: string, propertyName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(DeleteMemberProperty.identifiedAs(propertyName, await propertyIdFor(actor, propertyName))); +}); + +When('{word} attempts to create a member Property named {string} with supplied owner id {string}', async (actorName: string, propertyName: string, owner: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(AttemptMemberPropertyCreateWithOwner.named(propertyName, await ownerIdFor(actor, owner))); +}); + +When("{word} attempts to list Maya's member Property directory", async (actorName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(OpenMemberPropertyDirectory.community((await mayaCommunity()).communityId)); +}); + +When("{word} attempts to read the member Property {string} from Maya's community", async (actorName: string, propertyName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(ReadMemberProperty.identifiedAs(propertyName, await propertyIdFor(actor, propertyName))); +}); + +When("{word} reads the member Property {string} from Maya's community", async (actorName: string, propertyName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(ReadMemberProperty.identifiedAs(propertyName, await propertyIdFor(actor, propertyName))); +}); + +When('{word} attempts to update an unknown member Property', async (actorName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(UpdateMemberProperty.with('Unknown Member Property', UNKNOWN_PROPERTY_ID, { listedInDirectory: true })); +}); + +When('{word} deletes the member Property {string}', async (actorName: string, propertyName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(DeleteMemberProperty.identifiedAs(propertyName, await propertyIdFor(actor, propertyName))); +}); + +Then('the member Property operation succeeds', async () => { + await expectOperationSucceeded(); +}); + +Then('the member Property operation is rejected', async () => { + await expectOperationRejected(); +}); + +Then('the member Property error is exactly {string}', async (expectedError: string) => { + const actual = await actorCalled(lastActorName).answer(MemberPropertyOperationError()); + if (actual !== expectedError) { + throw new Error(`Expected the member Property error to be exactly "${expectedError}", but got "${actual ?? 'none'}"`); + } +}); + +Then('the member Property directory includes {string}', async (propertyName: string) => { + await expectDirectoryContains(propertyName, true); +}); + +Then('the member Property directory does not include {string}', async (propertyName: string) => { + await expectDirectoryContains(propertyName, false); +}); + +Then('the member Property {string} is persisted with {word} as its owner', async (propertyName: string, ownerActorName: string) => { + const property = await inspectProperty(propertyName); + const expectedOwnerId = await actorCalled(ownerActorName).answer(notes().get('actingMemberId')); + if (!property?.owner || property.owner.id !== expectedOwnerId) { + throw new Error(`Expected the member Property "${propertyName}" to be owned by ${ownerActorName} (${expectedOwnerId}), but it is owned by ${property?.owner?.id ?? 'nobody'}`); + } +}); + +Then('the member Property {string} has listing flag {string} set to {string}', async (propertyName: string, flag: string, expectedValue: string) => { + if (flag !== 'listedInDirectory') { + throw new Error(`Only the representative listing flag "listedInDirectory" can be asserted, not "${flag}"`); + } + const property = await inspectProperty(propertyName); + const actual = property?.listedInDirectory; + if (actual !== (expectedValue === 'true')) { + throw new Error(`Expected the member Property "${propertyName}" to have ${flag}=${expectedValue}, but got ${actual ?? 'missing property'}`); + } +}); + +Then('{word} sees the member Property {string} listing flag {string} set to {string}', async (actorName: string, propertyName: string, flag: string, expectedValue: string) => { + if (flag !== 'listedInDirectory') { + throw new Error(`Only the representative listing flag "listedInDirectory" can be asserted, not "${flag}"`); + } + const property = await actorCalled(actorName).answer(MemberPropertyNamed.called(propertyName)); + if (property?.listedInDirectory !== (expectedValue === 'true')) { + throw new Error(`Expected ${actorName} to see ${flag}=${expectedValue} for "${propertyName}", but got ${property?.listedInDirectory ?? 'missing property'}`); + } +}); + +Then('the member Property {string} has no property type', async (propertyName: string) => { + const property = await inspectProperty(propertyName); + if (property?.propertyType !== null) { + throw new Error(`Expected the member Property "${propertyName}" to have no property type, but got "${property?.propertyType ?? 'missing property'}"`); + } +}); + +Then('the member Property {string} remains owned by {word}', async (propertyName: string, ownerActorName: string) => { + const property = await inspectProperty(propertyName); + const expectedOwnerId = await actorCalled(ownerActorName).answer(notes().get('actingMemberId')); + if (property?.owner?.id !== expectedOwnerId) { + throw new Error(`Expected the member Property "${propertyName}" to remain owned by ${ownerActorName}, but it is owned by ${property?.owner?.id ?? 'nobody'}`); + } +}); + +Then('the member Property {string} remains active', async (propertyName: string) => { + if (!(await inspectProperty(propertyName))) { + throw new Error(`Expected the member Property "${propertyName}" to remain active, but the manager cannot find it`); + } +}); + +Then("the member Property {string} remains in Maya's community", async (propertyName: string) => { + const property = await inspectProperty(propertyName); + const { communityId } = await mayaCommunity(); + if (property?.community.id !== communityId) { + throw new Error(`Expected the member Property "${propertyName}" to remain in Maya's community "${communityId}", but it belongs to "${property?.community.id ?? 'no community'}"`); + } +}); + +Then('no member Property named {string} is created', async (propertyName: string) => { + const property = await inspectProperty(propertyName); + if (property) { + throw new Error(`Expected no member Property named "${propertyName}" to be created, but it has id ${property.id}`); + } +}); + +Then('the member Property details are unavailable', async () => { + const actor = actorCalled(lastActorName); + const status = await actor.answer(MemberPropertyReadStatus()); + if (status !== 'MISSING') { + const error = await actor.answer(MemberPropertyReadError()); + throw new Error(`Expected the member Property read to be non-disclosing and unavailable, but it was "${status}"${error ? `: ${error}` : ''}`); + } +}); + +Then('the member Property details are available', async () => { + await expectDetailsAvailable(); +}); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/arrange-member-property.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/arrange-member-property.ts new file mode 100644 index 000000000..7cb05d559 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/arrange-member-property.ts @@ -0,0 +1,103 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { actors, END_USER_IDS } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { getActorContext, getActorToken, setActorContext, setActorToken, userTokenFor } from '../../../shared/abilities/actor-auth.ts'; +import { ProvisionMemberPropertyFixture as ProvisionMemberPropertyFixtureAbility } from '../../../shared/abilities/provision-member-property-fixture.ts'; +import { PROPERTY_FULL_CREATE_MUTATION, type PropertyFullMutationResult } from '../../../shared/graphql/property-field-operations.ts'; +import type { MemberPropertyNotes } from '../notes/member-property-notes.ts'; +import { memberPropertyCommunityOf, rememberMemberPropertyId } from './member-property-context.ts'; + +const FOREIGN_MEMBER_NAME = 'Foreign Same-Community Owner'; + +/** + * Adds a second accepted member to the current community for foreign-owner + * directory and authorization scenarios. + */ +export class MakeForeignMemberAvailable extends Task { + static inCurrentCommunity(): MakeForeignMemberAvailable { + return new MakeForeignMemberAvailable(); + } + + private constructor() { + super('makes a foreign same-community member available for property ownership'); + } + + async performAs(actor: Actor): Promise { + const community = await memberPropertyCommunityOf(actor); + const foreignMember = await ProvisionMemberPropertyFixtureAbility.as(actor).performAs(actor, { + communityId: community.communityId, + endUserId: END_USER_IDS.otherCommunityOwner, + memberName: FOREIGN_MEMBER_NAME, + firstName: actors.OtherCommunityOwner.givenName, + lastName: actors.OtherCommunityOwner.familyName, + accountStatus: 'ACCEPTED', + canEditOwnProperty: false, + canManageProperties: false, + }); + await actor.attemptsTo(notes().set('foreignMemberId', foreignMember.memberId)); + } +} + +type MemberPropertyOwner = 'current-member' | 'foreign-member'; + +/** + * Uses the established manager identity only to arrange an existing listing. + * The original actor identity is restored before the scenario action begins. + */ +export class ArrangeMemberProperty extends Task { + static ownedByCurrentMember(propertyName: string): ArrangeMemberProperty { + return new ArrangeMemberProperty(propertyName, 'current-member'); + } + + static ownedByForeignMember(propertyName: string): ArrangeMemberProperty { + return new ArrangeMemberProperty(propertyName, 'foreign-member'); + } + + private constructor( + private readonly propertyName: string, + private readonly owner: MemberPropertyOwner, + ) { + super(`arranges the member Property "${propertyName}"`); + } + + async performAs(actor: Actor): Promise { + const community = await memberPropertyCommunityOf(actor); + const ownerId = await this.ownerId(actor); + const priorToken = getActorToken(actor.name); + const priorContext = getActorContext(actor.name); + setActorToken(actor.name, userTokenFor(actors.CommunityOwner.name)); + setActorContext(actor.name, { memberId: community.managerMemberId, communityId: community.communityId }); + + try { + const response = await GraphQLClient.as(actor).execute(PROPERTY_FULL_CREATE_MUTATION, { + input: { + propertyName: this.propertyName, + ownerId, + }, + }); + const result = response.data.propertyCreate as PropertyFullMutationResult | undefined; + if (result?.status.success !== true || !result.property?.id) { + throw new Error(`Could not arrange the member Property "${this.propertyName}": ${result?.status.errorMessage ?? 'no property was returned'}`); + } + await rememberMemberPropertyId(actor, this.propertyName, result.property.id); + } finally { + setActorToken(actor.name, priorToken); + setActorContext(actor.name, priorContext ?? null); + } + } + + private async ownerId(actor: Actor): Promise { + if (this.owner === 'current-member') { + const currentMemberId = await actor.answer(notes().get('actingMemberId')); + if (!currentMemberId) { + throw new Error('No current member id is available to arrange an owned member Property'); + } + return currentMemberId; + } + const foreignMemberId = await actor.answer(notes().get('foreignMemberId')); + if (!foreignMemberId) { + throw new Error('No foreign member is available. Arrange a foreign same-community member before creating its property.'); + } + return foreignMemberId; + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/become-separate-own-property-member.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/become-separate-own-property-member.ts new file mode 100644 index 000000000..fd48b9daf --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/become-separate-own-property-member.ts @@ -0,0 +1,44 @@ +import { actors, END_USER_IDS } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { setActorContext, setActorToken, userTokenFor } from '../../../shared/abilities/actor-auth.ts'; +import { ProvisionMemberPropertyFixture as ProvisionMemberPropertyFixtureAbility } from '../../../shared/abilities/provision-member-property-fixture.ts'; +import type { PropertyNotes } from '../../property/notes/property-notes.ts'; +import { BecomePropertyManager } from '../../property/tasks/become-property-manager.ts'; +import { initialiseMemberPropertyNotes } from './member-property-context.ts'; + +const SEPARATE_MEMBER_NAME = 'Quinn Separate Own-Property Member'; + +/** Arranges an accepted own-property member whose active community differs from Maya's. */ +export class BecomeSeparateOwnPropertyMember extends Task { + static inNewCommunity(): BecomeSeparateOwnPropertyMember { + return new BecomeSeparateOwnPropertyMember(); + } + + private constructor() { + super('becomes an accepted own-property member of a separate community'); + } + + async performAs(actor: Actor): Promise { + setActorToken(actor.name, userTokenFor(actors.OtherCommunityOwner.name)); + await actor.attemptsTo(BecomePropertyManager.ofANewCommunity()); + const communityId = await actor.answer(notes().get('activeCommunityId')); + const communityName = await actor.answer(notes().get('activeCommunityName')); + const managerMemberId = await actor.answer(notes().get('actingMemberId')); + if (!communityId || !managerMemberId) { + throw new Error('The separate manager arrangement did not return a community and member id'); + } + + const member = await ProvisionMemberPropertyFixtureAbility.as(actor).performAs(actor, { + communityId, + endUserId: END_USER_IDS.otherCommunityOwner, + memberName: SEPARATE_MEMBER_NAME, + firstName: actors.OtherCommunityOwner.givenName, + lastName: actors.OtherCommunityOwner.familyName, + accountStatus: 'ACCEPTED', + canEditOwnProperty: true, + canManageProperties: false, + }); + setActorContext(actor.name, { memberId: member.memberId, communityId }); + await initialiseMemberPropertyNotes(actor, { communityId, communityName, managerMemberId, actingMemberId: member.memberId }); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/establish-member-property-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/establish-member-property-community.ts new file mode 100644 index 000000000..2ba3b8917 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/establish-member-property-community.ts @@ -0,0 +1,50 @@ +import { actors, END_USER_IDS } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { setActorContext, setActorToken, userTokenFor } from '../../../shared/abilities/actor-auth.ts'; +import { ProvisionMemberPropertyFixture as ProvisionMemberPropertyFixtureAbility } from '../../../shared/abilities/provision-member-property-fixture.ts'; +import type { PropertyNotes } from '../../property/notes/property-notes.ts'; +import { BecomePropertyManager } from '../../property/tasks/become-property-manager.ts'; +import { initialiseMemberPropertyNotes } from './member-property-context.ts'; + +const MAYA_MEMBER_NAME = 'Maya Own-Property Member'; + +/** + * Creates a fresh community through the existing manager flow, then replaces + * the acting test principal with an accepted own-property member of it. + */ +export class EstablishMemberPropertyCommunity extends Task { + static forOwnPropertyMember(): EstablishMemberPropertyCommunity { + return new EstablishMemberPropertyCommunity(); + } + + private constructor() { + super('becomes an accepted own-property member of a member Property community'); + } + + async performAs(actor: Actor): Promise { + setActorToken(actor.name, userTokenFor(actors.CommunityOwner.name)); + await actor.attemptsTo(BecomePropertyManager.ofANewCommunity()); + + const communityId = await actor.answer(notes().get('activeCommunityId')); + const communityName = await actor.answer(notes().get('activeCommunityName')); + const managerMemberId = await actor.answer(notes().get('actingMemberId')); + if (!communityId || !managerMemberId) { + throw new Error('The manager arrangement did not return a community and member id'); + } + + const ownMember = await ProvisionMemberPropertyFixtureAbility.as(actor).performAs(actor, { + communityId, + endUserId: END_USER_IDS.communityMember, + memberName: MAYA_MEMBER_NAME, + firstName: actors.CommunityMember.givenName, + lastName: actors.CommunityMember.familyName, + accountStatus: 'ACCEPTED', + canEditOwnProperty: true, + canManageProperties: false, + }); + + setActorToken(actor.name, userTokenFor(actors.CommunityMember.name)); + setActorContext(actor.name, { memberId: ownMember.memberId, communityId }); + await initialiseMemberPropertyNotes(actor, { communityId, communityName, managerMemberId, actingMemberId: ownMember.memberId }); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-context.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-context.ts new file mode 100644 index 000000000..3891ca5aa --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-context.ts @@ -0,0 +1,52 @@ +import { type Actor, notes } from '@serenity-js/core'; +import type { MemberPropertyNotes } from '../notes/member-property-notes.ts'; + +export interface MemberPropertyCommunityContext { + communityId: string; + communityName: string; + managerMemberId: string; +} + +interface MemberPropertyActorContext extends MemberPropertyCommunityContext { + actingMemberId: string; +} + +export async function initialiseMemberPropertyNotes(actor: Actor, context: MemberPropertyActorContext): Promise { + await actor.attemptsTo( + notes().set('activeCommunityId', context.communityId), + notes().set('activeCommunityName', context.communityName), + notes().set('actingMemberId', context.actingMemberId), + notes().set('managerMemberId', context.managerMemberId), + notes().set('foreignMemberId', null), + notes().set('knownPropertyIds', {}), + notes().set('listedPropertyNames', []), + notes().set('directoryStatus', null), + notes().set('directoryError', null), + notes().set('lastOperationStatus', null), + notes().set('lastOperationError', null), + notes().set('lastReadStatus', null), + notes().set('lastReadError', null), + notes().set('lastReadPropertyId', null), + notes().set('baselinePropertyNames', []), + ); +} + +export async function memberPropertyCommunityOf(actor: Actor): Promise { + const communityId = await actor.answer(notes().get('activeCommunityId')); + const communityName = await actor.answer(notes().get('activeCommunityName')); + const managerMemberId = await actor.answer(notes().get('managerMemberId')); + if (!communityId || !managerMemberId) { + throw new Error('No member Property community is available. Did the accepted own-property member setup run?'); + } + return { communityId, communityName, managerMemberId }; +} + +export async function memberPropertyIdKnownTo(actor: Actor, propertyName: string): Promise { + const propertyIds = await actor.answer(notes().get('knownPropertyIds')); + return propertyIds?.[propertyName]; +} + +export async function rememberMemberPropertyId(actor: Actor, propertyName: string, propertyId: string): Promise { + const current = (await actor.answer(notes().get('knownPropertyIds'))) ?? {}; + await actor.attemptsTo(notes().set('knownPropertyIds', { ...current, [propertyName]: propertyId })); +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-operations.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-operations.ts new file mode 100644 index 000000000..63a77057c --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-operations.ts @@ -0,0 +1,200 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { PROPERTY_FULL_CREATE_MUTATION, PROPERTY_FULL_UPDATE_MUTATION, type PropertyFullCreateInput, type PropertyFullMutationResult, type PropertyFullUpdateInput } from '../../../shared/graphql/property-field-operations.ts'; +import { PROPERTIES_BY_COMMUNITY_ID_QUERY, PROPERTY_BY_ID_QUERY, PROPERTY_DELETE_MUTATION, type PropertyMutationResult, type PropertyResult } from '../../../shared/graphql/property-operations.ts'; +import type { MemberPropertyNotes } from '../notes/member-property-notes.ts'; +import { memberPropertyCommunityOf, rememberMemberPropertyId } from './member-property-context.ts'; + +export interface MemberPropertyUpdateInput { + propertyName?: string; + propertyType?: string | null; + ownerId?: string | null; + listedInDirectory?: boolean; +} + +const mutationError = (result: PropertyFullMutationResult | PropertyMutationResult | undefined, action: string): string => String(result?.status?.errorMessage ?? `Member Property ${action} was rejected`); + +async function recordMutation(actor: Actor, action: string, result: PropertyFullMutationResult | PropertyMutationResult | undefined, propertyName?: string): Promise { + if (result?.status?.success === true) { + const propertyId = result.property?.id; + if (propertyName && propertyId) { + await rememberMemberPropertyId(actor, propertyName, propertyId); + } + await actor.attemptsTo(notes().set('lastOperationStatus', 'SUCCESS'), notes().set('lastOperationError', null)); + return; + } + await actor.attemptsTo(notes().set('lastOperationStatus', 'REJECTED'), notes().set('lastOperationError', mutationError(result, action))); +} + +async function recordOperationError(actor: Actor, error: unknown): Promise { + const message = error instanceof Error ? error.message : String(error); + await actor.attemptsTo(notes().set('lastOperationStatus', 'ERROR'), notes().set('lastOperationError', message)); +} + +/** Sends a minimal member Property create request without an ownerId. */ +export class CreateMemberProperty extends Task { + static named(propertyName: string): CreateMemberProperty { + return new CreateMemberProperty(propertyName); + } + + private constructor(private readonly propertyName: string) { + super(`creates the member Property "${propertyName}"`); + } + + async performAs(actor: Actor): Promise { + try { + const response = await GraphQLClient.as(actor).execute(PROPERTY_FULL_CREATE_MUTATION, { + input: { propertyName: this.propertyName } satisfies PropertyFullCreateInput, + }); + await recordMutation(actor, 'create', response.data.propertyCreate as PropertyFullMutationResult | undefined, this.propertyName); + } catch (error) { + await recordOperationError(actor, error); + } + } +} + +/** Sends a deliberately raw create payload containing a forbidden ownerId. */ +export class AttemptMemberPropertyCreateWithOwner extends Task { + static named(propertyName: string, ownerId: string): AttemptMemberPropertyCreateWithOwner { + return new AttemptMemberPropertyCreateWithOwner(propertyName, ownerId); + } + + private constructor( + private readonly propertyName: string, + private readonly ownerId: string, + ) { + super(`attempts to create the member Property "${propertyName}" with an owner id`); + } + + async performAs(actor: Actor): Promise { + try { + const response = await GraphQLClient.as(actor).execute(PROPERTY_FULL_CREATE_MUTATION, { + input: { propertyName: this.propertyName, ownerId: this.ownerId } satisfies PropertyFullCreateInput, + }); + await recordMutation(actor, 'create', response.data.propertyCreate as PropertyFullMutationResult | undefined, this.propertyName); + } catch (error) { + await recordOperationError(actor, error); + } + } +} + +/** Sends a member Property update request and retains its observable result. */ +export class UpdateMemberProperty extends Task { + static with(propertyName: string, propertyId: string, input: MemberPropertyUpdateInput): UpdateMemberProperty { + return new UpdateMemberProperty(propertyName, propertyId, input); + } + + private constructor( + private readonly propertyName: string, + private readonly propertyId: string, + private readonly input: MemberPropertyUpdateInput, + ) { + super(`updates the member Property "${propertyName}"`); + } + + async performAs(actor: Actor): Promise { + try { + const response = await GraphQLClient.as(actor).execute(PROPERTY_FULL_UPDATE_MUTATION, { + input: { id: this.propertyId, ...this.input } satisfies PropertyFullUpdateInput, + }); + await recordMutation(actor, 'update', response.data.propertyUpdate as PropertyFullMutationResult | undefined, this.propertyName); + } catch (error) { + await recordOperationError(actor, error); + } + } +} + +/** Sends a member Property delete request and retains its observable result. */ +export class DeleteMemberProperty extends Task { + static identifiedAs(propertyName: string, propertyId: string): DeleteMemberProperty { + return new DeleteMemberProperty(propertyName, propertyId); + } + + private constructor( + propertyName: string, + private readonly propertyId: string, + ) { + super(`deletes the member Property "${propertyName}"`); + } + + async performAs(actor: Actor): Promise { + try { + const response = await GraphQLClient.as(actor).execute(PROPERTY_DELETE_MUTATION, { input: { id: this.propertyId } }); + await recordMutation(actor, 'delete', response.data.propertyDelete as PropertyMutationResult | undefined); + } catch (error) { + await recordOperationError(actor, error); + } + } +} + +/** Reads a member Property directory and records both success and failure state. */ +export class OpenMemberPropertyDirectory extends Task { + static currentCommunity(): OpenMemberPropertyDirectory { + return new OpenMemberPropertyDirectory(undefined); + } + + static community(communityId: string): OpenMemberPropertyDirectory { + return new OpenMemberPropertyDirectory(communityId); + } + + private constructor(private readonly requestedCommunityId: string | undefined) { + super('opens the member Property directory'); + } + + async performAs(actor: Actor): Promise { + const community = await memberPropertyCommunityOf(actor); + const communityId = this.requestedCommunityId ?? community.communityId; + try { + const response = await GraphQLClient.as(actor).execute(PROPERTIES_BY_COMMUNITY_ID_QUERY, { communityId }); + const properties = response.data.propertiesByCommunityId as PropertyResult[]; + await actor.attemptsTo( + notes().set( + 'listedPropertyNames', + properties.map((property) => property.propertyName), + ), + notes().set('directoryStatus', 'SUCCESS'), + notes().set('directoryError', null), + notes().set('lastOperationStatus', 'SUCCESS'), + notes().set('lastOperationError', null), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await actor.attemptsTo( + notes().set('listedPropertyNames', []), + notes().set('directoryStatus', 'ERROR'), + notes().set('directoryError', message), + notes().set('lastOperationStatus', 'ERROR'), + notes().set('lastOperationError', message), + ); + } + } +} + +/** Reads a detail by id and records a found, missing, or transport/error outcome. */ +export class ReadMemberProperty extends Task { + static identifiedAs(propertyName: string, propertyId: string): ReadMemberProperty { + return new ReadMemberProperty(propertyName, propertyId); + } + + private constructor( + propertyName: string, + private readonly propertyId: string, + ) { + super(`reads the member Property "${propertyName}"`); + } + + async performAs(actor: Actor): Promise { + try { + const response = await GraphQLClient.as(actor).execute(PROPERTY_BY_ID_QUERY, { id: this.propertyId }); + const property = response.data.property as PropertyResult | null; + await actor.attemptsTo( + notes().set('lastReadStatus', property ? 'FOUND' : 'MISSING'), + notes().set('lastReadError', null), + notes().set('lastReadPropertyId', property?.id ?? null), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await actor.attemptsTo(notes().set('lastReadStatus', 'ERROR'), notes().set('lastReadError', message), notes().set('lastReadPropertyId', null)); + } + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-personas.ts b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-personas.ts new file mode 100644 index 000000000..385912cd7 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/member-property/tasks/member-property-personas.ts @@ -0,0 +1,69 @@ +import { actors, END_USER_IDS, type MemberPropertyFixture } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, Task } from '@serenity-js/core'; +import { setActorContext, setActorToken, userTokenFor } from '../../../shared/abilities/actor-auth.ts'; +import { ProvisionMemberPropertyFixture as ProvisionMemberPropertyFixtureAbility } from '../../../shared/abilities/provision-member-property-fixture.ts'; +import { initialiseMemberPropertyNotes, type MemberPropertyCommunityContext } from './member-property-context.ts'; + +export type MemberPropertyVisitor = 'no-permission' | 'no-role' | 'created' | 'rejected' | 'guest'; + +const visitorFixture = (actorName: string, visitor: Exclude, communityId: string): MemberPropertyFixture => { + const accountStatus = visitor === 'created' ? 'CREATED' : visitor === 'rejected' ? 'REJECTED' : 'ACCEPTED'; + return { + communityId, + endUserId: END_USER_IDS.otherCommunityOwner, + memberName: `${actorName} Member Property Visitor`, + firstName: actors.OtherCommunityOwner.givenName, + lastName: actors.OtherCommunityOwner.familyName, + accountStatus, + canEditOwnProperty: visitor !== 'no-permission' && visitor !== 'no-role', + canManageProperties: false, + hasRole: visitor !== 'no-role', + }; +}; + +/** Makes an actor a deliberately ineligible principal in Maya's community. */ +export class BecomeMemberPropertyVisitor extends Task { + static inCommunity(visitor: MemberPropertyVisitor, community: MemberPropertyCommunityContext): BecomeMemberPropertyVisitor { + return new BecomeMemberPropertyVisitor(visitor, community); + } + + private constructor( + private readonly visitor: MemberPropertyVisitor, + private readonly community: MemberPropertyCommunityContext, + ) { + super(`becomes a ${visitor} member Property visitor`); + } + + async performAs(actor: Actor): Promise { + let memberId = ''; + if (this.visitor === 'guest') { + setActorToken(actor.name, null); + setActorContext(actor.name, null); + } else { + const fixture = visitorFixture(actor.name, this.visitor, this.community.communityId); + const member = await ProvisionMemberPropertyFixtureAbility.as(actor).performAs(actor, fixture); + memberId = member.memberId; + setActorToken(actor.name, userTokenFor(actors.OtherCommunityOwner.name)); + setActorContext(actor.name, { memberId, communityId: this.community.communityId }); + } + + await initialiseMemberPropertyNotes(actor, { ...this.community, actingMemberId: memberId }); + } +} + +/** Configures an actor to use the manager membership already provisioned for Maya's community. */ +export class BecomeMemberPropertyManager extends Task { + static inCommunity(community: MemberPropertyCommunityContext): BecomeMemberPropertyManager { + return new BecomeMemberPropertyManager(community); + } + + private constructor(private readonly community: MemberPropertyCommunityContext) { + super('becomes the property manager of the member Property community'); + } + + async performAs(actor: Actor): Promise { + setActorToken(actor.name, userTokenFor(actors.CommunityOwner.name)); + setActorContext(actor.name, { memberId: this.community.managerMemberId, communityId: this.community.communityId }); + await initialiseMemberPropertyNotes(actor, { ...this.community, actingMemberId: this.community.managerMemberId }); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/servers/test-mongo-server.ts b/packages/ocom-verification/acceptance-api/src/servers/test-mongo-server.ts index 25d8474fc..4f2ac38c3 100644 --- a/packages/ocom-verification/acceptance-api/src/servers/test-mongo-server.ts +++ b/packages/ocom-verification/acceptance-api/src/servers/test-mongo-server.ts @@ -22,9 +22,9 @@ export const testMongoServer = new MongoMemoryProcessTestServer({ }); function getDevScript(): 'dev' | 'dev:worktree' { - return process.env['WORKTREE_NAME'] ? 'dev:worktree' : 'dev'; + return process.env.WORKTREE_NAME ? 'dev:worktree' : 'dev'; } -function mongoConnectionString(): string { +export function mongoConnectionString(): string { return `mongodb://127.0.0.1:${getMongoPort()}/${mongoDbName}?replicaSet=${mongoReplSetName}`; } diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts index f6bf7af78..9dd02e4bf 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts @@ -4,6 +4,7 @@ export { createPropertyAbility } from './create-property.ts'; export { createStaffRoleAbility } from './create-staff-role.ts'; export { deletePropertyAbility } from './delete-property.ts'; export { createGraphQLClientAbility } from './graphql-client.ts'; +export { provisionMemberPropertyFixtureAbility } from './provision-member-property-fixture.ts'; export { provisionResidentMemberAbility } from './provision-resident-member.ts'; export { updatePropertyAbility } from './update-property.ts'; export { updateStaffRoleAbility } from './update-staff-role.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/provision-member-property-fixture.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/provision-member-property-fixture.ts new file mode 100644 index 000000000..7d3c72218 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/provision-member-property-fixture.ts @@ -0,0 +1,28 @@ +import { type MemberPropertyFixture, type MemberPropertyFixtureIds, provisionMemberPropertyFixture } from '@ocom-verification/verification-shared/test-data'; +import { Ability, type Actor } from '@serenity-js/core'; +import { mongoConnectionString, mongoDbName } from '../../servers/test-mongo-server.ts'; + +type ProvisionMemberPropertyFixtureHandler = (actor: Actor, fixture: MemberPropertyFixture) => Promise; + +/** + * Arrangement-only ability that provisions a member and role directly in the + * verification database. Product behavior is always exercised through GraphQL; + * direct writes only create principals unavailable through the public API. + */ +export class ProvisionMemberPropertyFixture extends Ability { + constructor(private readonly handler: ProvisionMemberPropertyFixtureHandler) { + super(); + } + + static using(handler: ProvisionMemberPropertyFixtureHandler): ProvisionMemberPropertyFixture { + return new ProvisionMemberPropertyFixture(handler); + } + + async performAs(actor: Actor, fixture: MemberPropertyFixture): Promise { + return await this.handler(actor, fixture); + } +} + +export function provisionMemberPropertyFixtureAbility(): ProvisionMemberPropertyFixture { + return ProvisionMemberPropertyFixture.using(async (_actor, fixture) => await provisionMemberPropertyFixture({ connectionString: mongoConnectionString(), dbName: mongoDbName }, fixture)); +} diff --git a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts index e0a9a3828..bf05989db 100644 --- a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts @@ -5,6 +5,7 @@ import '../contexts/community/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; +import '../contexts/member-property/step-definitions/index.ts'; import '../contexts/property/step-definitions/index.ts'; import '../contexts/staff/step-definitions/index.ts'; import '../contexts/staff-role/step-definitions/index.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index 2f189a986..6e96cd6ce 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -10,6 +10,7 @@ import { createStaffRoleAbility } from './shared/abilities/create-staff-role.ts' import { deletePropertyAbility } from './shared/abilities/delete-property.ts'; import { createGraphQLClientAbility } from './shared/abilities/graphql-client.ts'; import { provisionDeactivatedPropertyManagerAbility } from './shared/abilities/provision-deactivated-property-manager.ts'; +import { provisionMemberPropertyFixtureAbility } from './shared/abilities/provision-member-property-fixture.ts'; import { provisionResidentMemberAbility } from './shared/abilities/provision-resident-member.ts'; import { updatePropertyAbility } from './shared/abilities/update-property.ts'; import { updateStaffRoleAbility } from './shared/abilities/update-staff-role.ts'; @@ -35,6 +36,7 @@ export const CellixApiWorld = registerManagedSerenityWorld({ () => deletePropertyAbility(), () => provisionResidentMemberAbility(), () => provisionDeactivatedPropertyManagerAbility(), + () => provisionMemberPropertyFixtureAbility(), ], }), }); diff --git a/packages/ocom-verification/acceptance-ui/package.json b/packages/ocom-verification/acceptance-ui/package.json index 767f64a02..1c916855d 100644 --- a/packages/ocom-verification/acceptance-ui/package.json +++ b/packages/ocom-verification/acceptance-ui/package.json @@ -31,6 +31,7 @@ "@ocom-verification/verification-shared": "workspace:*", "@ocom/ui-community-route-accounts": "workspace:*", "@ocom/ui-community-route-admin": "workspace:*", + "@ocom/ui-community-route-member": "workspace:*", "@ocom/ui-community-route-root": "workspace:*", "@ocom/ui-staff-route-root": "workspace:*", "@ocom/ui-staff-route-user-management": "workspace:*", diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/member-property/abilities/mock-member-property-backend.ts b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/abilities/mock-member-property-backend.ts new file mode 100644 index 000000000..7355e10e1 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/abilities/mock-member-property-backend.ts @@ -0,0 +1,300 @@ +import type { MockedResponse } from '@apollo/client/testing'; +import { MemberPropertiesListDocument, MemberPropertiesRouteGuardMembersForCurrentEndUserDocument, MemberPropertyCreateDocument, MemberPropertyDetailDocument, MemberPropertyUpdateDocument } from '@ocom/ui-community-route-member'; +import { GraphQLError } from 'graphql'; + +const MEMBER_PROPERTY_COMMUNITY_ID = '65e1a77bcf86cd79943900c1'; +const MEMBER_PROPERTY_OWN_EDITOR_ID = '65e1a77bcf86cd79943900a2'; +const MEMBER_PROPERTY_MANAGER_ID = '65e1a77bcf86cd79943900a1'; +const MEMBER_PROPERTY_FOREIGN_OWNER_ID = '65e1a77bcf86cd79943900a3'; +export const MEMBER_PROPERTY_FOREIGN_OWNER_NAME = 'Foreign Same-Community Owner'; +const MEMBER_PROPERTY_BASE_PATH = `/community/${MEMBER_PROPERTY_COMMUNITY_ID}/member/${MEMBER_PROPERTY_OWN_EDITOR_ID}/properties`; +export const MEMBER_PROPERTY_ADMIN_BASE_PATH = `/community/${MEMBER_PROPERTY_COMMUNITY_ID}/admin/${MEMBER_PROPERTY_MANAGER_ID}/properties`; + +export type MemberPropertyUiVisitor = 'own-editor' | 'manager' | 'no-permission' | 'no-role' | 'nonaccepted' | 'guest' | 'mismatched-route'; + +interface MemberPropertyMockRecord { + id: string; + propertyName: string; + propertyType: string | null; + listedInDirectory: boolean; + ownerId: string; + deleted: boolean; +} + +interface MemberPropertyMutationInput { + id?: string; + propertyName?: string; + propertyType?: string | null; + ownerId?: string | null; + listedInDirectory?: boolean; +} + +const CURRENT_END_USER_ID = '65e1a77bcf86cd79943900e1'; + +let visitor: MemberPropertyUiVisitor = 'own-editor'; +let properties: MemberPropertyMockRecord[] = []; +let nextId = 0; + +export function resetMemberPropertyUiState(): void { + visitor = 'own-editor'; + properties = []; + nextId = 0; +} + +export function useMemberPropertyUiVisitor(nextVisitor: MemberPropertyUiVisitor): void { + visitor = nextVisitor; +} + +export function memberPropertyRouteFor(activeVisitor = visitor): string { + if (activeVisitor === 'manager') { + return `/community/${MEMBER_PROPERTY_COMMUNITY_ID}/member/${MEMBER_PROPERTY_MANAGER_ID}/properties`; + } + if (activeVisitor === 'mismatched-route') { + return `/community/${MEMBER_PROPERTY_COMMUNITY_ID}/member/65e1a77bcf86cd79943900ff/properties`; + } + return MEMBER_PROPERTY_BASE_PATH; +} + +export function ensureForeignMemberProperty(propertyName: string): void { + if (properties.some((property) => property.propertyName === propertyName && !property.deleted)) { + return; + } + properties.push({ + id: nextPropertyId(), + propertyName, + propertyType: null, + listedInDirectory: false, + ownerId: MEMBER_PROPERTY_FOREIGN_OWNER_ID, + deleted: false, + }); +} + +export function ensureOwnMemberProperty(propertyName: string): void { + if (properties.some((property) => property.propertyName === propertyName && !property.deleted)) { + return; + } + properties.push({ + id: nextPropertyId(), + propertyName, + propertyType: null, + listedInDirectory: false, + ownerId: MEMBER_PROPERTY_OWN_EDITOR_ID, + deleted: false, + }); +} + +/** Read-only inspection of the mock persistence state used by member UI assertions. */ +export function memberPropertyMockRecord(propertyName: string): MemberPropertyMockRecord | undefined { + return properties.find((property) => property.propertyName === propertyName && !property.deleted); +} + +const nextPropertyId = (): string => { + nextId += 1; + return `65e1b${nextId.toString(16).padStart(19, '0')}`; +}; + +const mayView = (): boolean => visitor === 'own-editor' || visitor === 'manager'; +const mayManage = (): boolean => visitor === 'manager'; +const mayEdit = (property: MemberPropertyMockRecord): boolean => mayManage() || (visitor === 'own-editor' && property.ownerId === MEMBER_PROPERTY_OWN_EDITOR_ID); + +const toListProperty = (property: MemberPropertyMockRecord) => ({ + __typename: 'Property' as const, + id: property.id, + propertyName: property.propertyName, + propertyType: property.propertyType, + listingDetail: null, + location: null, + updatedAt: null, +}); + +const toDetailProperty = (property: MemberPropertyMockRecord) => ({ + __typename: 'Property' as const, + id: property.id, + propertyName: property.propertyName, + propertyType: property.propertyType, + listedForSale: false, + listedForRent: false, + listedForLease: false, + listedInDirectory: property.listedInDirectory, + tags: [], + owner: { __typename: 'PropertyOwnerOption' as const, id: property.ownerId }, + location: { + __typename: 'PropertyLocation' as const, + address: { + __typename: 'PropertyAddress' as const, + streetNumber: null, + streetName: null, + municipality: null, + countrySubdivision: null, + postalCode: null, + country: null, + }, + }, + listingDetail: { + __typename: 'PropertyListingDetail' as const, + price: null, + rentHigh: null, + rentLow: null, + lease: null, + maxGuests: null, + bedrooms: null, + bathrooms: null, + squareFeet: null, + yearBuilt: null, + lotSize: null, + description: null, + amenities: [], + bedroomDetails: [], + additionalAmenities: [], + images: [], + video: null, + floorPlan: null, + floorPlanImages: [], + listingAgent: null, + listingAgentPhone: null, + listingAgentEmail: null, + listingAgentWebsite: null, + listingAgentCompany: null, + listingAgentCompanyPhone: null, + listingAgentCompanyEmail: null, + listingAgentCompanyWebsite: null, + listingAgentCompanyAddress: null, + }, + createdAt: null, + updatedAt: null, +}); + +const toCreateProperty = (property: MemberPropertyMockRecord) => ({ + __typename: 'Property' as const, + id: property.id, + propertyName: property.propertyName, + owner: { __typename: 'PropertyOwnerOption' as const, id: property.ownerId }, +}); + +const rejectedMutation = (field: 'propertyCreate' | 'propertyUpdate', errorMessage: string) => ({ + data: { + [field]: { + __typename: 'PropertyMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: false, errorMessage }, + property: null, + }, + }, +}); + +const guardMembership = () => { + if (visitor === 'guest') { + return []; + } + const routeMemberId = visitor === 'manager' ? MEMBER_PROPERTY_MANAGER_ID : MEMBER_PROPERTY_OWN_EDITOR_ID; + const accepted = visitor !== 'nonaccepted'; + return [ + { + __typename: 'Member' as const, + id: routeMemberId, + accounts: [{ __typename: 'MemberAccount' as const, statusCode: accepted ? 'ACCEPTED' : 'CREATED', user: { __typename: 'EndUser' as const, id: CURRENT_END_USER_ID } }], + role: + visitor === 'no-role' + ? null + : { + __typename: 'EndUserRole' as const, + permissions: { + __typename: 'EndUserRolePermissions' as const, + propertyPermissions: { + __typename: 'EndUserRolePropertyPermissions' as const, + canManageProperties: visitor === 'manager', + canEditOwnProperty: visitor === 'own-editor', + }, + }, + }, + community: { __typename: 'Community' as const, id: MEMBER_PROPERTY_COMMUNITY_ID }, + }, + ]; +}; + +/** + * Dynamic route mocks use the actual Phase 3 documents. No owner-options or + * delete mock is intentionally registered: such a request fails the test. + */ +export const buildMemberPropertyMocks = (): MockedResponse[] => [ + { + request: { query: MemberPropertiesRouteGuardMembersForCurrentEndUserDocument }, + maxUsageCount: Number.POSITIVE_INFINITY, + result: () => ({ + data: { + currentEndUserAndCreateIfNotExists: { __typename: 'EndUser', id: CURRENT_END_USER_ID }, + membersForCurrentEndUser: guardMembership(), + }, + }), + }, + { + request: { query: MemberPropertiesListDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: () => { + if (!mayView()) return { errors: [new GraphQLError('Unauthorized')] }; + return { data: { propertiesByCommunityId: properties.filter((property) => !property.deleted).map(toListProperty) } }; + }, + }, + { + request: { query: MemberPropertyDetailDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { id?: string }) => { + if (!mayView()) return { data: { property: null } }; + const property = properties.find((candidate) => candidate.id === variables.id && !candidate.deleted); + return { data: { property: property ? toDetailProperty(property) : null } }; + }, + }, + { + request: { query: MemberPropertyCreateDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { input?: MemberPropertyMutationInput }) => { + const input = variables.input ?? {}; + if (visitor !== 'own-editor') return rejectedMutation('propertyCreate', 'Unauthorized'); + if (input.ownerId !== undefined) return rejectedMutation('propertyCreate', 'Owner must be assigned by the server'); + if (!input.propertyName?.trim()) return rejectedMutation('propertyCreate', 'Property name is required'); + const property: MemberPropertyMockRecord = { + id: nextPropertyId(), + propertyName: input.propertyName, + propertyType: null, + listedInDirectory: false, + ownerId: MEMBER_PROPERTY_OWN_EDITOR_ID, + deleted: false, + }; + properties.push(property); + return { + data: { + propertyCreate: { + __typename: 'PropertyMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null }, + property: toCreateProperty(property), + }, + }, + }; + }, + }, + { + request: { query: MemberPropertyUpdateDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { input?: MemberPropertyMutationInput }) => { + const input = variables.input ?? {}; + const property = properties.find((candidate) => candidate.id === input.id && !candidate.deleted); + if (!property || !mayEdit(property)) return rejectedMutation('propertyUpdate', 'Property not found'); + if (!mayManage() && (input.propertyName !== undefined || input.propertyType !== undefined || input.ownerId !== undefined)) { + return rejectedMutation('propertyUpdate', 'Property not found'); + } + if (input.listedInDirectory !== undefined) property.listedInDirectory = input.listedInDirectory; + return { + data: { + propertyUpdate: { + __typename: 'PropertyMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null }, + property: toDetailProperty(property), + }, + }, + }; + }, + }, +]; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/member-property/notes/member-property-ui-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/notes/member-property-ui-notes.ts new file mode 100644 index 000000000..385a162f3 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/notes/member-property-ui-notes.ts @@ -0,0 +1,7 @@ +import type { MemberPropertyUiVisitor } from '../abilities/mock-member-property-backend.ts'; + +/** Scenario-local member route state for DOM acceptance tests. */ +export interface MemberPropertyUiNotes { + visitor: MemberPropertyUiVisitor; + memberPropertyRoute: string; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/member-property/questions/member-property-screen.ts b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/questions/member-property-screen.ts new file mode 100644 index 000000000..25981ff43 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/questions/member-property-screen.ts @@ -0,0 +1,42 @@ +import { type Actor, Question } from '@serenity-js/core'; +import { memberListPageFor, memberPropertyCurrentRouteFor, memberReadOnlyDetailPageFor, OpenMemberPropertyDirectory } from '../tasks/member-property-screen.tsx'; + +export const MemberPropertyDirectoryRendered = () => + Question.about('whether the member Property directory rendered', async (actor) => { + const page = memberListPageFor(actor); + return ( + (await page.adapter.locator('.ant-table').isVisible()) || + (await memberReadOnlyDetailPageFor(actor) + .adapter.getByText(/^Property Details$/) + .isVisible() + .catch(() => false)) + ); + }); + +export const MemberPropertyDirectoryNames = () => + Question.about('the visible member Property directory names', async (actor) => { + await (actor as Actor).attemptsTo(OpenMemberPropertyDirectory()); + return await memberListPageFor(actor).listedPropertyNames(); + }); + +export const MemberPropertyDetailIsReadOnly = () => + Question.about('whether the member Property detail is read-only', async (actor) => { + const detail = memberReadOnlyDetailPageFor(actor); + return (await detail.heading.isVisible()) && !(await detail.hasEditableControls()); + }); + +export const ForeignOwnerIdentityIsVisible = (ownerName: string) => + Question.about(`whether the foreign owner identity "${ownerName}" is visible`, async (actor) => { + return await memberReadOnlyDetailPageFor(actor).displaysOwnerIdentity(ownerName); + }); + +export const MemberPropertyRouteDenied = () => + Question.about('whether the member Property route shows an access-denied result', async (actor) => { + const currentRoute = (await memberPropertyCurrentRouteFor(actor).textContent()) ?? ''; + if (!currentRoute.includes('/member/')) return true; + return await memberReadOnlyDetailPageFor(actor) + .adapter.getByText(/access denied|forbidden|not authorized/i) + .isVisible(); + }); + +export const MemberPropertyCurrentRoute = () => Question.about('the current member Property route', async (actor) => (await memberPropertyCurrentRouteFor(actor).textContent()) ?? ''); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/member-property/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/step-definitions/index.ts new file mode 100644 index 000000000..6da6a6691 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/step-definitions/index.ts @@ -0,0 +1 @@ +import './member-property.steps.ts'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/member-property/step-definitions/member-property.steps.ts b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/step-definitions/member-property.steps.ts new file mode 100644 index 000000000..14c52b69f --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/step-definitions/member-property.steps.ts @@ -0,0 +1,142 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, actorInTheSpotlight } from '@serenity-js/core'; +import { + ensureForeignMemberProperty, + ensureOwnMemberProperty, + MEMBER_PROPERTY_ADMIN_BASE_PATH, + MEMBER_PROPERTY_FOREIGN_OWNER_NAME, + type MemberPropertyUiVisitor, + memberPropertyMockRecord, + resetMemberPropertyUiState, + useMemberPropertyUiVisitor, +} from '../abilities/mock-member-property-backend.ts'; +import { + ForeignOwnerIdentityIsVisible, + MemberPropertyCurrentRoute, + MemberPropertyDetailIsReadOnly, + MemberPropertyDirectoryNames, + MemberPropertyDirectoryRendered, + MemberPropertyRouteDenied, +} from '../questions/member-property-screen.ts'; +import { CreateMemberPropertyViaForm, OpenMemberPropertyDetail, OpenMemberPropertyDirectory, PrepareMemberPropertyVisitor, UpdateMemberPropertyListingFlagViaForm } from '../tasks/member-property-screen.tsx'; + +let lastActorName = 'Maya'; + +const routeVisitor = (visitor: string): MemberPropertyUiVisitor => { + const visitors: Record = { + 'no-permission': 'no-permission', + 'no-role': 'no-role', + nonaccepted: 'nonaccepted', + guest: 'guest', + 'mismatched-route': 'mismatched-route', + }; + const resolved = visitors[visitor]; + if (!resolved) { + throw new Error(`Unsupported member Property route visitor "${visitor}"`); + } + return resolved; +}; + +Given('{word} is an accepted own-property member in a member Property community', async (actorName: string) => { + lastActorName = actorName; + resetMemberPropertyUiState(); + useMemberPropertyUiVisitor('own-editor'); + await actorCalled(actorName).attemptsTo(PrepareMemberPropertyVisitor('own-editor')); +}); + +Given('a foreign same-community member owns the member Property {string}', (propertyName: string) => { + ensureForeignMemberProperty(propertyName); +}); + +Given('{word} owns the member Property {string}', (_actorName: string, propertyName: string) => { + ensureOwnMemberProperty(propertyName); +}); + +Given("{word} is a {string} member Property route visitor in Maya's community", async (actorName: string, visitor: string) => { + lastActorName = actorName; + const routePersona = routeVisitor(visitor); + useMemberPropertyUiVisitor(routePersona); + await actorCalled(actorName).attemptsTo(PrepareMemberPropertyVisitor(routePersona)); +}); + +Given("{word} is the property manager of Maya's member Property community", async (actorName: string) => { + lastActorName = actorName; + useMemberPropertyUiVisitor('manager'); + await actorCalled(actorName).attemptsTo(PrepareMemberPropertyVisitor('manager')); +}); + +When('{word} opens the member Property directory', async (actorName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(OpenMemberPropertyDirectory()); +}); + +When('{word} opens their member Property directory', async (actorName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(OpenMemberPropertyDirectory()); +}); + +When('{word} opens the member Property details for {string}', async (actorName: string, propertyName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(OpenMemberPropertyDetail(propertyName)); +}); + +When('{word} creates a member Property named {string}', async (actorName: string, propertyName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(CreateMemberPropertyViaForm(propertyName)); +}); + +When('{word} sets the member Property {string} listing flag {string} to {string}', async (actorName: string, propertyName: string, flag: string, value: string) => { + if (flag !== 'listedInDirectory' || (value !== 'true' && value !== 'false')) { + throw new Error(`Unsupported member Property UI listing update ${flag}=${value}`); + } + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(UpdateMemberPropertyListingFlagViaForm(propertyName, value === 'true')); +}); + +Then('the member Property operation succeeds', async () => { + if (!(await actorCalled(lastActorName).answer(MemberPropertyDirectoryRendered()))) { + throw new Error('Expected the member Property operation to succeed, but the member Property route did not render'); + } +}); + +Then('the member Property directory includes {string}', async (propertyName: string) => { + const names = await actorCalled(lastActorName).answer(MemberPropertyDirectoryNames()); + if (!names.includes(propertyName)) { + throw new Error(`Expected the member Property directory to include "${propertyName}", but it listed: ${names.join(', ') || 'none'}`); + } +}); + +Then('the member Property detail is read-only', async () => { + if (!(await actorInTheSpotlight().answer(MemberPropertyDetailIsReadOnly()))) { + throw new Error('Expected the foreign member Property detail to render as read-only'); + } +}); + +Then('the member Property detail does not display the foreign owner identity', async () => { + if (await actorInTheSpotlight().answer(ForeignOwnerIdentityIsVisible(MEMBER_PROPERTY_FOREIGN_OWNER_NAME))) { + throw new Error(`Expected the foreign owner identity "${MEMBER_PROPERTY_FOREIGN_OWNER_NAME}" to be hidden from the member detail`); + } +}); + +Then('the member Property {string} has listing flag {string} set to {string}', (propertyName: string, flag: string, expectedValue: string) => { + if (flag !== 'listedInDirectory') { + throw new Error(`Unsupported member Property UI flag "${flag}"`); + } + const property = memberPropertyMockRecord(propertyName); + if (!property || property.listedInDirectory !== (expectedValue === 'true')) { + throw new Error(`Expected "${propertyName}" to persist ${flag}=${expectedValue}`); + } +}); + +Then('{word} is denied the member Property route', async (actorName: string) => { + if (!(await actorCalled(actorName).answer(MemberPropertyRouteDenied()))) { + throw new Error(`Expected ${actorName} to see the member Property access-denied result`); + } +}); + +Then('{word} is redirected to the admin Property directory', async (actorName: string) => { + const currentRoute = await actorCalled(actorName).answer(MemberPropertyCurrentRoute()); + if (currentRoute !== MEMBER_PROPERTY_ADMIN_BASE_PATH) { + throw new Error(`Expected ${actorName} to be redirected to "${MEMBER_PROPERTY_ADMIN_BASE_PATH}", but the member route remained at "${currentRoute}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/member-property/tasks/member-property-screen.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/tasks/member-property-screen.tsx new file mode 100644 index 000000000..c68b6559f --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/member-property/tasks/member-property-screen.tsx @@ -0,0 +1,136 @@ +import { Render, RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { Member } from '@ocom/ui-community-route-member'; +import { MemberPropertiesListPage, MemberPropertyReadOnlyDetailPage, PropertyFormPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, notes, Task, type UsesAbilities } from '@serenity-js/core'; +import React from 'react'; +import { Route, Routes, useLocation } from 'react-router-dom'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import { buildMemberPropertyMocks, type MemberPropertyUiVisitor, memberPropertyRouteFor } from '../abilities/mock-member-property-backend.ts'; +import type { MemberPropertyUiNotes } from '../notes/member-property-ui-notes.ts'; + +const ROUTE_PATH_SELECTOR = '.member-property-current-route'; + +const CurrentRoute: React.FC = () => { + const location = useLocation(); + return {location.pathname}; +}; + +export const memberListPageFor = (actor: UsesAbilities): MemberPropertiesListPage => new MemberPropertiesListPage(new DomPageAdapter(RenderInDom.as(actor).container)); + +export const memberReadOnlyDetailPageFor = (actor: UsesAbilities): MemberPropertyReadOnlyDetailPage => new MemberPropertyReadOnlyDetailPage(new DomPageAdapter(RenderInDom.as(actor).container)); + +const memberPropertyFormPageFor = (actor: UsesAbilities): PropertyFormPage => new PropertyFormPage(new DomPageAdapter(RenderInDom.as(actor).container)); + +export const memberPropertyCurrentRouteFor = (actor: UsesAbilities) => new DomPageAdapter(RenderInDom.as(actor).container).locator(ROUTE_PATH_SELECTOR); + +const pause = (): Promise => new Promise((resolve) => setTimeout(resolve, 10)); + +type RouteContentPredicate = (adapter: DomPageAdapter) => Promise; + +const isVisible = async (predicate: () => Promise): Promise => predicate().catch(() => false); + +const directoryIsVisible: RouteContentPredicate = (adapter) => isVisible(() => adapter.getByText(/Community Properties \(/).isVisible()); +const deniedResultIsVisible: RouteContentPredicate = (adapter) => isVisible(() => adapter.locator('.member-property-route-forbidden').isVisible()); +const propertyFormIsVisible: RouteContentPredicate = (adapter) => isVisible(() => adapter.getByLabel('Property Name').isVisible()); +const propertyDetailIsVisible: RouteContentPredicate = async (adapter) => + (await isVisible(() => adapter.locator('.property-read-only-detail').isVisible())) || (await isVisible(() => adapter.locator('form').isVisible())) || (await isVisible(() => adapter.getByText('Property Not Found').isVisible())); + +const waitForMemberRoute = async (actor: Actor, matchesRoute: (route: string) => boolean, hasExpectedContent: RouteContentPredicate): Promise => { + for (let attempt = 0; attempt < 100; attempt += 1) { + const route = (await memberPropertyCurrentRouteFor(actor).textContent()) ?? ''; + if (matchesRoute(route) && (await hasExpectedContent(new DomPageAdapter(RenderInDom.as(actor).container)))) { + return; + } + await pause(); + } + throw new Error('The member Property route did not settle after navigation.'); +}; + +const waitForRouteResolution = async (actor: Actor): Promise => { + await waitForMemberRoute( + actor, + (route) => !route.includes('/member/') || route.endsWith('/properties'), + async (adapter) => (await directoryIsVisible(adapter)) || (await deniedResultIsVisible(adapter)) || !((await memberPropertyCurrentRouteFor(actor).textContent()) ?? '').includes('/member/'), + ); +}; + +export const PrepareMemberPropertyVisitor = (visitor: MemberPropertyUiVisitor): Task => + Task.where( + `#actor becomes a ${visitor} member Property route visitor`, + new TaskStep('#actor records the member Property route visitor', async (actor) => { + const route = memberPropertyRouteFor(visitor); + await actor.attemptsTo(notes().set('visitor', visitor), notes().set('memberPropertyRoute', route)); + }), + ); + +const RenderMemberPropertyRoute = (): Task => + Task.where( + '#actor renders the member Property route', + new TaskStep('#actor mounts the member Property route boundary', async (actor) => { + const route = await actor.answer(notes().get('memberPropertyRoute')); + await actor.attemptsTo( + Render.component( + React.createElement( + React.Fragment, + null, + React.createElement( + Routes, + null, + React.createElement(Route, { + path: '/community/:communityId/member/:memberId/*', + element: React.createElement(Member), + }), + React.createElement(Route, { + path: '*', + element: React.createElement('div', { role: 'status' }, 'Member Property route redirected'), + }), + ), + React.createElement(CurrentRoute), + ), + { wrapper: wrapOcomComponent({ mocks: buildMemberPropertyMocks(), initialEntries: [route] }) }, + ), + ); + await waitForRouteResolution(actor); + }), + ); + +export const OpenMemberPropertyDirectory = (): Task => Task.where('#actor opens the member Property directory', RenderMemberPropertyRoute()); + +export const OpenMemberPropertyDetail = (propertyName: string): Task => + Task.where( + `#actor opens the member Property details for "${propertyName}"`, + OpenMemberPropertyDirectory(), + new TaskStep('#actor selects the member Property details action', async (actor) => { + await memberListPageFor(actor).clickViewForProperty(propertyName); + await waitForMemberRoute(actor, (route) => /\/properties\/[^/]+$/.test(route), propertyDetailIsVisible); + }), + ); + +export const CreateMemberPropertyViaForm = (propertyName: string): Task => + Task.where( + `#actor creates the member Property "${propertyName}"`, + OpenMemberPropertyDirectory(), + new TaskStep('#actor submits the member Property create form', async (actor) => { + const list = memberListPageFor(actor); + await list.clickAddProperty(); + await waitForMemberRoute(actor, (route) => route.endsWith('/properties/create'), propertyFormIsVisible); + const form = memberPropertyFormPageFor(actor); + await form.fillPropertyName(propertyName); + await form.clickCreate(); + await waitForMemberRoute(actor, (route) => route.endsWith('/properties'), directoryIsVisible); + }), + ); + +export const UpdateMemberPropertyListingFlagViaForm = (propertyName: string, value: boolean): Task => + Task.where( + `#actor updates ${propertyName} in the member Property form`, + OpenMemberPropertyDetail(propertyName), + new TaskStep('#actor saves the member-editable listing flag', async (actor) => { + const form = memberPropertyFormPageFor(actor); + await form.setListingFlag('listedInDirectory', value); + await form.clickSave(); + await pause(); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts index 2b91f89a9..873414154 100644 --- a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts @@ -4,6 +4,7 @@ */ import '../contexts/community/step-definitions/index.ts'; +import '../contexts/member-property/step-definitions/index.ts'; import '../contexts/property/step-definitions/index.ts'; import '../contexts/staff/step-definitions/index.ts'; import '../contexts/staff-role/step-definitions/index.ts'; diff --git a/packages/ocom-verification/acceptance-ui/tsconfig.json b/packages/ocom-verification/acceptance-ui/tsconfig.json index b4b255926..d8dbe8b39 100644 --- a/packages/ocom-verification/acceptance-ui/tsconfig.json +++ b/packages/ocom-verification/acceptance-ui/tsconfig.json @@ -23,6 +23,8 @@ "../../cellix/serenity-framework/src/dom/css-module-types.d.ts", "../../cellix/serenity-framework/src/dom/css-types.d.ts", "../../ocom/ui-community-route-admin/src/**/*.tsx", + "../../ocom/ui-community-route-member/src/**/*.tsx", + "../../ocom/ui-community-shared/src/**/*.tsx", "../../ocom/ui-community-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-user-management/src/**/*.tsx", diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/abilities/member-property-portal-page.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/abilities/member-property-portal-page.ts new file mode 100644 index 000000000..5dbce502b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/abilities/member-property-portal-page.ts @@ -0,0 +1,96 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { MemberPropertiesListPage, MemberPropertyReadOnlyDetailPage, PropertyFormPage } from '@ocom-verification/verification-shared/pages'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { type AnswersQuestions, notes, type UsesAbilities } from '@serenity-js/core'; +import type { BrowserContext, Page } from 'playwright'; +import { infrastructure } from '../../../infrastructure.ts'; +import { performOAuth2Login } from '../../../shared/abilities/oauth2-login.ts'; +import type { MemberPropertyBrowserVisitor, MemberPropertyE2ENotes } from '../notes/member-property-notes.ts'; + +interface PortalCredentials { + email: string; + password: string; +} + +interface MemberPropertyPortalSession { + context: BrowserContext; + page: Page; + authenticated: boolean; +} + +const credentialsFor = (visitor: MemberPropertyBrowserVisitor): PortalCredentials | undefined => { + switch (visitor) { + case 'own-editor': + case 'mismatched-route': + return { email: actors.CommunityMember.email, password: 'password' }; + case 'manager': + return { email: actors.CommunityOwner.email, password: 'password' }; + case 'no-permission': + case 'no-role': + case 'nonaccepted': + return { email: actors.OtherCommunityOwner.email, password: 'password' }; + case 'guest': + return undefined; + } +}; + +const sessions = new Map(); + +/** Returns an isolated community-portal page for the requested member fixture identity. */ +async function memberPropertyPortalPageFor(visitor: MemberPropertyBrowserVisitor): Promise { + let session = sessions.get(visitor); + if (!session) { + const context = await infrastructure.newPortalContext('community'); + session = { context, page: await context.newPage(), authenticated: false }; + sessions.set(visitor, session); + } + const credentials = credentialsFor(visitor); + if (credentials && !session.authenticated) { + await performOAuth2Login(session.page, credentials, '/community/accounts'); + session.authenticated = true; + } + return session.page; +} + +/** Closes all isolated member Property browser contexts created by the suite. */ +export async function closeMemberPropertyPortalSessions(): Promise { + for (const session of sessions.values()) { + await session.context.close().catch(() => undefined); + } + sessions.clear(); +} + +export const memberPropertyPortalPageOf = async (actor: AnswersQuestions): Promise => { + const visitor = await actor.answer(notes().get('visitor')); + return await memberPropertyPortalPageFor(visitor); +}; + +export const memberPropertiesListOn = (page: Page): MemberPropertiesListPage => new MemberPropertiesListPage(new PlaywrightPageAdapter(page)); + +export const memberPropertyReadOnlyDetailOn = (page: Page): MemberPropertyReadOnlyDetailPage => new MemberPropertyReadOnlyDetailPage(new PlaywrightPageAdapter(page)); + +export const memberPropertyFormOn = (page: Page): PropertyFormPage => new PropertyFormPage(new PlaywrightPageAdapter(page)); + +export async function memberBasePathOf(actor: AnswersQuestions & UsesAbilities): Promise { + const basePath = await actor.answer(notes().get('memberBasePath')); + if (!basePath) { + throw new Error('No member Property base path was recorded. Did the member Property setup run?'); + } + return basePath; +} + +export async function adminBasePathOfMemberProperty(actor: AnswersQuestions & UsesAbilities): Promise { + const basePath = await actor.answer(notes().get('adminBasePath')); + if (!basePath) { + throw new Error('No admin Property base path was recorded. Did the member Property setup run?'); + } + return basePath; +} + +export const memberPropertyRouteDiagnostic = async (page: Page): Promise => { + const body = await page + .locator('body') + .textContent() + .catch(() => null); + return `url=${page.url()}; body=${body?.replace(/\s+/g, ' ').trim().slice(0, 400) ?? ''}`; +}; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/open-member-property-detail.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/open-member-property-detail.ts new file mode 100644 index 000000000..e38441f48 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/open-member-property-detail.ts @@ -0,0 +1,20 @@ +import { type Actor, Interaction, the } from '@serenity-js/core'; +import { memberPropertiesListOn, memberPropertyPortalPageOf, memberPropertyRouteDiagnostic } from '../abilities/member-property-portal-page.ts'; +import { OpenMemberPropertyDirectory } from './open-member-property-directory.ts'; + +/** Opens a member Property detail from the directory once the member route is available. */ +export const OpenMemberPropertyDetail = (propertyName: string) => + Interaction.where(the`#actor opens the member Property details for "${propertyName}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + await actor.attemptsTo(OpenMemberPropertyDirectory()); + const page = await memberPropertyPortalPageOf(actor); + const list = memberPropertiesListOn(page); + const headingVisible = await list.heading + .waitFor({ state: 'visible', timeout: 5_000 }) + .then(() => true) + .catch(() => false); + if (!headingVisible) { + throw new Error(`The member Property directory did not render before opening "${propertyName}": ${await memberPropertyRouteDiagnostic(page)}`); + } + await list.clickViewForProperty(propertyName); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/open-member-property-directory.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/open-member-property-directory.ts new file mode 100644 index 000000000..210a34d2a --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/open-member-property-directory.ts @@ -0,0 +1,14 @@ +import { type Actor, Interaction, the } from '@serenity-js/core'; +import { memberBasePathOf, memberPropertyPortalPageOf } from '../abilities/member-property-portal-page.ts'; + +/** + * Navigates to the actual member Property URL. It deliberately does not wait + * for a feature selector so Phase 1 reports the missing route in assertions, + * rather than mistaking it for browser infrastructure failure. + */ +export const OpenMemberPropertyDirectory = () => + Interaction.where(the`#actor opens the member Property directory`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await memberPropertyPortalPageOf(actor); + await page.goto(`${await memberBasePathOf(actor)}/properties`, { waitUntil: 'networkidle' }); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/record-member-property-id.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/record-member-property-id.ts new file mode 100644 index 000000000..7638422c9 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/interactions/record-member-property-id.ts @@ -0,0 +1,28 @@ +import { findMemberPropertyFixtureRecord } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { mongoConnectionString, mongoDbName } from '../../../servers/test-mongo-server.ts'; +import { waitUntil } from '../../property/abilities/admin-portal-page.ts'; +import type { MemberPropertyE2ENotes } from '../notes/member-property-notes.ts'; + +/** Records a fixture property's id in scenario-local member notes, never in the admin context's shared id map. */ +export const RecordMemberPropertyId = (propertyName: string) => + Interaction.where(the`#actor records the member Property id for "${propertyName}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const communityId = await actor.answer(notes().get('communityId')); + let property = await findMemberPropertyFixtureRecord({ connectionString: mongoConnectionString(), dbName: mongoDbName }, communityId, propertyName); + if (!property) { + await waitUntil( + async () => { + property = await findMemberPropertyFixtureRecord({ connectionString: mongoConnectionString(), dbName: mongoDbName }, communityId, propertyName); + return property !== undefined; + }, + `Could not record the member Property id for "${propertyName}" because it was not persisted`, + 5_000, + ); + } + if (!property) { + throw new Error(`Could not record the member Property id for "${propertyName}" because it was not persisted`); + } + const ids = await actor.answer(notes().get('memberPropertyIds')); + await actor.attemptsTo(notes().set('memberPropertyIds', { ...ids, [propertyName]: property.id })); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/notes/member-property-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/notes/member-property-notes.ts new file mode 100644 index 000000000..689f08bd3 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/notes/member-property-notes.ts @@ -0,0 +1,15 @@ +export type MemberPropertyBrowserVisitor = 'own-editor' | 'manager' | 'no-permission' | 'no-role' | 'nonaccepted' | 'guest' | 'mismatched-route'; + +/** Scenario-local state used by the member Property browser flows. */ +export interface MemberPropertyE2ENotes { + visitor: MemberPropertyBrowserVisitor; + communityId: string; + managerMemberId: string; + ownMemberId: string; + routeMemberId: string; + memberBasePath: string; + adminBasePath: string; + memberPropertyIds: Record; + lastOperationStatus?: 'SUCCESS' | 'FAILURE'; + lastOperationError?: string; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/questions/member-property-screen.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/questions/member-property-screen.ts new file mode 100644 index 000000000..2db14b6cc --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/questions/member-property-screen.ts @@ -0,0 +1,131 @@ +import { findMemberPropertyFixtureRecord } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, notes, Question } from '@serenity-js/core'; +import { mongoConnectionString, mongoDbName } from '../../../servers/test-mongo-server.ts'; +import { adminBasePathOfMemberProperty, memberBasePathOf, memberPropertiesListOn, memberPropertyPortalPageOf, memberPropertyReadOnlyDetailOn, memberPropertyRouteDiagnostic } from '../abilities/member-property-portal-page.ts'; +import type { MemberPropertyE2ENotes } from '../notes/member-property-notes.ts'; + +async function memberDirectoryIsRendered(actor: Actor): Promise { + const page = await memberPropertyPortalPageOf(actor); + return await memberPropertiesListOn(page).heading.isVisible(); +} + +/** Waits for a member directory row to appear, retaining route diagnostics on failure. */ +export const MemberPropertyDirectoryIncludes = (propertyName: string) => + Question.about(`whether the member Property directory includes "${propertyName}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await memberPropertyPortalPageOf(actor); + const list = memberPropertiesListOn(page); + const rendered = await list.heading + .waitFor({ state: 'visible', timeout: 5_000 }) + .then(() => true) + .catch(() => false); + if (!rendered) { + throw new Error(`Expected the member Property directory to render: ${await memberPropertyRouteDiagnostic(page)}`); + } + const exists = await list.hasPropertyNamed(propertyName); + if (!exists) { + throw new Error(`Expected the member Property directory to include "${propertyName}", but it listed: ${(await list.listedPropertyNames()).join(', ')}`); + } + return true; + }); + +/** Reports the form interaction result captured by the member route task. */ +export const MemberPropertyOperationSucceeds = () => + Question.about('whether the member Property operation succeeds', async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const status = await actor.answer(notes().get('lastOperationStatus')); + if (status !== 'SUCCESS') { + const error = await actor.answer(notes().get('lastOperationError')); + throw new Error(`Expected the member Property operation to succeed, but status was "${status ?? 'not recorded'}"${error ? `: ${error}` : ''}`); + } + return true; + }); + +/** Checks the allowed persisted listing-content mutation. */ +export const MemberPropertyListingFlagEquals = (propertyName: string, flagName: string, expectedValue: string) => + Question.about(`whether "${propertyName}" has ${flagName}=${expectedValue}`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const communityId = await actor.answer(notes().get('communityId')); + const property = await findMemberPropertyFixtureRecord({ connectionString: mongoConnectionString(), dbName: mongoDbName }, communityId, propertyName); + if (!property) { + throw new Error(`Expected "${propertyName}" to exist while checking its ${flagName}`); + } + if (flagName !== 'listedInDirectory') { + throw new Error(`Unsupported member Property listing flag assertion "${flagName}"`); + } + const expected = expectedValue === 'true'; + if (property.listedInDirectory !== expected) { + throw new Error(`Expected "${propertyName}" ${flagName} to be ${expected}, but it was ${property.listedInDirectory}`); + } + return true; + }); + +/** A foreign detail must provide information without exposing edit inputs or owner identity. */ +export const MemberPropertyDetailIsReadOnly = () => + Question.about('whether the member Property detail is read-only', async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await memberPropertyPortalPageOf(actor); + const detail = memberPropertyReadOnlyDetailOn(page); + const rendered = await detail.heading + .waitFor({ state: 'visible', timeout: 5_000 }) + .then(() => true) + .catch(() => false); + if (!rendered) { + throw new Error(`Expected a read-only member Property detail to render: ${await memberPropertyRouteDiagnostic(page)}`); + } + if (await detail.hasEditableControls()) { + throw new Error('Expected the foreign member Property detail to be read-only, but editable controls were visible'); + } + return true; + }); + +export const MemberPropertyDetailHidesForeignOwnerIdentity = () => + Question.about('whether the foreign owner identity is hidden', async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await memberPropertyPortalPageOf(actor); + if (await memberPropertyReadOnlyDetailOn(page).displaysOwnerIdentity('Other Owner')) { + throw new Error('Expected the foreign owner identity to be hidden from the member Property detail'); + } + return true; + }); + +/** Verifies that an ineligible visitor did not obtain the directory view. */ +export const MemberPropertyRouteIsDenied = () => + Question.about('whether the member Property route is denied', async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await memberPropertyPortalPageOf(actor); + if (await memberDirectoryIsRendered(actor)) { + throw new Error(`Expected the member Property route to be denied, but the directory rendered: ${await memberPropertyRouteDiagnostic(page)}`); + } + const pathname = new URL(page.url()).pathname; + if (!pathname.startsWith(await memberBasePathOf(actor))) { + return true; + } + const denied = await page + .getByText(/access denied|forbidden|not authorized|not found/i) + .isVisible() + .catch(() => false); + if (!denied) { + throw new Error(`Expected the member Property route to render an explicit denial or redirect away from the member subtree: ${await memberPropertyRouteDiagnostic(page)}`); + } + return true; + }); + +/** Verifies manager routing lands at the established admin Properties directory. */ +export const IsRedirectedToAdminPropertyDirectory = () => + Question.about('whether the visitor was redirected to the admin Property directory', async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await memberPropertyPortalPageOf(actor); + const expectedPath = `${await adminBasePathOfMemberProperty(actor)}/properties`; + const redirected = + new URL(page.url()).pathname === expectedPath || + (await page + .waitForURL((url) => url.pathname === expectedPath, { timeout: 5_000 }) + .then(() => true) + .catch(() => false)); + if (!redirected) { + const pathname = new URL(page.url()).pathname; + throw new Error(`Expected a manager member-Property URL to redirect to "${expectedPath}", but got "${pathname}": ${await memberPropertyRouteDiagnostic(page)}`); + } + return true; + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/step-definitions/index.ts new file mode 100644 index 000000000..6da6a6691 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/step-definitions/index.ts @@ -0,0 +1 @@ +import './member-property.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/step-definitions/member-property.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/step-definitions/member-property.steps.ts new file mode 100644 index 000000000..6120d539a --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/step-definitions/member-property.steps.ts @@ -0,0 +1,100 @@ +import { AfterAll, Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, actorInTheSpotlight } from '@serenity-js/core'; +import { closeMemberPropertyPortalSessions } from '../abilities/member-property-portal-page.ts'; +import { OpenMemberPropertyDetail } from '../interactions/open-member-property-detail.ts'; +import { + IsRedirectedToAdminPropertyDirectory, + MemberPropertyDetailHidesForeignOwnerIdentity, + MemberPropertyDetailIsReadOnly, + MemberPropertyDirectoryIncludes, + MemberPropertyListingFlagEquals, + MemberPropertyOperationSucceeds, + MemberPropertyRouteIsDenied, +} from '../questions/member-property-screen.ts'; +import { ArrangeForeignMemberProperty, ArrangeOwnMemberProperty, MakeForeignMemberAvailable } from '../tasks/arrange-member-property.ts'; +import { EstablishMemberPropertyCommunity } from '../tasks/establish-member-property-community.ts'; +import { BecomeMemberPropertyManager, BecomeMemberPropertyRouteVisitor, type MemberPropertyRouteVisitor } from '../tasks/member-property-personas.ts'; +import { CreateMemberPropertyViaForm, UpdateMemberPropertyListingFlagViaForm, ViewMemberPropertyDirectory } from '../tasks/member-property-route.ts'; + +const visitors: ReadonlySet = new Set(['no-permission', 'no-role', 'nonaccepted', 'guest', 'mismatched-route']); + +Given('Maya is an accepted own-property member in a member Property community', async () => { + await actorCalled('Maya').attemptsTo(EstablishMemberPropertyCommunity.forOwnPropertyMember()); +}); + +Given('a foreign same-community member owns the member Property {string}', async (propertyName: string) => { + await actorCalled('Maya').attemptsTo(MakeForeignMemberAvailable(), ArrangeForeignMemberProperty(propertyName)); +}); + +Given('Maya owns the member Property {string}', async (propertyName: string) => { + await actorCalled('Maya').attemptsTo(ArrangeOwnMemberProperty(propertyName)); +}); + +Given("{word} is a {string} member Property route visitor in Maya's community", async (actorName: string, visitorName: string) => { + if (!visitors.has(visitorName as MemberPropertyRouteVisitor)) { + throw new Error(`Unknown member Property route visitor "${visitorName}"`); + } + await actorCalled(actorName).attemptsTo(BecomeMemberPropertyRouteVisitor.inMayaCommunity(visitorName as MemberPropertyRouteVisitor, actorCalled('Maya'))); +}); + +Given("Morgan is the property manager of Maya's member Property community", async () => { + await actorCalled('Morgan').attemptsTo(BecomeMemberPropertyManager.inMayaCommunity(actorCalled('Maya'))); +}); + +When('Maya opens the member Property directory', async () => { + await actorCalled('Maya').attemptsTo(ViewMemberPropertyDirectory()); +}); + +When('{word} opens their member Property directory', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(ViewMemberPropertyDirectory()); +}); + +When('Maya creates a member Property named {string}', async (propertyName: string) => { + await actorCalled('Maya').attemptsTo(CreateMemberPropertyViaForm(propertyName)); +}); + +When('Maya opens the member Property details for {string}', async (propertyName: string) => { + await actorCalled('Maya').attemptsTo(OpenMemberPropertyDetail(propertyName)); +}); + +When('Maya sets the member Property {string} listing flag {string} to {string}', async (propertyName: string, flagName: string, value: string) => { + if (flagName !== 'listedInDirectory') { + throw new Error(`Unsupported member-editable listing flag "${flagName}"`); + } + if (value !== 'true' && value !== 'false') { + throw new Error(`Expected a boolean member listing flag value, but received "${value}"`); + } + await actorCalled('Maya').attemptsTo(UpdateMemberPropertyListingFlagViaForm(propertyName, value === 'true')); +}); + +Then('the member Property directory includes {string}', async (propertyName: string) => { + await actorInTheSpotlight().answer(MemberPropertyDirectoryIncludes(propertyName)); +}); + +Then('the member Property operation succeeds', async () => { + await actorInTheSpotlight().answer(MemberPropertyOperationSucceeds()); +}); + +Then('the member Property detail is read-only', async () => { + await actorInTheSpotlight().answer(MemberPropertyDetailIsReadOnly()); +}); + +Then('the member Property detail does not display the foreign owner identity', async () => { + await actorInTheSpotlight().answer(MemberPropertyDetailHidesForeignOwnerIdentity()); +}); + +Then('the member Property {string} has listing flag {string} set to {string}', async (propertyName: string, flagName: string, value: string) => { + await actorInTheSpotlight().answer(MemberPropertyListingFlagEquals(propertyName, flagName, value)); +}); + +Then('{word} is denied the member Property route', async (actorName: string) => { + await actorCalled(actorName).answer(MemberPropertyRouteIsDenied()); +}); + +Then('Morgan is redirected to the admin Property directory', async () => { + await actorCalled('Morgan').answer(IsRedirectedToAdminPropertyDirectory()); +}); + +AfterAll(async () => { + await closeMemberPropertyPortalSessions(); +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/arrange-member-property.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/arrange-member-property.ts new file mode 100644 index 000000000..1a093ec1a --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/arrange-member-property.ts @@ -0,0 +1,54 @@ +import { actors, END_USER_IDS, provisionMemberPropertyFixture } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, Interaction, notes, Task, the } from '@serenity-js/core'; +import { mongoConnectionString, mongoDbName } from '../../../servers/test-mongo-server.ts'; +import { OpenPropertyDetail } from '../../property/interactions/open-property-detail.ts'; +import { SelectPropertyOwner } from '../../property/interactions/select-property-owner.ts'; +import { SubmitPropertySave } from '../../property/interactions/submit-property-save.ts'; +import { CreatePropertyViaForm } from '../../property/tasks/create-property.ts'; +import { RecordMemberPropertyId } from '../interactions/record-member-property-id.ts'; +import type { MemberPropertyE2ENotes } from '../notes/member-property-notes.ts'; +import { FOREIGN_MEMBER_NAME, MAYA_MEMBER_NAME } from './establish-member-property-community.ts'; + +/** Provisions the foreign same-community owner required by read-only detail scenarios. */ +export const MakeForeignMemberAvailable = () => + Interaction.where(the`#actor makes a foreign same-community member available`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const communityId = await actor.answer(notes().get('communityId')); + await provisionMemberPropertyFixture( + { connectionString: mongoConnectionString(), dbName: mongoDbName }, + { + communityId, + endUserId: END_USER_IDS.otherCommunityOwner, + memberName: FOREIGN_MEMBER_NAME, + firstName: actors.OtherCommunityOwner.givenName, + lastName: actors.OtherCommunityOwner.familyName, + accountStatus: 'ACCEPTED', + canEditOwnProperty: false, + canManageProperties: false, + }, + ); + }); + +/** + * Reuses the live admin Property workflow to arrange fixtures, preserving the + * existing manager regression surface while member routes are added later. + */ +export const ArrangeForeignMemberProperty = (propertyName: string) => + Task.where( + the`#actor arranges a foreign member Property "${propertyName}"`, + CreatePropertyViaForm({ propertyName }), + OpenPropertyDetail(propertyName), + SelectPropertyOwner(FOREIGN_MEMBER_NAME), + SubmitPropertySave(), + RecordMemberPropertyId(propertyName), + ); + +export const ArrangeOwnMemberProperty = (propertyName: string) => + Task.where( + the`#actor arranges an own member Property "${propertyName}"`, + CreatePropertyViaForm({ propertyName }), + OpenPropertyDetail(propertyName), + SelectPropertyOwner(MAYA_MEMBER_NAME), + SubmitPropertySave(), + RecordMemberPropertyId(propertyName), + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/establish-member-property-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/establish-member-property-community.ts new file mode 100644 index 000000000..d24cf5e21 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/establish-member-property-community.ts @@ -0,0 +1,65 @@ +import { actors, END_USER_IDS, provisionMemberPropertyFixture } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { mongoConnectionString, mongoDbName } from '../../../servers/test-mongo-server.ts'; +import type { PropertyE2ENotes } from '../../property/notes/property-notes.ts'; +import { BecomePropertyManager } from '../../property/tasks/become-property-manager.ts'; +import type { MemberPropertyE2ENotes } from '../notes/member-property-notes.ts'; + +export const MAYA_MEMBER_NAME = 'Maya Own-Property Member'; +export const FOREIGN_MEMBER_NAME = 'Foreign Same-Community Owner'; + +interface MemberPropertyCommunityIds { + communityId: string; + managerMemberId: string; + adminBasePath: string; +} + +const parseAdminBasePath = (path: string): MemberPropertyCommunityIds => { + const match = path.match(/^\/community\/([a-f0-9]{24})\/admin\/([a-f0-9]{24})$/i); + const communityId = match?.[1]; + const managerMemberId = match?.[2]; + if (!communityId || !managerMemberId) { + throw new Error(`Expected the manager setup to record an admin portal path, but got "${path}"`); + } + return { communityId, managerMemberId, adminBasePath: path }; +}; + +/** Creates a manager community through the UI and provisions Maya as its own-property member fixture. */ +export class EstablishMemberPropertyCommunity extends Task { + static forOwnPropertyMember(): EstablishMemberPropertyCommunity { + return new EstablishMemberPropertyCommunity(); + } + + private constructor() { + super('becomes an accepted own-property member in a member Property community'); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo(BecomePropertyManager()); + const adminBasePath = await actor.answer(notes().get('adminBasePath')); + const community = parseAdminBasePath(adminBasePath); + const ownMember = await provisionMemberPropertyFixture( + { connectionString: mongoConnectionString(), dbName: mongoDbName }, + { + communityId: community.communityId, + endUserId: END_USER_IDS.communityMember, + memberName: MAYA_MEMBER_NAME, + firstName: actors.CommunityMember.givenName, + lastName: actors.CommunityMember.familyName, + accountStatus: 'ACCEPTED', + canEditOwnProperty: true, + canManageProperties: false, + }, + ); + await actor.attemptsTo( + notes().set('visitor', 'own-editor'), + notes().set('communityId', community.communityId), + notes().set('managerMemberId', community.managerMemberId), + notes().set('ownMemberId', ownMember.memberId), + notes().set('routeMemberId', ownMember.memberId), + notes().set('memberBasePath', `/community/${community.communityId}/member/${ownMember.memberId}`), + notes().set('adminBasePath', community.adminBasePath), + notes().set('memberPropertyIds', {}), + ); + } +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/member-property-personas.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/member-property-personas.ts new file mode 100644 index 000000000..1e13d0db9 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/member-property-personas.ts @@ -0,0 +1,89 @@ +import { actors, END_USER_IDS, provisionMemberPropertyFixture } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { mongoConnectionString, mongoDbName } from '../../../servers/test-mongo-server.ts'; +import type { MemberPropertyBrowserVisitor, MemberPropertyE2ENotes } from '../notes/member-property-notes.ts'; + +export type MemberPropertyRouteVisitor = 'no-permission' | 'no-role' | 'nonaccepted' | 'guest' | 'mismatched-route'; + +interface MemberPropertyCommunityRoute { + communityId: string; + managerMemberId: string; + ownMemberId: string; + adminBasePath: string; +} + +const routeContextFrom = async (actor: Actor): Promise => ({ + communityId: await actor.answer(notes().get('communityId')), + managerMemberId: await actor.answer(notes().get('managerMemberId')), + ownMemberId: await actor.answer(notes().get('ownMemberId')), + adminBasePath: await actor.answer(notes().get('adminBasePath')), +}); + +async function recordBrowserVisitor(actor: Actor, visitor: MemberPropertyBrowserVisitor, context: MemberPropertyCommunityRoute, routeMemberId: string): Promise { + await actor.attemptsTo( + notes().set('visitor', visitor), + notes().set('communityId', context.communityId), + notes().set('managerMemberId', context.managerMemberId), + notes().set('ownMemberId', context.ownMemberId), + notes().set('routeMemberId', routeMemberId), + notes().set('memberBasePath', `/community/${context.communityId}/member/${routeMemberId}`), + notes().set('adminBasePath', context.adminBasePath), + notes().set('memberPropertyIds', {}), + ); +} + +/** Configures a fresh browser actor to use the manager membership from Maya's community. */ +export class BecomeMemberPropertyManager extends Task { + static inMayaCommunity(maya: Actor): BecomeMemberPropertyManager { + return new BecomeMemberPropertyManager(maya); + } + + private constructor(private readonly maya: Actor) { + super('becomes the property manager of Maya’s member Property community'); + } + + async performAs(actor: Actor): Promise { + const context = await routeContextFrom(this.maya); + await recordBrowserVisitor(actor, 'manager', context, context.managerMemberId); + } +} + +/** Configures a browser actor as an ineligible visitor of Maya's member route. */ +export class BecomeMemberPropertyRouteVisitor extends Task { + static inMayaCommunity(visitor: MemberPropertyRouteVisitor, maya: Actor): BecomeMemberPropertyRouteVisitor { + return new BecomeMemberPropertyRouteVisitor(visitor, maya); + } + + private constructor( + private readonly visitor: MemberPropertyRouteVisitor, + private readonly maya: Actor, + ) { + super(`becomes a ${visitor} member Property route visitor`); + } + + async performAs(actor: Actor): Promise { + const context = await routeContextFrom(this.maya); + let routeMemberId = context.ownMemberId; + if (this.visitor === 'no-permission' || this.visitor === 'no-role' || this.visitor === 'nonaccepted') { + const member = await provisionMemberPropertyFixture( + { connectionString: mongoConnectionString(), dbName: mongoDbName }, + { + communityId: context.communityId, + endUserId: END_USER_IDS.otherCommunityOwner, + memberName: `${actor.name} Member Property Visitor`, + firstName: actors.OtherCommunityOwner.givenName, + lastName: actors.OtherCommunityOwner.familyName, + accountStatus: this.visitor === 'nonaccepted' ? 'CREATED' : 'ACCEPTED', + canEditOwnProperty: false, + canManageProperties: false, + hasRole: this.visitor !== 'no-role', + }, + ); + routeMemberId = member.memberId; + } + if (this.visitor === 'mismatched-route') { + routeMemberId = '65e1a77bcf86cd79943900ff'; + } + await recordBrowserVisitor(actor, this.visitor, context, routeMemberId); + } +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/member-property-route.ts b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/member-property-route.ts new file mode 100644 index 000000000..3322ec251 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/member-property/tasks/member-property-route.ts @@ -0,0 +1,98 @@ +import { type Actor, Interaction, notes, Task, the } from '@serenity-js/core'; +import { memberPropertiesListOn, memberPropertyFormOn, memberPropertyPortalPageOf, memberPropertyRouteDiagnostic } from '../abilities/member-property-portal-page.ts'; +import { OpenMemberPropertyDetail } from '../interactions/open-member-property-detail.ts'; +import { OpenMemberPropertyDirectory } from '../interactions/open-member-property-directory.ts'; +import { RecordMemberPropertyId } from '../interactions/record-member-property-id.ts'; +import type { MemberPropertyE2ENotes } from '../notes/member-property-notes.ts'; + +const responseErrorMessage = (payload: unknown): string => { + if (!payload || typeof payload !== 'object') { + return 'The GraphQL response had no mutation payload'; + } + const errors = (payload as { errors?: unknown }).errors; + if (Array.isArray(errors) && errors.length > 0) { + return errors.map((error) => (error && typeof error === 'object' && 'message' in error ? String(error.message) : String(error))).join(', '); + } + return 'The Property mutation did not report success'; +}; + +const waitForSuccessfulMemberPropertyMutation = async (page: Awaited>, operationName: string, resultField: 'propertyCreate' | 'propertyUpdate'): Promise => { + const response = await page.waitForResponse((candidate) => candidate.url().includes('/api/graphql') && candidate.request().method() === 'POST' && candidate.request().postData()?.includes(operationName) === true, { + timeout: 5_000, + }); + if (!response.ok()) { + throw new Error(`The ${operationName} request failed with HTTP ${response.status()}`); + } + const payload = (await response.json()) as unknown; + const results = Array.isArray(payload) ? payload : [payload]; + const mutationResult = results.find((result): result is { data?: Record } => + Boolean(result && typeof result === 'object' && 'data' in result && (result as { data?: Record }).data?.[resultField]), + ); + const status = mutationResult?.data?.[resultField]?.status; + if (!status?.success) { + throw new Error(status?.errorMessage ?? responseErrorMessage(results.find((result) => result && typeof result === 'object' && 'errors' in result))); + } +}; + +async function requireDirectory(actor: Actor): Promise { + const page = await memberPropertyPortalPageOf(actor); + const list = memberPropertiesListOn(page); + const rendered = await list.heading + .waitFor({ state: 'visible', timeout: 5_000 }) + .then(() => true) + .catch(() => false); + if (!rendered) { + throw new Error(`The member Property directory is not implemented: ${await memberPropertyRouteDiagnostic(page)}`); + } +} + +/** Opens the real route without hiding its Phase 1 missing-feature red baseline. */ +export const ViewMemberPropertyDirectory = () => Task.where(the`#actor views the member Property directory`, OpenMemberPropertyDirectory()); + +export const CreateMemberPropertyViaForm = (propertyName: string) => + Task.where( + the`#actor creates the member Property "${propertyName}"`, + notes().set('lastOperationStatus', undefined), + notes().set('lastOperationError', undefined), + OpenMemberPropertyDirectory(), + Interaction.where(the`#actor submits the member Property create form`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + try { + await requireDirectory(actor); + const page = await memberPropertyPortalPageOf(actor); + await memberPropertiesListOn(page).clickAddProperty(); + const form = memberPropertyFormOn(page); + await form.propertyNameInput.waitFor({ state: 'visible', timeout: 5_000 }); + await form.fillPropertyName(propertyName); + const creation = waitForSuccessfulMemberPropertyMutation(page, 'MemberPropertyCreate', 'propertyCreate'); + await form.clickCreate(); + await creation; + await actor.attemptsTo(RecordMemberPropertyId(propertyName)); + await actor.attemptsTo(notes().set('lastOperationStatus', 'SUCCESS')); + } catch (error) { + await actor.attemptsTo(notes().set('lastOperationStatus', 'FAILURE'), notes().set('lastOperationError', error instanceof Error ? error.message : String(error))); + } + }), + ); + +export const UpdateMemberPropertyListingFlagViaForm = (propertyName: string, value: boolean) => + Task.where( + the`#actor updates the member Property "${propertyName}"`, + notes().set('lastOperationStatus', undefined), + notes().set('lastOperationError', undefined), + OpenMemberPropertyDetail(propertyName), + Interaction.where(the`#actor saves the member-editable listing flag`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + try { + const page = await memberPropertyPortalPageOf(actor); + const form = memberPropertyFormOn(page); + await form.setListingFlag('listedInDirectory', value); + const update = waitForSuccessfulMemberPropertyMutation(page, 'MemberPropertyUpdate', 'propertyUpdate'); + await form.clickSave(); + await update; + await actor.attemptsTo(notes().set('lastOperationStatus', 'SUCCESS')); + } catch (error) { + await actor.attemptsTo(notes().set('lastOperationStatus', 'FAILURE'), notes().set('lastOperationError', error instanceof Error ? error.message : String(error))); + } + }), + ); diff --git a/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts b/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts index 9602aed37..64eae809b 100644 --- a/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts +++ b/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts @@ -3,7 +3,7 @@ import { getMongoPort } from '@ocom-verification/verification-shared/environment import { seedDatabase } from '@ocom-verification/verification-shared/test-data'; import { appPaths } from '../shared/environment/app-paths.ts'; -const mongoDbName = 'owner-community'; +export const mongoDbName = 'owner-community'; const mongoReplSetName = 'globaldb'; export const testMongoServer = new MongoMemoryProcessTestServer({ @@ -18,9 +18,9 @@ export const testMongoServer = new MongoMemoryProcessTestServer({ readyMarker: 'MongoDB Memory Replica Set ready at:', seedData: seedDatabase, serverName: 'TestMongoMemoryServer', - spawnArgs: () => ['run', process.env['WORKTREE_NAME'] ? 'dev:worktree' : 'dev'], + spawnArgs: () => ['run', process.env.WORKTREE_NAME ? 'dev:worktree' : 'dev'], }); -function mongoConnectionString(): string { +export function mongoConnectionString(): string { return `mongodb://127.0.0.1:${getMongoPort()}/${mongoDbName}?replicaSet=${mongoReplSetName}`; } diff --git a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts index 2b91f89a9..a177c6aa9 100644 --- a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts @@ -5,6 +5,7 @@ import '../contexts/community/step-definitions/index.ts'; import '../contexts/property/step-definitions/index.ts'; +import '../contexts/member-property/step-definitions/index.ts'; import '../contexts/staff/step-definitions/index.ts'; import '../contexts/staff-role/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts index ccf4b8b14..ad08918c1 100644 --- a/packages/ocom-verification/verification-shared/src/pages/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/index.ts @@ -1,5 +1,7 @@ export { CommunityPage } from './community.page.ts'; export { HomePage } from './home.page.ts'; +export { MemberPropertiesListPage } from './member-properties-list.page.ts'; +export { MemberPropertyReadOnlyDetailPage } from './member-property-read-only-detail.page.ts'; export { PropertiesListPage } from './properties-list.page.ts'; export { type FieldAdornment, type PropertyAddressSelectFieldKey, PropertyFormPage, type PropertyFormTextFieldKey, type PropertyListingFlagKey, stateCodeForName, stateNameForCode, US_STATE_OPTIONS } from './property-form.page.ts'; export { STAFF_ROLE_PERMISSION_LABELS, StaffRoleFormPage } from './staff-role-form.page.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/member-properties-list.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-properties-list.page.ts new file mode 100644 index 000000000..c623edb40 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/member-properties-list.page.ts @@ -0,0 +1,12 @@ +import type { ElementHandle } from '@cellix/serenity-framework/pages'; +import { PropertiesListPage } from './properties-list.page.ts'; + +/** + * Page contract for the member directory. It shares table interactions with + * the manager directory but deliberately does not expose owner-column APIs. + */ +export class MemberPropertiesListPage extends PropertiesListPage { + override get heading(): ElementHandle { + return this.adapter.getByText(/Community Properties \(/); + } +} diff --git a/packages/ocom-verification/verification-shared/src/pages/member-property-read-only-detail.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-property-read-only-detail.page.ts new file mode 100644 index 000000000..a1f793ba7 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/member-property-read-only-detail.page.ts @@ -0,0 +1,34 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +/** + * Page contract for a member's read-only view of another member's property. + * This intentionally has no editable-form APIs: foreign properties must never + * infer editability from an owner field rendered in the page. + */ +export class MemberPropertyReadOnlyDetailPage extends AdapterBackedPageObject { + get heading(): ElementHandle { + return this.adapter.getByText(/^Property Details$/); + } + + get editableControls(): ElementHandle { + return this.adapter.locator('input, textarea, [role="combobox"], [role="switch"]'); + } + + ownerIdentityNamed(memberName: string): ElementHandle { + return this.adapter.getByText(memberName); + } + + async hasEditableControls(): Promise { + if ((await this.adapter.locatorAll('input, textarea, [role="combobox"], [role="switch"]')).length > 0) { + return true; + } + return await this.adapter + .getByRole('button', { name: /^(?:Edit|Save|Remove|Delete)$/i }) + .isVisible() + .catch(() => false); + } + + async displaysOwnerIdentity(memberName: string): Promise { + return await this.ownerIdentityNamed(memberName).isVisible(); + } +} diff --git a/packages/ocom-verification/verification-shared/src/scenarios/property/member-property-authorization.feature b/packages/ocom-verification/verification-shared/src/scenarios/property/member-property-authorization.feature new file mode 100644 index 000000000..9473650a4 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/property/member-property-authorization.feature @@ -0,0 +1,110 @@ +@member-property @api-only +Feature: Member Property API authorization + + As a community + I want member Property requests authorized by accepted membership, community, and ownership + So that members can edit their own listings without gaining directory-manager powers + + Background: + Given Maya is an accepted own-property member in a member Property community + + Scenario: A foreign same-community update is masked as a missing property + Given a foreign same-community member owns the member Property "Neighbor Cottage" + And Morgan is the property manager of Maya's member Property community + When Maya attempts to set the member Property "Neighbor Cottage" listing flag "listedInDirectory" to "true" + Then the member Property operation is rejected + And the member Property error is exactly "Property not found" + When Maya attempts to update an unknown member Property + Then the member Property error is exactly "Property not found" + And Morgan sees the member Property "Neighbor Cottage" listing flag "listedInDirectory" set to "false" + + Scenario: An owner cannot mix a permitted update with a manager-only field + Given Maya owns the member Property "Maya Listing" + When Maya attempts to update the member Property "Maya Listing" with: + | propertyName | Renamed Listing | + | listedInDirectory | true | + Then the member Property operation is rejected + And the member Property "Maya Listing" has listing flag "listedInDirectory" set to "false" + And no member Property named "Renamed Listing" is created + + Scenario: An owner cannot change type, owner, or delete a property + Given Maya owns the member Property "Maya Listing" + When Maya attempts to update the member Property "Maya Listing" with: + | propertyType | condo | + Then the member Property operation is rejected + And the member Property "Maya Listing" has no property type + And a foreign same-community member is available for member Property ownership + When Maya attempts to update the member Property "Maya Listing" with: + | ownerId | foreign | + Then the member Property operation is rejected + When Maya attempts to update the member Property "Maya Listing" with: + | ownerId | clear | + Then the member Property operation is rejected + And the member Property "Maya Listing" remains owned by Maya + When Maya attempts to delete the member Property "Maya Listing" + Then the member Property operation is rejected + And the member Property "Maya Listing" remains active + And the member Property "Maya Listing" remains in Maya's community + + Scenario Outline: A supplied owner id cannot create a member Property + Given a foreign same-community member is available for member Property ownership + When Maya attempts to create a member Property named "Spoofed " with supplied owner id "" + Then the member Property operation is rejected + And no member Property named "Spoofed " is created + + Examples: + | owner | + | self | + | foreign | + + Scenario Outline: An ineligible visitor cannot list, read, create, or update member Properties + Given a foreign same-community member owns the member Property "Protected Cottage" + And is a "" member Property API visitor in Maya's community + When attempts to list Maya's member Property directory + Then the member Property operation is rejected + When attempts to read the member Property "Protected Cottage" from Maya's community + Then the member Property details are unavailable + When attempts to create a member Property named " Cabin" + Then the member Property operation is rejected + And no member Property named " Cabin" is created + When attempts to set the member Property "Protected Cottage" listing flag "listedInDirectory" to "true" + Then the member Property operation is rejected + + Examples: + | actor | visitor | + | Nora | no-permission | + | Noah | no-role | + | Parker | created | + | Derek | rejected | + | Guest | guest | + + Scenario: A member cannot access another community's properties and receives the missing-property result + Given a foreign same-community member owns the member Property "Protected Cottage" + And Quinn is an accepted own-property member of a separate member Property community + When Quinn attempts to list Maya's member Property directory + Then the member Property operation is rejected + When Quinn attempts to read the member Property "Protected Cottage" from Maya's community + Then the member Property details are unavailable + When Quinn attempts to set the member Property "Protected Cottage" listing flag "listedInDirectory" to "true" + Then the member Property error is exactly "Property not found" + When Quinn attempts to update an unknown member Property + Then the member Property error is exactly "Property not found" + + Scenario: A manager retains all-owner Property management + Given Maya owns the member Property "Maya Listing" + And Morgan is the property manager of Maya's member Property community + When Morgan sets the member Property "Maya Listing" listing flag "listedInDirectory" to "true" + Then the member Property operation succeeds + And Morgan sees the member Property "Maya Listing" listing flag "listedInDirectory" set to "true" + + Scenario: A manager-soft-deleted property is hidden and unavailable to a member + Given Maya owns the member Property "Retired Listing" + And Morgan is the property manager of Maya's member Property community + When Morgan deletes the member Property "Retired Listing" + Then the member Property operation succeeds + When Maya opens the member Property directory + Then the member Property directory does not include "Retired Listing" + When Maya attempts to read the member Property "Retired Listing" from Maya's community + Then the member Property details are unavailable + When Maya attempts to set the member Property "Retired Listing" listing flag "listedInDirectory" to "true" + Then the member Property operation is rejected diff --git a/packages/ocom-verification/verification-shared/src/scenarios/property/member-property-management.feature b/packages/ocom-verification/verification-shared/src/scenarios/property/member-property-management.feature new file mode 100644 index 000000000..8920b8475 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/property/member-property-management.feature @@ -0,0 +1,63 @@ +@member-property +Feature: Member Property management + + As an accepted community member allowed to edit my own properties + I want to find community properties and manage only my own listing content + So that the directory is useful without giving me manager powers + + Background: + Given Maya is an accepted own-property member in a member Property community + + Scenario: An own-property member can enter the current community directory + Given a foreign same-community member owns the member Property "Neighbor Cottage" + When Maya opens the member Property directory + Then the member Property directory includes "Neighbor Cottage" + + @api-only + Scenario: An own-property member can retrieve a foreign same-community property + Given a foreign same-community member owns the member Property "Neighbor Cottage" + When Maya reads the member Property "Neighbor Cottage" from Maya's community + Then the member Property details are available + + Scenario: An own-property member creates a property through the member route + When Maya creates a member Property named "Maya Cottage" + Then the member Property operation succeeds + + @api-only + Scenario: The server persists a created member Property with the verified member as owner + When Maya creates a member Property named "Maya Cottage" + Then the member Property operation succeeds + And the member Property "Maya Cottage" is persisted with Maya as its owner + + @skip-api + Scenario: A foreign same-community property is read-only and hides its owner's identity + Given a foreign same-community member owns the member Property "Neighbor Cottage" + When Maya opens the member Property details for "Neighbor Cottage" + Then the member Property detail is read-only + And the member Property detail does not display the foreign owner identity + + Scenario: An owner updates an allowed listing-content field + Given Maya owns the member Property "Maya Listing" + When Maya sets the member Property "Maya Listing" listing flag "listedInDirectory" to "true" + Then the member Property operation succeeds + And the member Property "Maya Listing" has listing flag "listedInDirectory" set to "true" + + @skip-api + Scenario Outline: Ineligible visitors cannot enter a member Property route + Given is a "" member Property route visitor in Maya's community + When opens their member Property directory + Then is denied the member Property route + + Examples: + | actor | visitor | + | Nora | no-permission | + | Noah | no-role | + | Parker | nonaccepted | + | Guest | guest | + | Quinn | mismatched-route | + + @skip-api + Scenario: A manager entering a member Property URL is redirected to the admin directory + Given Morgan is the property manager of Maya's member Property community + When Morgan opens their member Property directory + Then Morgan is redirected to the admin Property directory diff --git a/packages/ocom-verification/verification-shared/src/test-data/index.ts b/packages/ocom-verification/verification-shared/src/test-data/index.ts index b4a279858..abc62165b 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/index.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/index.ts @@ -1,3 +1,12 @@ +export { + findMemberPropertyFixtureRecord, + type MemberPropertyFixture, + type MemberPropertyFixtureAccountStatus, + type MemberPropertyFixtureDatabase, + type MemberPropertyFixtureIds, + type MemberPropertyPersistenceRecord, + provisionMemberPropertyFixture, +} from './member-property-fixtures.ts'; export { DEFAULT_STAFF_ROLE_NAMES, END_USER_IDS, diff --git a/packages/ocom-verification/verification-shared/src/test-data/member-property-fixtures.ts b/packages/ocom-verification/verification-shared/src/test-data/member-property-fixtures.ts new file mode 100644 index 000000000..f43145e0e --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/test-data/member-property-fixtures.ts @@ -0,0 +1,200 @@ +import { MongoClient, MongoServerError, ObjectId } from 'mongodb'; + +export type MemberPropertyFixtureAccountStatus = 'ACCEPTED' | 'CREATED' | 'REJECTED'; + +export interface MemberPropertyFixture { + communityId: string; + endUserId: string; + memberName: string; + firstName: string; + lastName: string; + accountStatus: MemberPropertyFixtureAccountStatus; + canEditOwnProperty: boolean; + canManageProperties: boolean; + /** Omit the member role entirely for no-role access-denial arrangements. */ + hasRole?: boolean; +} + +export interface MemberPropertyFixtureIds { + memberId: string; + roleId: string | null; +} + +export interface MemberPropertyFixtureDatabase { + connectionString: string; + dbName: string; +} + +export interface MemberPropertyPersistenceRecord { + id: string; + propertyName: string; + ownerId: string | null; + propertyType: string | null; + listedInDirectory: boolean; + isDeleted: boolean; +} + +const emptyPermissions = { + servicePermissions: { canManageServices: false }, + serviceTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + violationTicketPermissions: { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }, + communityPermissions: { + canManageRolesAndPermissions: false, + canManageCommunitySettings: false, + canManageSiteContent: false, + canManageMembers: false, + canEditOwnMemberProfile: false, + canEditOwnMemberAccounts: false, + }, +}; + +const MONGO_WRITE_RETRY_ATTEMPTS = 5; + +const delay = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +/** + * Community creation provisions its default role asynchronously. Retrying a + * transient Mongo catalog write conflict keeps fixture setup deterministic + * without hiding non-transient fixture defects. + */ +async function retryTransientCatalogWrite(operation: () => Promise): Promise { + for (let attempt = 1; attempt <= MONGO_WRITE_RETRY_ATTEMPTS; attempt += 1) { + try { + return await operation(); + } catch (error) { + const retryable = error instanceof MongoServerError && error.code === 112; + if (!retryable || attempt === MONGO_WRITE_RETRY_ATTEMPTS) { + throw error; + } + await delay(attempt * 50); + } + } + throw new Error('Transient Mongo fixture write retries were unexpectedly exhausted'); +} + +/** + * Creates a community-scoped member and, unless explicitly omitted, an + * end-user role for property scenarios. + * The member is linked to an already-seeded end user so API and browser tests + * can authenticate as the same fixture principal. + */ +export async function provisionMemberPropertyFixture(database: MemberPropertyFixtureDatabase, fixture: MemberPropertyFixture): Promise { + const client = new MongoClient(database.connectionString); + try { + await client.connect(); + const db = client.db(database.dbName); + const now = new Date(); + const communityId = new ObjectId(fixture.communityId); + const endUserId = new ObjectId(fixture.endUserId); + let roleId: string | null = null; + if (fixture.hasRole !== false) { + const roleName = `${fixture.memberName} Property Role`; + const role = await retryTransientCatalogWrite( + async () => + await db.collection('roles').findOneAndReplace( + { roleType: 'end-user-roles', community: communityId, roleName }, + { + roleType: 'end-user-roles', + community: communityId, + roleName, + isDefault: false, + permissions: { + ...emptyPermissions, + propertyPermissions: { + canManageProperties: fixture.canManageProperties, + canEditOwnProperty: fixture.canEditOwnProperty, + }, + }, + schemaVersion: '1.0.0', + createdAt: now, + updatedAt: now, + }, + { upsert: true, returnDocument: 'after' }, + ), + ); + if (!role?._id) { + throw new Error(`Could not provision the "${roleName}" role for member Property fixtures`); + } + roleId = String(role._id); + } + + const member = await retryTransientCatalogWrite( + async () => + await db.collection('members').findOneAndReplace( + { community: communityId, memberName: fixture.memberName }, + { + memberName: fixture.memberName, + community: communityId, + ...(roleId ? { role: new ObjectId(roleId) } : {}), + accounts: [ + { + _id: new ObjectId(), + firstName: fixture.firstName, + lastName: fixture.lastName, + user: endUserId, + statusCode: fixture.accountStatus, + createdBy: endUserId, + createdAt: now, + updatedAt: now, + }, + ], + customViews: [], + profile: { + interests: [], + showInterests: false, + showEmail: false, + showProfile: false, + showLocation: false, + showProperties: false, + }, + schemaVersion: '1.0.0', + createdAt: now, + updatedAt: now, + }, + { upsert: true, returnDocument: 'after' }, + ), + ); + if (!member?._id) { + throw new Error(`Could not provision the "${fixture.memberName}" member for member Property fixtures`); + } + + return { roleId, memberId: String(member._id) }; + } finally { + await client.close(); + } +} + +/** Reads the persisted property fields that member acceptance scenarios must verify without exposing UI-only projections. */ +export async function findMemberPropertyFixtureRecord(database: MemberPropertyFixtureDatabase, communityId: string, propertyName: string): Promise { + const client = new MongoClient(database.connectionString); + try { + await client.connect(); + const property = await client + .db(database.dbName) + .collection('properties') + .findOne({ community: new ObjectId(communityId), propertyName }); + if (!property?._id) { + return undefined; + } + return { + id: String(property._id), + propertyName: String(property.propertyName ?? ''), + ownerId: property.owner ? String(property.owner) : null, + propertyType: property.propertyType === undefined || property.propertyType === null ? null : String(property.propertyType), + listedInDirectory: property.listedInDirectory === true, + isDeleted: property.isDeleted === true, + }; + } finally { + await client.close(); + } +} diff --git a/packages/ocom/application-services/src/contexts/property/index.ts b/packages/ocom/application-services/src/contexts/property/index.ts index 8918e9cf9..08f81c796 100644 --- a/packages/ocom/application-services/src/contexts/property/index.ts +++ b/packages/ocom/application-services/src/contexts/property/index.ts @@ -1,12 +1,12 @@ import type { DataSources } from '@ocom/persistence'; -import { Property as PropertyApi, type PropertyApplicationService } from './property/index.ts'; +import { Property as PropertyApi, type PropertyRequestContext as PropertyApiRequestContext, type PropertyApplicationService } from './property/index.ts'; export interface PropertyContextApplicationService { Property: PropertyApplicationService; } -export const Property = (dataSources: DataSources): PropertyContextApplicationService => { +export const Property = (dataSources: DataSources, requestContext?: PropertyApiRequestContext): PropertyContextApplicationService => { return { - Property: PropertyApi(dataSources), + Property: PropertyApi(dataSources, requestContext), }; }; diff --git a/packages/ocom/application-services/src/contexts/property/property/apply-property-fields.ts b/packages/ocom/application-services/src/contexts/property/property/apply-property-fields.ts index d781cfce6..e0bc1928b 100644 --- a/packages/ocom/application-services/src/contexts/property/property/apply-property-fields.ts +++ b/packages/ocom/application-services/src/contexts/property/property/apply-property-fields.ts @@ -55,9 +55,10 @@ export interface PropertyListingDetailFieldsCommand { } /** - * User-manageable property fields shared by the create and update commands. - * Every field is optional: `undefined` leaves the current value untouched and - * an explicit `null` clears the value (where the domain allows clearing). + * Fields shared by create and update commands. Every field is optional: + * `undefined` leaves the current value untouched and an explicit `null` + * clears the value where the domain allows clearing. The aggregate and + * update preflight distinguish listing content from manager-only fields. */ export interface PropertyFieldsCommand { propertyType?: string | null; @@ -79,16 +80,16 @@ const resolveAddressField = (next: string | null | undefined, current: string): }; async function applyOwner(dataSources: DataSources, property: PropertyAggregate, ownerId: string | null): Promise { + property.assertCanManageProperties(); if (ownerId === null) { property.owner = null; return; } - const member = await dataSources.readonlyDataSource.Community.Member.MemberReadRepo.getByIdWithRole(ownerId); + const member = await dataSources.readonlyDataSource.Community.Member.MemberReadRepo.getByIdInCommunityWithRole(ownerId, property.community.id); if (!member) { - throw new Error(`Owner member with id ${ownerId} not found`); - } - if (member.communityId !== property.community.id) { - throw new Error("Owner member does not belong to the property's community"); + // The repository scopes by community, so a foreign identifier is + // intentionally indistinguishable from an unknown identifier. + throw new Error('Property owner not found'); } property.owner = member; } diff --git a/packages/ocom/application-services/src/contexts/property/property/create.ts b/packages/ocom/application-services/src/contexts/property/property/create.ts index 94cc9ea9a..90f92a071 100644 --- a/packages/ocom/application-services/src/contexts/property/property/create.ts +++ b/packages/ocom/application-services/src/contexts/property/property/create.ts @@ -1,19 +1,60 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; import { applyPropertyFields, type PropertyFieldsCommand } from './apply-property-fields.ts'; -import { ensureCommunityPropertiesViewable } from './ensure-property-viewable.ts'; +import { ensureCommunityPropertiesCreatable, ensureCommunityPropertiesManageable, isCommunityPropertiesManageable } from './ensure-property-viewable.ts'; export interface PropertyCreateCommand extends PropertyFieldsCommand { propertyName: string; communityId: string; } -export const create = (dataSources: DataSources) => { +/** + * Request-scoped identity supplied only by the authenticated application + * services factory. It is deliberately not part of a GraphQL command. + */ +export interface PropertyRequestContext { + readonly currentMember: Domain.Contexts.Community.Member.MemberEntityReference | null; +} + +const noCurrentMemberContext: PropertyRequestContext = { + currentMember: null, +}; + +export const create = (dataSources: DataSources, requestContext: PropertyRequestContext = noCurrentMemberContext) => { return async (command: PropertyCreateCommand): Promise => { // Authorize against the target community before any lookups, so an // unauthorized caller cannot probe community existence or // property-name availability from the distinct error responses. - ensureCommunityPropertiesViewable(dataSources.passport, command.communityId); + ensureCommunityPropertiesCreatable(dataSources.passport, command.communityId); + const canManageProperties = isCommunityPropertiesManageable(dataSources.passport, command.communityId); + + // Type is a manager-owned field. Reject it before checking the requested + // name so an own-property editor cannot use this mutation as a name + // availability oracle. + if (command.propertyType !== undefined && !canManageProperties) { + ensureCommunityPropertiesManageable(dataSources.passport, command.communityId); + } + + let owner: Domain.Contexts.Community.Member.MemberEntityReference | null = null; + if (canManageProperties) { + if (command.ownerId !== undefined && command.ownerId !== null) { + owner = await dataSources.readonlyDataSource.Community.Member.MemberReadRepo.getByIdInCommunityWithRole(command.ownerId, command.communityId); + if (!owner) { + throw new Error('Property owner not found'); + } + } + } else { + // A member's owner is never command data, including if it happens to + // equal their own id. Bind the verified canonical member instead. + if (command.ownerId !== undefined) { + throw new Error('Property owner cannot be specified'); + } + const currentMember = requestContext.currentMember; + if (!currentMember || currentMember.community.id !== command.communityId) { + throw new Error('Unauthorized'); + } + owner = currentMember; + } let community: Domain.Contexts.Community.Community.CommunityEntityReference | undefined; await dataSources.domainDataSource.Community.Community.CommunityUnitOfWork.withScopedTransaction(async (repo) => { @@ -33,10 +74,12 @@ export const create = (dataSources: DataSources) => { let propertyToReturn: Domain.Contexts.Property.Property.PropertyEntityReference | undefined; await dataSources.domainDataSource.Property.Property.PropertyUnitOfWork.withScopedTransaction(async (repo) => { - const newProperty = await repo.getNewInstance(command.propertyName, communityToUse); - // The remaining fields are applied within the same transaction; the - // aggregate setters enforce the manage-properties authorization. - await applyPropertyFields(dataSources, newProperty, command); + const newProperty = await repo.getNewInstance(command.propertyName, communityToUse, owner); + // Owner was bound in the aggregate factory from either the trusted + // request context or a community-scoped manager lookup. It must not be + // reapplied from command data. + const { ownerId: _ownerId, ...remainingFields } = command; + await applyPropertyFields(dataSources, newProperty, remainingFields); propertyToReturn = await repo.save(newProperty); }); if (!propertyToReturn) { diff --git a/packages/ocom/application-services/src/contexts/property/property/ensure-property-manageable.ts b/packages/ocom/application-services/src/contexts/property/property/ensure-property-manageable.ts index 3f3f3b153..9d64cdb2b 100644 --- a/packages/ocom/application-services/src/contexts/property/property/ensure-property-manageable.ts +++ b/packages/ocom/application-services/src/contexts/property/property/ensure-property-manageable.ts @@ -1,6 +1,17 @@ const PROPERTY_NOT_FOUND_MESSAGE = 'Property not found'; -const hasErrorName = (error: unknown, name: string): boolean => typeof error === 'object' && error !== null && (error as Error).name === name; +const hasErrorName = (error: unknown, name: string): boolean => error instanceof Error && error.name === name; + +export const assertPropertyManageableOrThrow = (property: TProperty): void => { + try { + property.assertCanManageProperties(); + } catch (error) { + if (hasErrorName(error, 'PermissionError')) { + throw new Error(PROPERTY_NOT_FOUND_MESSAGE); + } + throw error; + } +}; /** * Loads a property for a manage-style mutation while collapsing "unknown id" @@ -19,8 +30,27 @@ export const getManageablePropertyOrThrow = async (repo: { getById(id: string): Promise }, id: string): Promise => { + let property: TProperty; try { - property.assertCanManageProperties(); + property = await repo.getById(id); + } catch (error) { + if (hasErrorName(error, 'NotFoundError')) { + throw new Error(PROPERTY_NOT_FOUND_MESSAGE); + } + throw error; + } + try { + property.assertCanEditProperties(); } catch (error) { if (hasErrorName(error, 'PermissionError')) { throw new Error(PROPERTY_NOT_FOUND_MESSAGE); diff --git a/packages/ocom/application-services/src/contexts/property/property/ensure-property-viewable.ts b/packages/ocom/application-services/src/contexts/property/property/ensure-property-viewable.ts index daa0e7898..ee6986e13 100644 --- a/packages/ocom/application-services/src/contexts/property/property/ensure-property-viewable.ts +++ b/packages/ocom/application-services/src/contexts/property/property/ensure-property-viewable.ts @@ -1,22 +1,25 @@ import type { Domain } from '@ocom/domain'; +type PropertyAuthorizationSubject = Domain.Contexts.Property.PropertyAuthorizationSubject; + +const forCommunity = (communityId: string): PropertyAuthorizationSubject => ({ + community: { id: communityId }, +}); + /** * Non-throwing variant of {@link ensurePropertyViewable} for read paths that * deny by omission instead of by error. */ -export const isPropertyViewable = (passport: Domain.Passport, property: Domain.Contexts.Property.Property.PropertyEntityReference): boolean => { - return passport.property.forProperty(property).determineIf((permissions) => permissions.isSystemAccount || permissions.canManageProperties); +export const isPropertyViewable = (passport: Domain.Passport, property: PropertyAuthorizationSubject): boolean => { + return passport.property.forProperty(property).determineIf((permissions) => permissions.isSystemAccount || permissions.canManageProperties || permissions.canEditOwnProperty); }; /** - * Admin-side property reads must be authorized by the request passport's - * property visa (spec: application service operations enforce - * `canManageProperties`). The passport is built from the request's current - * member/community hints, so a manager of another community — or the same - * user acting under a different community context — is denied here even if - * they hold manage permissions elsewhere. + * Property reads are authorized by the request passport's property visa. An + * accepted own-property member can read every property in their selected + * community, while a manager or system account retains the same access. */ -export const ensurePropertyViewable = (passport: Domain.Passport, property: Domain.Contexts.Property.Property.PropertyEntityReference): void => { +export const ensurePropertyViewable = (passport: Domain.Passport, property: PropertyAuthorizationSubject): void => { if (!isPropertyViewable(passport, property)) { throw new Error('Unauthorized'); } @@ -25,11 +28,34 @@ export const ensurePropertyViewable = (passport: Domain.Passport, property: Doma /** * Authorizes a community-wide property read before any rows are fetched, so an * unauthorized actor is rejected even when the community has no properties. - * Property visas scope by the root's community (and never dereference other - * root fields for the manage/system predicates), so a minimal community-scoped - * root is sufficient to evaluate the same predicate the per-property check uses. + * Property visas scope by the root's community (and this read predicate never + * needs an owner), so a minimal community-scoped subject is sufficient. */ export const ensureCommunityPropertiesViewable = (passport: Domain.Passport, communityId: string): void => { - const communityScopedRoot = { community: { id: communityId } } as Domain.Contexts.Property.Property.PropertyEntityReference; - ensurePropertyViewable(passport, communityScopedRoot); + ensurePropertyViewable(passport, forCommunity(communityId)); +}; + +export const isCommunityPropertiesManageable = (passport: Domain.Passport, communityId: string): boolean => { + return passport.property.forProperty(forCommunity(communityId)).determineIf((permissions) => permissions.isSystemAccount || permissions.canManageProperties); +}; + +/** + * Management-only collection operations (owner selection and manager-created + * ownership assignment) must not inherit the broader listing-read policy. + */ +export const ensureCommunityPropertiesManageable = (passport: Domain.Passport, communityId: string): void => { + if (!isCommunityPropertiesManageable(passport, communityId)) { + throw new Error('Unauthorized'); + } +}; + +/** + * A property may be created by a manager/system account or by an accepted + * member with the narrowly scoped own-property capability. The latter must + * still be paired with a trusted current-member context by create.ts. + */ +export const ensureCommunityPropertiesCreatable = (passport: Domain.Passport, communityId: string): void => { + if (!passport.property.forProperty(forCommunity(communityId)).determineIf((permissions) => permissions.isSystemAccount || permissions.canManageProperties || permissions.canEditOwnProperty)) { + throw new Error('Unauthorized'); + } }; diff --git a/packages/ocom/application-services/src/contexts/property/property/index.ts b/packages/ocom/application-services/src/contexts/property/property/index.ts index d92dca213..ae475e2aa 100644 --- a/packages/ocom/application-services/src/contexts/property/property/index.ts +++ b/packages/ocom/application-services/src/contexts/property/property/index.ts @@ -8,13 +8,14 @@ import type { PropertyListingDetailFieldsCommand, PropertyLocationFieldsCommand, } from './apply-property-fields.ts'; -import { create, type PropertyCreateCommand } from './create.ts'; +import { create, type PropertyCreateCommand, type PropertyRequestContext } from './create.ts'; import { type PropertyQueryByCommunityIdCommand, queryByCommunityId } from './query-by-community-id.ts'; import { type PropertyQueryByIdCommand, queryById } from './query-by-id.ts'; import { type PropertyOwnerOption, type PropertyQueryOwnerOptionsByCommunityIdCommand, queryOwnerOptionsByCommunityId } from './query-owner-options-by-community-id.ts'; import { type PropertyRequestDeleteCommand, requestDelete } from './request-delete.ts'; import { type PropertyUpdateCommand, type PropertyUpdateListingDetailCommand, update } from './update.ts'; +export type { PropertyRequestContext } from './create.ts'; export type { PropertyAdditionalAmenityCommand, PropertyAddressFieldsCommand, @@ -36,9 +37,9 @@ export interface PropertyApplicationService { queryOwnerOptionsByCommunityId: (command: PropertyQueryOwnerOptionsByCommunityIdCommand) => Promise; } -export const Property = (dataSources: DataSources): PropertyApplicationService => { +export const Property = (dataSources: DataSources, requestContext?: PropertyRequestContext): PropertyApplicationService => { return { - create: create(dataSources), + create: create(dataSources, requestContext), update: update(dataSources), requestDelete: requestDelete(dataSources), queryById: queryById(dataSources), diff --git a/packages/ocom/application-services/src/contexts/property/property/property.application-service.test.ts b/packages/ocom/application-services/src/contexts/property/property/property.application-service.test.ts index 823d70998..00dc4970a 100644 --- a/packages/ocom/application-services/src/contexts/property/property/property.application-service.test.ts +++ b/packages/ocom/application-services/src/contexts/property/property/property.application-service.test.ts @@ -9,6 +9,34 @@ import { update } from './update.ts'; type DataSources = Parameters[0]; +interface PropertySetCalls { + propertyName?: unknown; + propertyType?: unknown; + listedForSale?: unknown; + listedForRent?: unknown; + listedForLease?: unknown; + listedInDirectory?: unknown; + tags?: unknown; + owner?: unknown; +} + +interface ListingDetailSets extends Record { + price?: unknown; + bedrooms?: unknown; + bathrooms?: unknown; + squareFeet?: unknown; +} + +interface BedroomAdd { + roomName?: unknown; + bedDescriptions?: unknown; +} + +interface AmenityAdd { + category?: unknown; + amenities?: unknown; +} + describe('property application services', () => { let dataSources: DataSources; let communityRepository: { @@ -26,6 +54,7 @@ describe('property application services', () => { }; let memberReadRepository: { getByIdWithRole: ReturnType; + getByIdInCommunityWithRole: ReturnType; getByCommunityId: ReturnType; }; let propertyVisaPermissions: { canManageProperties: boolean; canEditOwnProperty: boolean; isEditingOwnProperty: boolean; isSystemAccount: boolean }; @@ -47,6 +76,7 @@ describe('property application services', () => { }; memberReadRepository = { getByIdWithRole: vi.fn(), + getByIdInCommunityWithRole: vi.fn(), getByCommunityId: vi.fn(), }; // Mirrors MemberPropertyVisa: the request passport evaluates the predicate @@ -101,11 +131,11 @@ describe('property application services', () => { }); function makePropertyAggregate() { - const setCalls: Record = {}; - const listingDetailSets: Record = {}; + const setCalls: PropertySetCalls = {}; + const listingDetailSets: ListingDetailSets = {}; const addressSets: Record[] = []; - const bedroomAdds: Record[] = []; - const amenityAdds: Record[] = []; + const bedroomAdds: BedroomAdd[] = []; + const amenityAdds: AmenityAdd[] = []; const removedBedrooms: unknown[] = []; const removedAmenities: unknown[] = []; const existingBedroom = { props: { id: 'bedroom-1', roomName: 'Old Room', bedDescriptions: ['Queen'] } }; @@ -137,14 +167,14 @@ describe('property application services', () => { removedBedrooms.push(props); }), requestNewBedroom: vi.fn(() => { - const added: Record = {}; + const added: BedroomAdd = {}; bedroomAdds.push(added); return { set roomName(value: unknown) { - added['roomName'] = value; + added.roomName = value; }, set bedDescriptions(value: unknown) { - added['bedDescriptions'] = value; + added.bedDescriptions = value; }, }; }), @@ -152,14 +182,14 @@ describe('property application services', () => { removedAmenities.push(props); }), requestNewAdditionalAmenity: vi.fn(() => { - const added: Record = {}; + const added: AmenityAdd = {}; amenityAdds.push(added); return { set category(value: unknown) { - added['category'] = value; + added.category = value; }, set amenities(value: unknown) { - added['amenities'] = value; + added.amenities = value; }, }; }), @@ -210,33 +240,34 @@ describe('property application services', () => { existingAmenity, currentAddress, assertCanManageProperties: vi.fn(), + assertCanEditProperties: vi.fn(), community: { id: 'community-1' }, get propertyName() { return 'Current Property Name'; }, set propertyName(value: unknown) { - setCalls['propertyName'] = value; + setCalls.propertyName = value; }, set propertyType(value: unknown) { - setCalls['propertyType'] = value; + setCalls.propertyType = value; }, set listedForSale(value: unknown) { - setCalls['listedForSale'] = value; + setCalls.listedForSale = value; }, set listedForRent(value: unknown) { - setCalls['listedForRent'] = value; + setCalls.listedForRent = value; }, set listedForLease(value: unknown) { - setCalls['listedForLease'] = value; + setCalls.listedForLease = value; }, set listedInDirectory(value: unknown) { - setCalls['listedInDirectory'] = value; + setCalls.listedInDirectory = value; }, set tags(value: unknown) { - setCalls['tags'] = value; + setCalls.tags = value; }, set owner(value: unknown) { - setCalls['owner'] = value; + setCalls.owner = value; }, location: { get address() { @@ -262,7 +293,7 @@ describe('property application services', () => { const result = await create(dataSources)({ propertyName: 'P1', communityId: 'community-1' }); expect(communityRepository.get).toHaveBeenCalledWith('community-1'); - expect(propertyRepository.getNewInstance).toHaveBeenCalledWith('P1', community); + expect(propertyRepository.getNewInstance).toHaveBeenCalledWith('P1', community, null); expect(propertyRepository.save).toHaveBeenCalledWith(newProperty); expect(result).toBe(newProperty); }); @@ -281,7 +312,7 @@ describe('property application services', () => { communityRepository.get.mockResolvedValue(community); propertyRepository.getNewInstance.mockResolvedValue(property); propertyRepository.save.mockResolvedValue(property); - memberReadRepository.getByIdWithRole.mockResolvedValue(member); + memberReadRepository.getByIdInCommunityWithRole.mockResolvedValue(member); const result = await create(dataSources)({ propertyName: 'Grand Pavilion', @@ -295,17 +326,16 @@ describe('property application services', () => { listingDetail: { price: 1250000, bathrooms: 3.5 }, }); - expect(propertyRepository.getNewInstance).toHaveBeenCalledWith('Grand Pavilion', community); + expect(propertyRepository.getNewInstance).toHaveBeenCalledWith('Grand Pavilion', community, member); expect(property.setCalls).toEqual({ propertyType: 'house', listedForSale: true, listedInDirectory: true, tags: ['waterfront', 'pool'], - owner: member, }); expect(property.addressSets[0]).toMatchObject({ streetNumber: '42', streetName: 'Shoreline Dr', municipality: 'Old Town' }); - expect((property.listingDetailSets['price'] as { valueOf(): number }).valueOf()).toBe(1250000); - expect((property.listingDetailSets['bathrooms'] as { valueOf(): number }).valueOf()).toBe(3.5); + expect((property.listingDetailSets.price as { valueOf(): number }).valueOf()).toBe(1250000); + expect((property.listingDetailSets.bathrooms as { valueOf(): number }).valueOf()).toBe(3.5); expect(propertyRepository.save).toHaveBeenCalledWith(property); expect(result).toBe(property); }); @@ -316,7 +346,7 @@ describe('property application services', () => { communityRepository.get.mockResolvedValue(community); propertyRepository.getNewInstance.mockResolvedValue(property); propertyRepository.save.mockResolvedValue(property); - memberReadRepository.getByIdWithRole.mockResolvedValue({ id: 'member-2', communityId: 'other-community' }); + memberReadRepository.getByIdInCommunityWithRole.mockResolvedValue(null); await expect( create(dataSources)({ @@ -324,7 +354,7 @@ describe('property application services', () => { communityId: 'community-1', ownerId: 'member-2', }), - ).rejects.toThrow("Owner member does not belong to the property's community"); + ).rejects.toThrow('Property owner not found'); expect(propertyRepository.save).not.toHaveBeenCalled(); }); @@ -371,6 +401,223 @@ describe('property application services', () => { }); }); + describe('property application services member authorization', () => { + let dataSources: DataSources; + let communityRepository: { + get: ReturnType; + }; + let propertyRepository: { + getNewInstance: ReturnType; + getById: ReturnType; + save: ReturnType; + }; + let propertyReadRepository: { + getById: ReturnType; + getByCommunityId: ReturnType; + isPropertyNameTaken: ReturnType; + }; + let memberReadRepository: { + getByIdInCommunityWithRole: ReturnType; + getByCommunityId: ReturnType; + }; + let propertyPermissions: { + canManageProperties: boolean; + canEditOwnProperty: boolean; + isEditingOwnProperty: boolean; + isSystemAccount: boolean; + }; + + const trustedMember = { + id: 'member-1', + communityId: 'community-1', + community: { id: 'community-1' }, + } as unknown as Domain.Contexts.Community.Member.MemberEntityReference; + + const makeProperty = () => { + const setCalls: PropertySetCalls = {}; + return { + id: 'property-1', + setCalls, + community: { id: 'community-1' }, + assertCanManageProperties: vi.fn(), + assertCanEditProperties: vi.fn(), + set listedInDirectory(value: boolean) { + setCalls.listedInDirectory = value; + }, + set propertyName(value: string) { + setCalls.propertyName = value; + }, + set propertyType(value: string | null) { + setCalls.propertyType = value; + }, + set owner(value: unknown) { + setCalls.owner = value; + }, + }; + }; + + beforeEach(() => { + communityRepository = { get: vi.fn() }; + propertyRepository = { + getNewInstance: vi.fn(), + getById: vi.fn(), + save: vi.fn(), + }; + propertyReadRepository = { + getById: vi.fn(), + getByCommunityId: vi.fn(), + isPropertyNameTaken: vi.fn().mockResolvedValue(false), + }; + memberReadRepository = { + getByIdInCommunityWithRole: vi.fn(), + getByCommunityId: vi.fn(), + }; + propertyPermissions = { + canManageProperties: false, + canEditOwnProperty: true, + isEditingOwnProperty: true, + isSystemAccount: false, + }; + dataSources = { + passport: { + property: { + forProperty: vi.fn(() => ({ + determineIf: (predicate: (permissions: typeof propertyPermissions) => boolean) => predicate(propertyPermissions), + })), + }, + }, + domainDataSource: { + Community: { + Community: { + CommunityUnitOfWork: { + withScopedTransaction: vi.fn(async (callback: (repo: typeof communityRepository) => Promise) => callback(communityRepository)), + }, + }, + }, + Property: { + Property: { + PropertyUnitOfWork: { + withScopedTransaction: vi.fn(async (callback: (repo: typeof propertyRepository) => Promise) => callback(propertyRepository)), + }, + }, + }, + }, + readonlyDataSource: { + Community: { Member: { MemberReadRepo: memberReadRepository } }, + Property: { Property: { PropertyReadRepo: propertyReadRepository } }, + }, + } as unknown as DataSources; + }); + + it('allows a permissioned member to list all active properties in their current community before reading rows', async () => { + propertyReadRepository.getByCommunityId.mockResolvedValue([{ id: 'own-property' }, { id: 'foreign-property' }]); + + await expect(queryByCommunityId(dataSources)({ communityId: 'community-1' })).resolves.toEqual([{ id: 'own-property' }, { id: 'foreign-property' }]); + expect(propertyReadRepository.getByCommunityId).toHaveBeenCalledWith('community-1'); + }); + + it('binds the verified current member as owner when an own-property member creates a property', async () => { + const community = { id: 'community-1' }; + const property = makeProperty(); + communityRepository.get.mockResolvedValue(community); + propertyRepository.getNewInstance.mockResolvedValue(property); + propertyRepository.save.mockResolvedValue(property); + + await expect(create(dataSources, { currentMember: trustedMember })({ propertyName: 'Maya Cottage', communityId: 'community-1' })).resolves.toBe(property); + + expect(propertyRepository.getNewInstance).toHaveBeenCalledWith('Maya Cottage', community, trustedMember); + expect(propertyRepository.save).toHaveBeenCalledWith(property); + }); + + it('rejects every client ownerId for own-property member creation before owner lookup or field application', async () => { + await expect( + create(dataSources, { currentMember: trustedMember })({ + propertyName: 'Spoofed Cottage', + communityId: 'community-1', + ownerId: 'member-1', + }), + ).rejects.toThrow('Property owner cannot be specified'); + + expect(memberReadRepository.getByIdInCommunityWithRole).not.toHaveBeenCalled(); + expect(propertyRepository.getNewInstance).not.toHaveBeenCalled(); + expect(propertyRepository.save).not.toHaveBeenCalled(); + }); + + it('masks a foreign own-editor update as a missing property without applying fields', async () => { + const property = makeProperty(); + const permissionError = Object.assign(new Error('not editable'), { name: 'PermissionError' }); + property.assertCanEditProperties.mockImplementation(() => { + throw permissionError; + }); + propertyRepository.getById.mockResolvedValue(property); + + await expect(update(dataSources)({ id: 'foreign-property', listedInDirectory: true })).rejects.toThrow('Property not found'); + + expect(property.assertCanEditProperties).toHaveBeenCalledTimes(1); + expect(property.setCalls).toEqual({}); + expect(propertyRepository.save).not.toHaveBeenCalled(); + }); + + it('allows an own editor to update listing content through the editable-property loader', async () => { + const property = makeProperty(); + propertyRepository.getById.mockResolvedValue(property); + propertyRepository.save.mockResolvedValue(property); + + await expect(update(dataSources)({ id: 'property-1', listedInDirectory: true })).resolves.toBe(property); + + expect(property.assertCanEditProperties).toHaveBeenCalledTimes(1); + expect(property.setCalls).toEqual({ listedInDirectory: true }); + expect(propertyRepository.save).toHaveBeenCalledWith(property); + }); + + it('rejects a mixed own-editor update before saving any allowed field', async () => { + const property = makeProperty(); + const permissionError = Object.assign(new Error('not manageable'), { name: 'PermissionError' }); + property.assertCanManageProperties.mockImplementation(() => { + throw permissionError; + }); + propertyRepository.getById.mockResolvedValue(property); + + await expect(update(dataSources)({ id: 'property-1', propertyName: 'Renamed Cottage', listedInDirectory: true })).rejects.toThrow('Property not found'); + + expect(property.assertCanEditProperties).toHaveBeenCalledTimes(1); + expect(property.setCalls).toEqual({}); + expect(propertyRepository.save).not.toHaveBeenCalled(); + }); + + it('rejects an explicit null manager-only name before applying a listing update', async () => { + const property = makeProperty(); + const permissionError = Object.assign(new Error('not manageable'), { name: 'PermissionError' }); + property.assertCanManageProperties.mockImplementation(() => { + throw permissionError; + }); + propertyRepository.getById.mockResolvedValue(property); + + await expect(update(dataSources)({ id: 'property-1', propertyName: null, listedInDirectory: true })).rejects.toThrow('Property not found'); + + expect(property.assertCanEditProperties).toHaveBeenCalledTimes(1); + expect(property.setCalls).toEqual({}); + expect(propertyRepository.save).not.toHaveBeenCalled(); + }); + + it('keeps owner options manager-only even when a member can list and read properties', async () => { + await expect(queryOwnerOptionsByCommunityId(dataSources)({ communityId: 'community-1' })).rejects.toThrow('Unauthorized'); + expect(memberReadRepository.getByCommunityId).not.toHaveBeenCalled(); + }); + + it('uses a community-scoped owner lookup and masks a foreign owner id for a manager', async () => { + propertyPermissions.canManageProperties = true; + const property = makeProperty(); + propertyRepository.getById.mockResolvedValue(property); + memberReadRepository.getByIdInCommunityWithRole.mockResolvedValue(null); + + await expect(update(dataSources)({ id: 'property-1', ownerId: 'foreign-member' })).rejects.toThrow('Property owner not found'); + + expect(memberReadRepository.getByIdInCommunityWithRole).toHaveBeenCalledWith('foreign-member', 'community-1'); + expect(propertyRepository.save).not.toHaveBeenCalled(); + }); + }); + describe('update', () => { it('applies only the provided fields', async () => { const property = makePropertyAggregate(); @@ -400,7 +647,7 @@ describe('property application services', () => { const property = makePropertyAggregate(); const permissionError = new Error('You do not have permission to manage properties'); permissionError.name = 'PermissionError'; - property.assertCanManageProperties.mockImplementation(() => { + property.assertCanEditProperties.mockImplementation(() => { throw permissionError; }); propertyRepository.getById.mockResolvedValue(property); @@ -433,10 +680,10 @@ describe('property application services', () => { }); expect(property.setCalls).toEqual({ propertyName: 'New Name' }); - expect(property.listingDetailSets['bedrooms']).toBeInstanceOf(Domain.Contexts.Property.Property.ListingDetailValueObjects.Bedrooms); - expect((property.listingDetailSets['bedrooms'] as { valueOf(): number }).valueOf()).toBe(3); - expect((property.listingDetailSets['bathrooms'] as { valueOf(): number }).valueOf()).toBe(2.5); - expect((property.listingDetailSets['squareFeet'] as { valueOf(): number }).valueOf()).toBe(1750); + expect(property.listingDetailSets.bedrooms).toBeInstanceOf(Domain.Contexts.Property.Property.ListingDetailValueObjects.Bedrooms); + expect((property.listingDetailSets.bedrooms as { valueOf(): number }).valueOf()).toBe(3); + expect((property.listingDetailSets.bathrooms as { valueOf(): number }).valueOf()).toBe(2.5); + expect((property.listingDetailSets.squareFeet as { valueOf(): number }).valueOf()).toBe(1750); }); it('applies explicit nulls as value objects to clear listing detail fields', async () => { @@ -449,9 +696,9 @@ describe('property application services', () => { listingDetail: { bedrooms: null, bathrooms: null, squareFeet: null }, }); - expect((property.listingDetailSets['bedrooms'] as { valueOf(): number | null }).valueOf()).toBeNull(); - expect((property.listingDetailSets['bathrooms'] as { valueOf(): number | null }).valueOf()).toBeNull(); - expect((property.listingDetailSets['squareFeet'] as { valueOf(): number | null }).valueOf()).toBeNull(); + expect((property.listingDetailSets.bedrooms as { valueOf(): number | null }).valueOf()).toBeNull(); + expect((property.listingDetailSets.bathrooms as { valueOf(): number | null }).valueOf()).toBeNull(); + expect((property.listingDetailSets.squareFeet as { valueOf(): number | null }).valueOf()).toBeNull(); expect(propertyRepository.save).toHaveBeenCalledWith(property); }); @@ -543,11 +790,11 @@ describe('property application services', () => { expect(property.removedBedrooms).toEqual([property.existingBedroom.props]); expect(property.removedAmenities).toEqual([property.existingAmenity.props]); expect(property.bedroomAdds).toHaveLength(1); - expect((property.bedroomAdds[0]?.['roomName'] as { valueOf(): string }).valueOf()).toBe('Primary Suite'); - expect((property.bedroomAdds[0]?.['bedDescriptions'] as { valueOf(): string[] }).valueOf()).toEqual(['King', 'Crib']); + expect((property.bedroomAdds[0]?.roomName as { valueOf(): string }).valueOf()).toBe('Primary Suite'); + expect((property.bedroomAdds[0]?.bedDescriptions as { valueOf(): string[] }).valueOf()).toEqual(['King', 'Crib']); expect(property.amenityAdds).toHaveLength(1); - expect((property.amenityAdds[0]?.['category'] as { valueOf(): string }).valueOf()).toBe('Outdoor'); - expect((property.amenityAdds[0]?.['amenities'] as { valueOf(): string[] }).valueOf()).toEqual(['Fire Pit', 'BBQ']); + expect((property.amenityAdds[0]?.category as { valueOf(): string }).valueOf()).toBe('Outdoor'); + expect((property.amenityAdds[0]?.amenities as { valueOf(): string[] }).valueOf()).toEqual(['Fire Pit', 'BBQ']); expect(propertyRepository.save).toHaveBeenCalledWith(property); }); @@ -556,11 +803,11 @@ describe('property application services', () => { const member = { id: 'member-1', communityId: 'community-1' }; propertyRepository.getById.mockResolvedValue(property); propertyRepository.save.mockResolvedValue(property); - memberReadRepository.getByIdWithRole.mockResolvedValue(member); + memberReadRepository.getByIdInCommunityWithRole.mockResolvedValue(member); await update(dataSources)({ id: 'property-1', ownerId: 'member-1' }); - expect(memberReadRepository.getByIdWithRole).toHaveBeenCalledWith('member-1'); + expect(memberReadRepository.getByIdInCommunityWithRole).toHaveBeenCalledWith('member-1', 'community-1'); expect(property.setCalls).toEqual({ owner: member }); expect(propertyRepository.save).toHaveBeenCalledWith(property); }); @@ -572,7 +819,7 @@ describe('property application services', () => { await update(dataSources)({ id: 'property-1', ownerId: null }); - expect(memberReadRepository.getByIdWithRole).not.toHaveBeenCalled(); + expect(memberReadRepository.getByIdInCommunityWithRole).not.toHaveBeenCalled(); expect(property.setCalls).toEqual({ owner: null }); expect(propertyRepository.save).toHaveBeenCalledWith(property); }); @@ -581,9 +828,9 @@ describe('property application services', () => { const property = makePropertyAggregate(); propertyRepository.getById.mockResolvedValue(property); propertyRepository.save.mockResolvedValue(property); - memberReadRepository.getByIdWithRole.mockResolvedValue({ id: 'member-2', communityId: 'other-community' }); + memberReadRepository.getByIdInCommunityWithRole.mockResolvedValue(null); - await expect(update(dataSources)({ id: 'property-1', ownerId: 'member-2' })).rejects.toThrow("Owner member does not belong to the property's community"); + await expect(update(dataSources)({ id: 'property-1', ownerId: 'member-2' })).rejects.toThrow('Property owner not found'); expect(property.setCalls).toEqual({}); expect(propertyRepository.save).not.toHaveBeenCalled(); @@ -593,9 +840,9 @@ describe('property application services', () => { const property = makePropertyAggregate(); propertyRepository.getById.mockResolvedValue(property); propertyRepository.save.mockResolvedValue(property); - memberReadRepository.getByIdWithRole.mockResolvedValue(null); + memberReadRepository.getByIdInCommunityWithRole.mockResolvedValue(null); - await expect(update(dataSources)({ id: 'property-1', ownerId: 'missing-member' })).rejects.toThrow('Owner member with id missing-member not found'); + await expect(update(dataSources)({ id: 'property-1', ownerId: 'missing-member' })).rejects.toThrow('Property owner not found'); expect(propertyRepository.save).not.toHaveBeenCalled(); }); @@ -612,6 +859,7 @@ describe('property application services', () => { it('propagates domain errors from the aggregate', async () => { propertyRepository.getById.mockResolvedValue({ + assertCanEditProperties: vi.fn(), assertCanManageProperties: vi.fn(), community: { id: 'community-1' }, get propertyName() { diff --git a/packages/ocom/application-services/src/contexts/property/property/query-owner-options-by-community-id.ts b/packages/ocom/application-services/src/contexts/property/property/query-owner-options-by-community-id.ts index efc001912..3b5334ef2 100644 --- a/packages/ocom/application-services/src/contexts/property/property/query-owner-options-by-community-id.ts +++ b/packages/ocom/application-services/src/contexts/property/property/query-owner-options-by-community-id.ts @@ -1,5 +1,5 @@ import type { DataSources } from '@ocom/persistence'; -import { ensureCommunityPropertiesViewable } from './ensure-property-viewable.ts'; +import { ensureCommunityPropertiesManageable } from './ensure-property-viewable.ts'; export interface PropertyQueryOwnerOptionsByCommunityIdCommand { communityId: string; @@ -24,7 +24,7 @@ export interface PropertyOwnerOption { */ export const queryOwnerOptionsByCommunityId = (dataSources: DataSources) => { return async (command: PropertyQueryOwnerOptionsByCommunityIdCommand): Promise => { - ensureCommunityPropertiesViewable(dataSources.passport, command.communityId); + ensureCommunityPropertiesManageable(dataSources.passport, command.communityId); const members = await dataSources.readonlyDataSource.Community.Member.MemberReadRepo.getByCommunityId(command.communityId); return members.map((member) => ({ id: member.id, diff --git a/packages/ocom/application-services/src/contexts/property/property/update.ts b/packages/ocom/application-services/src/contexts/property/property/update.ts index dbc7d9802..35d9644e0 100644 --- a/packages/ocom/application-services/src/contexts/property/property/update.ts +++ b/packages/ocom/application-services/src/contexts/property/property/update.ts @@ -1,20 +1,28 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; import { applyPropertyFields, type PropertyFieldsCommand, type PropertyListingDetailFieldsCommand } from './apply-property-fields.ts'; -import { getManageablePropertyOrThrow } from './ensure-property-manageable.ts'; +import { assertPropertyManageableOrThrow, getEditablePropertyOrThrow } from './ensure-property-manageable.ts'; export type PropertyUpdateListingDetailCommand = PropertyListingDetailFieldsCommand; export interface PropertyUpdateCommand extends PropertyFieldsCommand { id: string; - propertyName?: string; + propertyName?: string | null; } export const update = (dataSources: DataSources) => { return async (command: PropertyUpdateCommand): Promise => { let propertyToReturn: Domain.Contexts.Property.Property.PropertyEntityReference | undefined; await dataSources.domainDataSource.Property.Property.PropertyUnitOfWork.withScopedTransaction(async (repo) => { - const property = await getManageablePropertyOrThrow(repo, command.id); + const property = await getEditablePropertyOrThrow(repo, command.id); + + // These fields are not listing content and must be authorized before + // applying any editable field. This keeps a mixed member request + // atomic even though individual aggregate setters authorize + // themselves too. + if (command.propertyName !== undefined || command.propertyType !== undefined || command.ownerId !== undefined) { + assertPropertyManageableOrThrow(property); + } if (command.propertyName !== undefined && command.propertyName !== null) { // Friendly pre-check on rename only (exact string compare, matching the diff --git a/packages/ocom/application-services/src/index.test.ts b/packages/ocom/application-services/src/index.test.ts index d39505e78..7a272e0bb 100644 --- a/packages/ocom/application-services/src/index.test.ts +++ b/packages/ocom/application-services/src/index.test.ts @@ -8,21 +8,28 @@ const memberOfCommunityA = { id: 'member-1', accounts: [{ user: { id: 'user-1' } }], community: { id: 'community-a' }, + role: { id: 'role-1' }, }; -const foreignMember = { - id: 'member-2', - accounts: [{ user: { id: 'someone-else' } }], - community: { id: 'community-a' }, -}; - -function buildContext(overrides?: { member?: unknown; community?: unknown }) { +function buildContext(overrides?: { member?: unknown }) { + const currentMemberLookup = vi.fn((_endUserId: string, communityId: string) => { + if (overrides?.member !== undefined) { + return overrides.member; + } + return communityId === 'community-a' ? memberOfCommunityA : null; + }); + const hintedMemberLookup = vi.fn().mockResolvedValue(memberOfCommunityA); const readonlyDataSource = { User: { EndUser: { EndUserReadRepo: { getByExternalId: vi.fn().mockResolvedValue(endUser) } }, }, Community: { - Member: { MemberReadRepo: { getByIdWithCommunityAndRoleAndUser: vi.fn().mockResolvedValue(overrides?.member ?? memberOfCommunityA) } }, - Community: { CommunityReadRepo: { getById: vi.fn().mockResolvedValue(overrides?.community ?? { id: 'community-a' }) } }, + Member: { + MemberReadRepo: { + getByEndUserIdAndCommunityIdWithRole: currentMemberLookup, + getByIdWithCommunityAndRoleAndUser: hintedMemberLookup, + }, + }, + Community: { CommunityReadRepo: { getById: vi.fn().mockResolvedValue({ id: 'community-a' }) } }, }, }; const withPassport = vi.fn().mockReturnValue({}); @@ -40,22 +47,23 @@ function buildContext(overrides?: { member?: unknown; community?: unknown }) { blobStorageService: {}, queueStorageService: {}, } as unknown as ApiContextSpec; - return { context, withPassport }; + return { context, withPassport, currentMemberLookup, hintedMemberLookup }; } describe('buildApplicationServicesFactory forRequest principal hints', () => { - it('falls back to the guest passport when the member does not belong to the hinted community', async () => { - const { context, withPassport } = buildContext({ community: { id: 'community-b' } }); + it('falls back to the guest passport when the verified user has no member in the requested community', async () => { + const { context, withPassport, currentMemberLookup } = buildContext(); const factory = buildApplicationServicesFactory(context); await expect(factory.forRequest('Bearer token', { memberId: 'member-1', communityId: 'community-b' })).resolves.toBeDefined(); + expect(currentMemberLookup).toHaveBeenCalledWith('user-1', 'community-b'); expect(withPassport).toHaveBeenCalledTimes(1); expect(withPassport.mock.calls[0]?.[0]).toStrictEqual(Domain.PassportFactory.forGuest()); }); - it('falls back to the guest passport when the hinted member belongs to another user', async () => { - const { context, withPassport } = buildContext({ member: foreignMember }); + it('falls back to the guest passport when the verified current-member lookup does not return a member', async () => { + const { context, withPassport } = buildContext({ member: null }); const factory = buildApplicationServicesFactory(context); await expect(factory.forRequest('Bearer token', { memberId: 'member-2', communityId: 'community-a' })).resolves.toBeDefined(); @@ -63,12 +71,54 @@ describe('buildApplicationServicesFactory forRequest principal hints', () => { expect(withPassport.mock.calls[0]?.[0]).toStrictEqual(Domain.PassportFactory.forGuest()); }); - it('builds a member passport when the hinted member and community match', async () => { - const { context, withPassport } = buildContext(); + it('fails closed without throwing when the verified current member has no populated role', async () => { + const rolelessMember = Object.defineProperty( + { + ...memberOfCommunityA, + role: undefined, + }, + 'role', + { + get() { + throw new Error('role is not populated'); + }, + }, + ); + const { context, withPassport } = buildContext({ member: rolelessMember }); + const factory = buildApplicationServicesFactory(context); + + await expect(factory.forRequest('******', { memberId: 'member-1', communityId: 'community-a' })).resolves.toBeDefined(); + + expect(withPassport.mock.calls[0]?.[0]).toStrictEqual(Domain.PassportFactory.forGuest()); + }); + + it('propagates an unexpected current-member role resolution failure', async () => { + const unavailableRoleMember = Object.defineProperty( + { + ...memberOfCommunityA, + role: undefined, + }, + 'role', + { + get() { + throw new Error('role service unavailable'); + }, + }, + ); + const { context } = buildContext({ member: unavailableRoleMember }); + const factory = buildApplicationServicesFactory(context); + + await expect(factory.forRequest('******', { memberId: 'member-1', communityId: 'community-a' })).rejects.toThrow('role service unavailable'); + }); + + it('uses the verified-user and community lookup rather than the untrusted member-id hint', async () => { + const { context, withPassport, currentMemberLookup, hintedMemberLookup } = buildContext(); const factory = buildApplicationServicesFactory(context); await factory.forRequest('Bearer token', { memberId: 'member-1', communityId: 'community-a' }); + expect(currentMemberLookup).toHaveBeenCalledWith('user-1', 'community-a'); + expect(hintedMemberLookup).not.toHaveBeenCalled(); expect(withPassport.mock.calls[0]?.[0]).not.toStrictEqual(Domain.PassportFactory.forGuest()); }); diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts index d227b16f5..c3cbd6c03 100644 --- a/packages/ocom/application-services/src/index.ts +++ b/packages/ocom/application-services/src/index.ts @@ -55,31 +55,47 @@ export interface AppServicesHost { export type ApplicationServicesFactory = AppServicesHost; +const isUnpopulatedMemberRoleError = (error: unknown): boolean => error instanceof Error && (error.message === 'role is not populated' || error.message === 'role is not populated or is not of the correct type'); + +/** + * Members without an assigned role are valid route-denial subjects. Domain + * member references expose `role` as a required populated reference, so an + * absent persisted role throws when its getter is read. Treat that expected + * absence as ineligible rather than failing request-context construction. + */ +const hasResolvedMemberRole = (member: Domain.Contexts.Community.Member.MemberEntityReference | null): boolean => { + if (!member) { + return false; + } + try { + return Boolean(member.role); + } catch (error) { + if (isUnpopulatedMemberRoleError(error)) { + return false; + } + throw error; + } +}; + export const buildApplicationServicesFactory = (context: ApiContextSpec): ApplicationServicesFactory => { const forRequest = async (rawAuthHeader?: string, hints?: PrincipalHints): Promise => { const accessToken = rawAuthHeader?.replace(/^Bearer\s+/i, '').trim(); const tokenValidationResult = accessToken ? await context.tokenValidationService.verifyJwt(accessToken) : null; let passport = Domain.PassportFactory.forGuest(); + let currentMember: Domain.Contexts.Community.Member.MemberEntityReference | null = null; if (tokenValidationResult !== null) { const { verifiedJwt, openIdConfigKey } = tokenValidationResult; const { readonlyDataSource } = context.dataSourcesFactory.withSystemPassport(); if (openIdConfigKey === 'AccountPortal') { const endUser = await readonlyDataSource.User.EndUser.EndUserReadRepo.getByExternalId(verifiedJwt.sub); - const member = hints?.memberId ? await readonlyDataSource.Community.Member.MemberReadRepo.getByIdWithCommunityAndRoleAndUser(hints?.memberId) : null; - const community = hints?.communityId ? await readonlyDataSource.Community.Community.CommunityReadRepo.getById(hints?.communityId) : null; + const member = endUser && hints?.communityId ? await readonlyDataSource.Community.Member.MemberReadRepo.getByEndUserIdAndCommunityIdWithRole(endUser.id, hints.communityId) : null; - if (endUser && member && community) { - // Build a member passport only for coherent principal hints: the - // member must belong to both the authenticated user and the hinted - // community. Incoherent hints (e.g. a member id paired with another - // community's id in the route) fail closed to the guest passport, - // matching the fallback when a hint lookup finds nothing. Any other - // passport-construction failure propagates. - const memberBelongsToUser = member.accounts.some((account) => account.user.id === endUser.id); - const memberBelongsToCommunity = member.community.id === community.id; - if (memberBelongsToUser && memberBelongsToCommunity) { - passport = Domain.PassportFactory.forMember(endUser, member, community); - } + // A selected community is not an authentication claim, but it can + // scope the canonical membership lookup. The member ID header is + // intentionally never consulted as actor identity. + if (endUser && member && hasResolvedMemberRole(member)) { + passport = Domain.PassportFactory.forMember(endUser, member, member.community); + currentMember = member; } } else if (openIdConfigKey === 'StaffPortal') { const staffUser = await readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByExternalId(verifiedJwt.sub); @@ -95,7 +111,7 @@ export const buildApplicationServicesFactory = (context: ApiContextSpec): Applic return { Community: Community(dataSources, blobStorageService, queueStorageService), - Property: Property(dataSources), + Property: Property(dataSources, { currentMember }), Service: Service(dataSources), User: User(dataSources), get verifiedUser(): VerifiedUser | null { diff --git a/packages/ocom/domain/src/domain/contexts/property/index.ts b/packages/ocom/domain/src/domain/contexts/property/index.ts index 9955dd9c9..2a5fb71c1 100644 --- a/packages/ocom/domain/src/domain/contexts/property/index.ts +++ b/packages/ocom/domain/src/domain/contexts/property/index.ts @@ -1,2 +1,2 @@ export * as Property from './property/index.ts'; -export type { PropertyPassport } from './property.passport.ts'; +export type { PropertyAuthorizationSubject, PropertyPassport } from './property.passport.ts'; diff --git a/packages/ocom/domain/src/domain/contexts/property/property.passport.ts b/packages/ocom/domain/src/domain/contexts/property/property.passport.ts index cd61c6206..d5c55994c 100644 --- a/packages/ocom/domain/src/domain/contexts/property/property.passport.ts +++ b/packages/ocom/domain/src/domain/contexts/property/property.passport.ts @@ -1,6 +1,20 @@ -import type { PropertyEntityReference } from './property/property.aggregate.ts'; import type { PropertyVisa } from './property.visa.ts'; +/** + * The minimum immutable property state required to evaluate property access. + * Keeping this narrower than the aggregate lets collection operations + * authorize a community before querying rows without manufacturing a fake + * Property aggregate. + */ +export interface PropertyAuthorizationSubject { + readonly community: { + readonly id: string; + }; + readonly owner?: { + readonly id: string; + } | null; +} + export interface PropertyPassport { - forProperty(root: PropertyEntityReference): PropertyVisa; + forProperty(root: PropertyAuthorizationSubject): PropertyVisa; } diff --git a/packages/ocom/domain/src/domain/contexts/property/property/features/property.aggregate.feature b/packages/ocom/domain/src/domain/contexts/property/property/features/property.aggregate.feature index 528d5f221..a9a61e758 100644 --- a/packages/ocom/domain/src/domain/contexts/property/property/features/property.aggregate.feature +++ b/packages/ocom/domain/src/domain/contexts/property/property/features/property.aggregate.feature @@ -183,8 +183,8 @@ Feature: Property Scenario: Setting the hash with edit own property permission Given a Property aggregate with edit own property permission and is editing own property - When I set the hash to "new-hash-value" - Then the property's hash should be "new-hash-value" + When I try to set the hash to "new-hash-value" + Then a PermissionError should be thrown Scenario: Setting lastIndexed with permission to manage properties Given a Property aggregate with permission to manage properties @@ -198,8 +198,8 @@ Feature: Property Scenario: Setting lastIndexed with edit own property permission Given a Property aggregate with edit own property permission and is editing own property - When I set lastIndexed to a specific date - Then the property's lastIndexed should be updated + When I try to set lastIndexed to a specific date + Then a PermissionError should be thrown Scenario: Setting updateIndexFailedDate with permission to manage properties Given a Property aggregate with permission to manage properties @@ -213,8 +213,8 @@ Feature: Property Scenario: Setting updateIndexFailedDate with edit own property permission Given a Property aggregate with edit own property permission and is editing own property - When I set updateIndexFailedDate to a specific date - Then the property's updateIndexFailedDate should be updated + When I try to set updateIndexFailedDate to a specific date + Then a PermissionError should be thrown Scenario: Getting listingDetail Given a Property aggregate diff --git a/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.test.ts b/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.test.ts index ad36dcbd6..de421fe05 100644 --- a/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.test.ts +++ b/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.test.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; import type { PropArray } from '@cellix/domain-seedwork/prop-array'; -import { expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { CommunityEntityReference, CommunityProps } from '../../community/community/community.ts'; import type { MemberEntityReference } from '../../community/member/member.ts'; import type { MemberAccountEntityReference } from '../../community/member/member-account.ts'; @@ -193,7 +193,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Scenario('Creating a new property instance', ({ When, Then, And }) => { When('I create a new Property aggregate using getNewInstance with propertyName "New Property", and a CommunityEntityReference', () => { - newProperty = Property.getNewInstance(makeBaseProps(), 'New Property', communityRef, passport); + newProperty = Property.getNewInstance(makeBaseProps(), 'New Property', communityRef, null, passport); }); Then('the property\'s propertyName should be "New Property"', () => { expect(newProperty.propertyName).toBe('New Property'); @@ -215,6 +215,77 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); + describe('Property member ownership policy', () => { + it('binds the trusted member owner before applying own-editor creation defaults', () => { + const community = makeCommunityEntityReference('community-1'); + const trustedOwner = makeMemberEntityReference('member-1'); + const passport = makePassport({ + canManageProperties: false, + canEditOwnProperty: true, + isEditingOwnProperty: true, + }); + + const property = Property.getNewInstance(makeBaseProps({ owner: null }), 'Member Cottage', community, trustedOwner, passport); + + expect(property.owner?.id).toBe('member-1'); + expect(property.listedForSale).toBe(false); + expect(property.listedForRent).toBe(false); + expect(property.listedForLease).toBe(false); + expect(property.listedInDirectory).toBe(false); + }); + + it('uses the bound owner for own-editor creation when persistence stores the owner as an unresolved reference', () => { + const community = makeCommunityEntityReference('community-1'); + const trustedOwner = makeMemberEntityReference('member-1'); + const setOwnerRef = vi.fn(); + const props = makeBaseProps({ + owner: null, + setOwnerRef, + }); + Object.defineProperty(props, 'owner', { + get: () => { + throw new Error('owner relation is unresolved'); + }, + }); + const passport = makePassport({ + canManageProperties: false, + canEditOwnProperty: true, + isEditingOwnProperty: true, + }); + + const property = Property.getNewInstance(props, 'Member Cottage', community, trustedOwner, passport); + + expect(property.owner).toBe(trustedOwner); + expect(setOwnerRef).toHaveBeenCalledWith(trustedOwner); + }); + + it('rejects own-property creation when the initial owner is not the actor', () => { + const community = makeCommunityEntityReference('community-1'); + const foreignOwner = makeMemberEntityReference('member-2'); + const passport = makePassport({ + canManageProperties: false, + canEditOwnProperty: true, + isEditingOwnProperty: false, + }); + + expect(() => Property.getNewInstance(makeBaseProps({ owner: null }), 'Spoofed Cottage', community, foreignOwner, passport)).toThrow('Cannot create new property'); + }); + + it('rejects assigning an owner from another community before changing the owner reference', () => { + const property = new Property(makeBaseProps({ owner: null }), makePassport({ canManageProperties: true })); + const foreignOwner = { + ...makeMemberEntityReference('member-2'), + communityId: 'community-2', + community: makeCommunityEntityReference('community-2'), + } as MemberEntityReference; + + expect(() => { + property.owner = foreignOwner; + }).toThrow("Property owner must belong to the property's community"); + expect(property.owner).toBeNull(); + }); + }); + Scenario('Changing the propertyName with permission to manage properties', ({ Given, When, Then }) => { Given('a Property aggregate with permission to manage properties', () => { passport = makePassport({ canManageProperties: true }); @@ -752,6 +823,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); Scenario('Setting the hash with edit own property permission', ({ Given, When, Then }) => { + let setHashWithEditOwnPropertyPermission: () => void; Given('a Property aggregate with edit own property permission and is editing own property', () => { passport = makePassport({ canManageProperties: false, @@ -760,11 +832,14 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); property = new Property(makeBaseProps(), passport); }); - When('I set the hash to "new-hash-value"', () => { - property.hash = 'new-hash-value'; + When('I try to set the hash to "new-hash-value"', () => { + setHashWithEditOwnPropertyPermission = () => { + property.hash = 'new-hash-value'; + }; }); - Then('the property\'s hash should be "new-hash-value"', () => { - expect(property.hash).toBe('new-hash-value'); + Then('a PermissionError should be thrown', () => { + expect(setHashWithEditOwnPropertyPermission).toThrow(PermissionError); + expect(setHashWithEditOwnPropertyPermission).toThrow('You do not have permission to update the index hash for this property'); }); }); @@ -805,6 +880,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); Scenario('Setting lastIndexed with edit own property permission', ({ Given, When, Then }) => { + let setLastIndexedWithEditOwnPropertyPermission: () => void; Given('a Property aggregate with edit own property permission and is editing own property', () => { passport = makePassport({ canManageProperties: false, @@ -813,12 +889,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); property = new Property(makeBaseProps(), passport); }); - When('I set lastIndexed to a specific date', () => { - const testDate = new Date('2024-01-01T00:00:00Z'); - property.lastIndexed = testDate; + When('I try to set lastIndexed to a specific date', () => { + setLastIndexedWithEditOwnPropertyPermission = () => { + const testDate = new Date('2024-01-01T00:00:00Z'); + property.lastIndexed = testDate; + }; }); - Then("the property's lastIndexed should be updated", () => { - expect(property.lastIndexed).toEqual(new Date('2024-01-01T00:00:00Z')); + Then('a PermissionError should be thrown', () => { + expect(setLastIndexedWithEditOwnPropertyPermission).toThrow(PermissionError); + expect(setLastIndexedWithEditOwnPropertyPermission).toThrow('You do not have permission to update the index timestamp for this property'); }); }); @@ -859,6 +938,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); Scenario('Setting updateIndexFailedDate with edit own property permission', ({ Given, When, Then }) => { + let setUpdateIndexFailedDateWithEditOwnPropertyPermission: () => void; Given('a Property aggregate with edit own property permission and is editing own property', () => { passport = makePassport({ canManageProperties: false, @@ -867,12 +947,15 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); property = new Property(makeBaseProps(), passport); }); - When('I set updateIndexFailedDate to a specific date', () => { - const testDate = new Date('2024-01-01T00:00:00Z'); - property.updateIndexFailedDate = testDate; + When('I try to set updateIndexFailedDate to a specific date', () => { + setUpdateIndexFailedDateWithEditOwnPropertyPermission = () => { + const testDate = new Date('2024-01-01T00:00:00Z'); + property.updateIndexFailedDate = testDate; + }; }); - Then("the property's updateIndexFailedDate should be updated", () => { - expect(property.updateIndexFailedDate).toEqual(new Date('2024-01-01T00:00:00Z')); + Then('a PermissionError should be thrown', () => { + expect(setUpdateIndexFailedDateWithEditOwnPropertyPermission).toThrow(PermissionError); + expect(setUpdateIndexFailedDateWithEditOwnPropertyPermission).toThrow('You do not have permission to update the failed index timestamp for this property'); }); }); diff --git a/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.ts b/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.ts index 32af7421a..0c7ae9714 100644 --- a/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.ts +++ b/packages/ocom/domain/src/domain/contexts/property/property/property.aggregate.ts @@ -43,20 +43,35 @@ export interface PropertyEntityReference extends Readonly extends AggregateRoot implements PropertyEntityReference { private isNew: boolean = false; private visaCache: PropertyVisa | undefined; + /** + * Persistence adapters commonly write owner references as raw identifiers. + * Retain an explicitly assigned trusted owner for the lifetime of this + * aggregate so own-property authorization does not have to dereference an + * unpopulated persistence relation during creation or reassignment. + */ + private assignedOwner: MemberEntityReference | null | undefined; - public static getNewInstance(newProps: props, propertyName: string, community: CommunityEntityReference, passport: Passport): Property { + public static getNewInstance(newProps: props, propertyName: string, community: CommunityEntityReference, owner: MemberEntityReference | null, passport: Passport): Property { const property = new Property(newProps, passport); property.isNew = true; - property.propertyName = propertyName; - property.community = community; - property.listedForSale = false; - property.listedForRent = false; - property.listedForLease = false; - property.listedInDirectory = false; - property.addIntegrationEvent(PropertyCreatedEvent, { - id: property.props.id, - }); - property.isNew = false; + try { + // The owner must be present before creation capability and defaults are + // evaluated: an own-property editor's visa derives ownership from this + // aggregate rather than from untrusted request input. + property.community = community; + property.owner = owner; + property.assertCanCreateProperty(); + property.propertyName = propertyName; + property.listedForSale = false; + property.listedForRent = false; + property.listedForLease = false; + property.listedInDirectory = false; + property.addIntegrationEvent(PropertyCreatedEvent, { + id: property.props.id, + }); + } finally { + property.isNew = false; + } return property; } @@ -76,6 +91,22 @@ export class Property extends AggregateRoot extends AggregateRoot | null): void { + if (owner && owner.community.id !== this.props.community.id) { + throw new Error("Property owner must belong to the property's community"); + } + } + private normalizeTags(tags: string[]): string[] { const normalized = tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0); if (normalized.length > 50) { @@ -136,14 +173,16 @@ export class Property extends AggregateRoot extends AggregateRoot extends AggregateRoot extends AggregateRoot extends Repository> { - getNewInstance(propertyName: string, community: CommunityEntityReference): Promise>; + getNewInstance(propertyName: string, community: CommunityEntityReference, owner: MemberEntityReference | null): Promise>; getById(id: string): Promise>; getAll(): Promise>>; } diff --git a/packages/ocom/domain/src/domain/iam/guest/contexts/guest.property.passport.ts b/packages/ocom/domain/src/domain/iam/guest/contexts/guest.property.passport.ts index c98b1ebfb..87587ef42 100644 --- a/packages/ocom/domain/src/domain/iam/guest/contexts/guest.property.passport.ts +++ b/packages/ocom/domain/src/domain/iam/guest/contexts/guest.property.passport.ts @@ -1,10 +1,9 @@ -import type { PropertyEntityReference } from '../../../contexts/property/property/property.aggregate.ts'; -import type { PropertyPassport } from '../../../contexts/property/property.passport.ts'; +import type { PropertyAuthorizationSubject, PropertyPassport } from '../../../contexts/property/property.passport.ts'; import type { PropertyVisa } from '../../../contexts/property/property.visa.ts'; import { GuestPassportBase } from '../guest.passport-base.ts'; export class GuestPropertyPassport extends GuestPassportBase implements PropertyPassport { - forProperty(_root: PropertyEntityReference): PropertyVisa { + forProperty(_root: PropertyAuthorizationSubject): PropertyVisa { return { determineIf: () => false }; } } diff --git a/packages/ocom/domain/src/domain/iam/member/contexts/member.property.passport.ts b/packages/ocom/domain/src/domain/iam/member/contexts/member.property.passport.ts index 55d432a63..4bdccff94 100644 --- a/packages/ocom/domain/src/domain/iam/member/contexts/member.property.passport.ts +++ b/packages/ocom/domain/src/domain/iam/member/contexts/member.property.passport.ts @@ -1,10 +1,9 @@ -import type { PropertyEntityReference } from '../../../contexts/property/property/property.aggregate.ts'; -import type { PropertyPassport } from '../../../contexts/property/property.passport.ts'; +import type { PropertyAuthorizationSubject, PropertyPassport } from '../../../contexts/property/property.passport.ts'; import { MemberPassportBase } from '../member.passport-base.ts'; import { MemberPropertyVisa } from './member.property.visa.ts'; export class MemberPropertyPassport extends MemberPassportBase implements PropertyPassport { - forProperty(root: PropertyEntityReference) { + forProperty(root: PropertyAuthorizationSubject) { return new MemberPropertyVisa(root, this._member, this._user); } } diff --git a/packages/ocom/domain/src/domain/iam/member/contexts/member.property.visa.test.ts b/packages/ocom/domain/src/domain/iam/member/contexts/member.property.visa.test.ts index ed269de64..a77e350f7 100644 --- a/packages/ocom/domain/src/domain/iam/member/contexts/member.property.visa.test.ts +++ b/packages/ocom/domain/src/domain/iam/member/contexts/member.property.visa.test.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import type { MemberEntityReference } from '../../../contexts/community/member/member.ts'; import type { PropertyEntityReference } from '../../../contexts/property/property/property.aggregate.ts'; import type { EndUserEntityReference } from '../../../contexts/user/end-user/end-user.ts'; @@ -73,6 +73,27 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); + describe('MemberPropertyVisa capability boundaries', () => { + it('allows an accepted own-property member to view a foreign same-community property without treating it as editable', () => { + const foreignProperty = makeProperty('property-2', 'community-1', 'member-2'); + const ownPropertyMember = makeMember('member-1', 'community-1', { propertyPermissions: { canManageProperties: false, canEditOwnProperty: true } }, [{ userId: 'user-1', statusCode: 'ACCEPTED' }]); + const visa = new MemberPropertyVisa(foreignProperty, ownPropertyMember, makeUser()); + + expect(visa.determineIf((permissions) => permissions.canEditOwnProperty)).toBe(true); + expect(visa.determineIf((permissions) => permissions.isEditingOwnProperty)).toBe(false); + }); + + it('fails closed when an accepted member has no role', () => { + const memberWithoutRole = { + ...makeMember('member-1', 'community-1', { propertyPermissions: { canManageProperties: false, canEditOwnProperty: true } }, [{ userId: 'user-1', statusCode: 'ACCEPTED' }]), + role: undefined, + } as unknown as MemberEntityReference; + const visa = new MemberPropertyVisa(makeProperty(), memberWithoutRole, makeUser()); + + expect(visa.determineIf(() => true)).toBe(false); + }); + }); + Scenario('Creating a MemberPropertyVisa with a member belonging to the community', ({ When, Then }) => { When('I create a MemberPropertyVisa with the property and member', () => { visa = new MemberPropertyVisa(property, member, user); diff --git a/packages/ocom/domain/src/domain/iam/member/contexts/member.property.visa.ts b/packages/ocom/domain/src/domain/iam/member/contexts/member.property.visa.ts index a37d895bb..3ad6cd16f 100644 --- a/packages/ocom/domain/src/domain/iam/member/contexts/member.property.visa.ts +++ b/packages/ocom/domain/src/domain/iam/member/contexts/member.property.visa.ts @@ -1,11 +1,11 @@ import type { MemberEntityReference } from '../../../contexts/community/member/member.ts'; import { AccountStatusCodes } from '../../../contexts/community/member/member-account.value-objects.ts'; -import type { PropertyEntityReference } from '../../../contexts/property/property/property.aggregate.ts'; import type { PropertyDomainPermissions } from '../../../contexts/property/property.domain-permissions.ts'; +import type { PropertyAuthorizationSubject } from '../../../contexts/property/property.passport.ts'; import type { PropertyVisa } from '../../../contexts/property/property.visa.ts'; import type { EndUserEntityReference } from '../../../contexts/user/end-user/end-user.ts'; -export class MemberPropertyVisa implements PropertyVisa { +export class MemberPropertyVisa implements PropertyVisa { private readonly root: root; private readonly member: MemberEntityReference; private readonly user: EndUserEntityReference; @@ -27,7 +27,14 @@ export class MemberPropertyVisa implements return false; } - const { propertyPermissions } = this.member.role.permissions; + // A membership without a resolved role is not an authenticated property + // principal. Do not pass a permissive callback an all-false object and + // rely on every caller to inspect it. + if (!this.member.role) { + return false; + } + + const propertyPermissions = this.member.role.permissions?.propertyPermissions; const permissions: PropertyDomainPermissions = { canManageProperties: propertyPermissions?.canManageProperties ?? false, canEditOwnProperty: propertyPermissions?.canEditOwnProperty ?? false, diff --git a/packages/ocom/domain/src/domain/iam/system/contexts/system.property.passport.ts b/packages/ocom/domain/src/domain/iam/system/contexts/system.property.passport.ts index 70deaca36..7d0b87035 100644 --- a/packages/ocom/domain/src/domain/iam/system/contexts/system.property.passport.ts +++ b/packages/ocom/domain/src/domain/iam/system/contexts/system.property.passport.ts @@ -1,11 +1,10 @@ -import type { PropertyEntityReference } from '../../../contexts/property/property/property.aggregate.ts'; import type { PropertyDomainPermissions } from '../../../contexts/property/property.domain-permissions.ts'; -import type { PropertyPassport } from '../../../contexts/property/property.passport.ts'; +import type { PropertyAuthorizationSubject, PropertyPassport } from '../../../contexts/property/property.passport.ts'; import type { PropertyVisa } from '../../../contexts/property/property.visa.ts'; import { SystemPassportBase } from '../system.passport-base.ts'; export class SystemPropertyPassport extends SystemPassportBase implements PropertyPassport { - forProperty(_root: PropertyEntityReference): PropertyVisa { + forProperty(_root: PropertyAuthorizationSubject): PropertyVisa { const permissions = this.permissions as PropertyDomainPermissions; return { determineIf: (func) => func(permissions) }; } diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.property.passport.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.property.passport.ts index edcd5202d..2f4a29fe7 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.property.passport.ts +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.property.passport.ts @@ -1,10 +1,9 @@ -import type { PropertyEntityReference } from '../../../../contexts/property/property/property.aggregate.ts'; -import type { PropertyPassport } from '../../../../contexts/property/property.passport.ts'; +import type { PropertyAuthorizationSubject, PropertyPassport } from '../../../../contexts/property/property.passport.ts'; import type { PropertyVisa } from '../../../../contexts/property/property.visa.ts'; import { StaffUserPassportBase } from '../../staff-user.passport-base.ts'; export class StaffUserPropertyPassport extends StaffUserPassportBase implements PropertyPassport { - forProperty(_root: PropertyEntityReference): PropertyVisa { + forProperty(_root: PropertyAuthorizationSubject): PropertyVisa { return { determineIf: () => false }; } } diff --git a/packages/ocom/graphql/src/schema/types/member.resolvers.additional.test.ts b/packages/ocom/graphql/src/schema/types/member.resolvers.additional.test.ts index 30bcfb196..bff757cae 100644 --- a/packages/ocom/graphql/src/schema/types/member.resolvers.additional.test.ts +++ b/packages/ocom/graphql/src/schema/types/member.resolvers.additional.test.ts @@ -10,6 +10,7 @@ function createContext(): GraphContext { queryById: vi.fn(), }, Member: { + determineIfAdmin: vi.fn(), queryById: vi.fn(), queryByIdWithRole: vi.fn(), queryByIdsWithRole: vi.fn(), diff --git a/packages/ocom/graphql/src/schema/types/property.resolvers.ts b/packages/ocom/graphql/src/schema/types/property.resolvers.ts index d372a84fc..f6a49e0bb 100644 --- a/packages/ocom/graphql/src/schema/types/property.resolvers.ts +++ b/packages/ocom/graphql/src/schema/types/property.resolvers.ts @@ -244,10 +244,9 @@ const applySharedPropertyInput = (input: PropertyCreateInput | PropertyUpdateInp }; /** - * Admin-side property reads are authorized in the application services via the - * request passport's property visa (built from the request's current - * member/community hints), which requires `canManageProperties` in the - * property's community. Resolvers only pre-check that a verified user exists. + * Property authorization remains in application services. Resolvers only + * perform transport-level authenticated-user checks; the service separates + * member read/edit permissions from manager-only operations. */ const property: Resolvers = { Property: { @@ -273,7 +272,10 @@ const property: Resolvers = { Query: { property: async (_parent, args: { id: string }, context: GraphContext, _info: GraphQLResolveInfo) => { if (!context.applicationServices.verifiedUser?.verifiedJwt) { - throw new Error('Unauthorized'); + // Detail reads are intentionally non-disclosing: an unauthenticated + // request receives the same null shape as a missing or inaccessible + // property rather than an authorization oracle. + return null; } const foundProperty = await context.applicationServices.Property.Property.queryById({ id: args.id, @@ -327,7 +329,7 @@ const property: Resolvers = { const updateCommand: PropertyUpdateCommand = { id: args.input.id, }; - if (args.input.propertyName !== null && args.input.propertyName !== undefined) { + if (args.input.propertyName !== undefined) { updateCommand.propertyName = args.input.propertyName; } applySharedPropertyInput(args.input, updateCommand); diff --git a/packages/ocom/graphql/src/schema/types/property.resolvers.unit.test.ts b/packages/ocom/graphql/src/schema/types/property.resolvers.unit.test.ts index a1e5d7f1d..51c82c3b3 100644 --- a/packages/ocom/graphql/src/schema/types/property.resolvers.unit.test.ts +++ b/packages/ocom/graphql/src/schema/types/property.resolvers.unit.test.ts @@ -105,11 +105,12 @@ describe('property.resolvers - unit tests', () => { }); describe('Query.property', () => { - it('throws Unauthorized when there is no verified user', async () => { + it('returns null when there is no verified user so detail reads remain non-disclosing', async () => { const context = createContext(); setVerifiedUser(context, undefined); const resolver = propertyResolvers.Query?.property as ResolverFn; - await expect(resolver(null, { id: 'property-1' }, context, info)).rejects.toThrow('Unauthorized'); + await expect(resolver(null, { id: 'property-1' }, context, info)).resolves.toBeNull(); + expect(context.applicationServices.Property.Property.queryById).not.toHaveBeenCalled(); }); it('returns the property from queryById', async () => { @@ -323,7 +324,7 @@ describe('property.resolvers - unit tests', () => { expect(context.applicationServices.Property.Property.queryById).not.toHaveBeenCalled(); }); - it('forwards provided fields, dropping null name but keeping type and numeric nulls as clears', async () => { + it('forwards provided manager-only fields, including a null name, for service-layer authorization', async () => { const context = createContext(); vi.mocked(context.applicationServices.Property.Property.update).mockResolvedValue({ id: 'property-1' } as never); const resolver = propertyResolvers.Mutation?.propertyUpdate as ResolverFn; @@ -343,6 +344,7 @@ describe('property.resolvers - unit tests', () => { ); expect(context.applicationServices.Property.Property.update).toHaveBeenCalledWith({ id: 'property-1', + propertyName: null, propertyType: 'condo', listingDetail: { bedrooms: 3, bathrooms: null }, }); @@ -576,4 +578,34 @@ describe('property.resolvers - unit tests', () => { consoleErr.mockRestore(); }); }); + + describe('member-facing property authorization results', () => { + it('preserves the generic missing-property mutation result returned by the application service', async () => { + const context = createContext(); + const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => { + // suppress expected error logging + }); + vi.mocked(context.applicationServices.Property.Property.update).mockRejectedValue(new Error('Property not found')); + const resolver = propertyResolvers.Mutation?.propertyUpdate as ResolverFn; + + await expect(resolver(null, { input: { id: 'foreign-property', listedInDirectory: true } }, context, info)).resolves.toMatchObject({ + status: { success: false, errorMessage: 'Property not found' }, + }); + consoleErr.mockRestore(); + }); + + it('continues forwarding a supplied ownerId so the application service can reject it for member creation', async () => { + const context = createContext(); + vi.mocked(context.applicationServices.Property.Property.create).mockResolvedValue({ id: 'property-1' } as never); + const resolver = propertyResolvers.Mutation?.propertyCreate as ResolverFn; + + await resolver(null, { input: { propertyName: 'Spoofed Cottage', ownerId: 'member-1' } }, context, info); + + expect(context.applicationServices.Property.Property.create).toHaveBeenCalledWith({ + propertyName: 'Spoofed Cottage', + communityId: 'community-1', + ownerId: 'member-1', + }); + }); + }); }); diff --git a/packages/ocom/persistence/src/datasources/domain/property/property/property.repository.test.ts b/packages/ocom/persistence/src/datasources/domain/property/property/property.repository.test.ts index affc0f003..aff63de27 100644 --- a/packages/ocom/persistence/src/datasources/domain/property/property/property.repository.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/property/property/property.repository.test.ts @@ -191,7 +191,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { communityDomainObject = { id: '507f1f77bcf86cd799439012', name: 'Test Community' } as Domain.Contexts.Community.Community.CommunityEntityReference; }); When('I call getNewInstance with name "New Property" and the community', async () => { - result = await repo.getNewInstance('New Property', communityDomainObject); + result = await repo.getNewInstance('New Property', communityDomainObject, null); }); Then('I should receive a new Property domain object', () => { expect(result).toBeInstanceOf(Domain.Contexts.Property.Property.Property); @@ -211,7 +211,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { invalidCommunity = undefined; }); When('I call getNewInstance with name "Invalid Property" and the invalid community', () => { - getNewInstanceWithInvalidCommunity = () => repo.getNewInstance('Invalid Property', invalidCommunity as Domain.Contexts.Community.Community.CommunityEntityReference); + getNewInstanceWithInvalidCommunity = () => repo.getNewInstance('Invalid Property', invalidCommunity as Domain.Contexts.Community.Community.CommunityEntityReference, null); }); Then('an error should be thrown indicating the community is not valid', async () => { await expect(getNewInstanceWithInvalidCommunity).rejects.toThrow(); diff --git a/packages/ocom/persistence/src/datasources/domain/property/property/property.repository.ts b/packages/ocom/persistence/src/datasources/domain/property/property/property.repository.ts index b2fca8151..1b4542185 100644 --- a/packages/ocom/persistence/src/datasources/domain/property/property/property.repository.ts +++ b/packages/ocom/persistence/src/datasources/domain/property/property/property.repository.ts @@ -12,9 +12,13 @@ export class PropertyRepository implements Domain.Contexts.Property.Property.PropertyRepository { // biome-ignore lint:noRequireAwait - async getNewInstance(propertyName: string, community: Domain.Contexts.Community.Community.CommunityEntityReference): Promise> { + async getNewInstance( + propertyName: string, + community: Domain.Contexts.Community.Community.CommunityEntityReference, + owner: Domain.Contexts.Community.Member.MemberEntityReference | null, + ): Promise> { const adapter = this.typeConverter.toAdapter(new this.model()); - return Promise.resolve(Domain.Contexts.Property.Property.Property.getNewInstance(adapter, propertyName, community, this.passport)); + return Promise.resolve(Domain.Contexts.Property.Property.Property.getNewInstance(adapter, propertyName, community, owner, this.passport)); } async getById(id: string): Promise> { diff --git a/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.test.ts b/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.test.ts index 0ed877e45..7e8af5540 100644 --- a/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.test.ts +++ b/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.test.ts @@ -4,7 +4,7 @@ import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import type { Member } from '@ocom/data-sources-mongoose-models/member'; import type { Domain } from '@ocom/domain'; -import { expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ModelsContext } from '../../../../index.ts'; import { MemberConverter } from '../../../domain/community/member/member.domain-adapter.ts'; import { MemberDataSourceImpl } from './member.data.ts'; @@ -135,6 +135,92 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { }); }); + describe('MemberReadRepositoryImpl scoped property-owner lookup', () => { + it('queries by both owner id and community while loading the owner role', async () => { + const ownerDocument = makeMockMemberDocument(); + const dataSource = { + find: vi.fn(), + findById: vi.fn(), + findOne: vi.fn().mockResolvedValue(ownerDocument), + aggregate: vi.fn(), + }; + const converter = { + toDomain: vi.fn().mockReturnValue({ id: 'member-123', communityId: 'community-1' }), + }; + vi.mocked(MemberDataSourceImpl).mockImplementation(function ScopedOwnerDataSource() { + return dataSource as unknown as InstanceType; + }); + vi.mocked(MemberConverter).mockImplementation(function ScopedOwnerConverter() { + return converter as unknown as MemberConverter; + }); + const passport = makeMockPassport(); + const repository = new MemberReadRepositoryImpl(makeMockModelsContext(), passport); + + await expect(repository.getByIdInCommunityWithRole('member-123', 'community-1')).resolves.toEqual({ + id: 'member-123', + communityId: 'community-1', + }); + + expect(dataSource.findOne).toHaveBeenCalledWith( + { + _id: expect.objectContaining({ toString: expect.any(Function), equals: expect.any(Function) }), + community: expect.objectContaining({ toString: expect.any(Function), equals: expect.any(Function) }), + }, + { populateFields: ['community', 'role', 'role.community'] }, + ); + }); + + it('returns null from the scoped lookup for both a missing and a foreign owner', async () => { + const dataSource = { + find: vi.fn(), + findById: vi.fn(), + findOne: vi.fn().mockResolvedValue(null), + aggregate: vi.fn(), + }; + vi.mocked(MemberDataSourceImpl).mockImplementation(function MissingScopedOwnerDataSource() { + return dataSource as unknown as InstanceType; + }); + const repository = new MemberReadRepositoryImpl(makeMockModelsContext(), makeMockPassport()); + + await expect(repository.getByIdInCommunityWithRole('foreign-or-missing', 'community-1')).resolves.toBeNull(); + }); + }); + + describe('MemberReadRepositoryImpl verified current-member lookup', () => { + it('resolves a member from the verified EndUser id and requested community rather than a member id hint', async () => { + const memberDocument = makeMockMemberDocument(); + const dataSource = { + find: vi.fn(), + findById: vi.fn(), + findOne: vi.fn().mockResolvedValue(memberDocument), + aggregate: vi.fn(), + }; + const converter = { + toDomain: vi.fn().mockReturnValue({ id: 'member-123', community: { id: 'community-1' } }), + }; + vi.mocked(MemberDataSourceImpl).mockImplementation(function CurrentMemberDataSource() { + return dataSource as unknown as InstanceType; + }); + vi.mocked(MemberConverter).mockImplementation(function CurrentMemberConverter() { + return converter as unknown as MemberConverter; + }); + const repository = new MemberReadRepositoryImpl(makeMockModelsContext(), makeMockPassport()); + + await expect(repository.getByEndUserIdAndCommunityIdWithRole('end-user-1', 'community-1')).resolves.toEqual({ + id: 'member-123', + community: { id: 'community-1' }, + }); + + expect(dataSource.findOne).toHaveBeenCalledWith( + { + community: expect.objectContaining({ toString: expect.any(Function), equals: expect.any(Function) }), + 'accounts.user': expect.objectContaining({ toString: expect.any(Function), equals: expect.any(Function) }), + }, + { populateFields: ['community', 'role', 'role.community', 'accounts.user'] }, + ); + }); + }); + Scenario('Getting members by community ID', ({ When, Then }) => { When('I call getByCommunityId with "507f1f77bcf86cd799439011"', async () => { await repository.getByCommunityId('507f1f77bcf86cd799439011'); diff --git a/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.ts b/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.ts index ee7f8159e..f5e411501 100644 --- a/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.ts +++ b/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.ts @@ -9,6 +9,18 @@ export interface MemberReadRepository { getByCommunityId: (communityId: string, options?: FindOptions) => Promise; getById: (id: string, options?: FindOneOptions) => Promise; getByIdWithRole: (id: string, options?: FindOneOptions) => Promise; + /** + * Retrieves a member only when it belongs to the supplied community. Used + * for ownership assignment so a foreign member identifier is + * indistinguishable from a missing one. + */ + getByIdInCommunityWithRole: (id: string, communityId: string) => Promise; + /** + * Resolves the current member from the verified EndUser identity and the + * selected community; callers must not use a client-supplied member ID as + * an authentication principal. + */ + getByEndUserIdAndCommunityIdWithRole: (endUserId: string, communityId: string) => Promise; /** * Retrieves the members matching the given IDs with their role populated in a single query. * Missing IDs are simply absent from the result; order is not guaranteed. @@ -103,6 +115,34 @@ export class MemberReadRepositoryImpl implements MemberReadRepository { return this.converter.toDomain(result, this.passport); } + async getByIdInCommunityWithRole(id: string, communityId: string): Promise { + const result = await this.mongoDataSource.findOne( + { + _id: new MongooseSeedwork.ObjectId(id), + community: new MongooseSeedwork.ObjectId(communityId), + } as Parameters[0], + { populateFields: ['community', 'role', 'role.community'] }, + ); + if (!result) { + return null; + } + return this.converter.toDomain(result, this.passport); + } + + async getByEndUserIdAndCommunityIdWithRole(endUserId: string, communityId: string): Promise { + const result = await this.mongoDataSource.findOne( + { + community: new MongooseSeedwork.ObjectId(communityId), + 'accounts.user': new MongooseSeedwork.ObjectId(endUserId), + } as Parameters[0], + { populateFields: ['community', 'role', 'role.community', 'accounts.user'] }, + ); + if (!result) { + return null; + } + return this.converter.toDomain(result, this.passport); + } + async getByIdsWithRole(ids: string[]): Promise { if (ids.length === 0) { return []; diff --git a/packages/ocom/ui-community-route-admin/src/components/format-display-address.ts b/packages/ocom/ui-community-route-admin/src/components/format-display-address.ts index ebf79aa7c..38ce6e150 100644 --- a/packages/ocom/ui-community-route-admin/src/components/format-display-address.ts +++ b/packages/ocom/ui-community-route-admin/src/components/format-display-address.ts @@ -1,30 +1 @@ -/** Address parts accepted by {@link formatDisplayAddress}. */ -interface DisplayAddressParts { - streetNumber?: string | null; - streetName?: string | null; - municipality?: string | null; - countrySubdivision?: string | null; - postalCode?: string | null; - country?: string | null; -} - -const nonEmpty = (value?: string | null): value is string => typeof value === 'string' && value.trim().length > 0; - -/** - * Formats an address as `streetNumber streetName, municipality, - * countrySubdivision postalCode`, joining only the non-empty parts. - * Returns `N/A` when no part is set. - */ -export const formatDisplayAddress = (address?: DisplayAddressParts | null): string => { - const street = [address?.streetNumber, address?.streetName] - .filter(nonEmpty) - .map((part) => part.trim()) - .join(' '); - const region = [address?.countrySubdivision, address?.postalCode] - .filter(nonEmpty) - .map((part) => part.trim()) - .join(' '); - const municipality = nonEmpty(address?.municipality) ? address.municipality.trim() : ''; - const parts = [street, municipality, region].filter((part) => part.length > 0); - return parts.length > 0 ? parts.join(', ') : 'N/A'; -}; +export { formatDisplayAddress } from '@ocom/ui-community-shared'; diff --git a/packages/ocom/ui-community-route-admin/src/components/properties-create.tsx b/packages/ocom/ui-community-route-admin/src/components/properties-create.tsx index 6e4d859cf..68c368a9c 100644 --- a/packages/ocom/ui-community-route-admin/src/components/properties-create.tsx +++ b/packages/ocom/ui-community-route-admin/src/components/properties-create.tsx @@ -1,6 +1,6 @@ +import { PROPERTY_EDIT_POLICIES, PropertyForm, type PropertyFormMemberOption, toManagerPropertyInputFields } from '@ocom/ui-community-shared'; import type React from 'react'; import type { PropertyCreateInput } from '../generated.tsx'; -import { PropertyForm, type PropertyFormMemberOption, toPropertyInputFields } from './property-form.tsx'; interface PropertiesCreateProps { members?: PropertyFormMemberOption[] | undefined; @@ -13,6 +13,7 @@ interface PropertiesCreateProps { export const PropertiesCreate: React.FC = (props) => { return ( = (props) => { onSubmit={(values) => { props.onSave({ propertyName: values.propertyName ?? '', - ...toPropertyInputFields(values), + ...toManagerPropertyInputFields(values), }); }} /> diff --git a/packages/ocom/ui-community-route-admin/src/components/properties-detail.tsx b/packages/ocom/ui-community-route-admin/src/components/properties-detail.tsx index a4296aa1b..a8c898ad5 100644 --- a/packages/ocom/ui-community-route-admin/src/components/properties-detail.tsx +++ b/packages/ocom/ui-community-route-admin/src/components/properties-detail.tsx @@ -1,13 +1,10 @@ -import { Button, Descriptions, Modal, Space, Typography } from 'antd'; -import dayjs from 'dayjs'; +import { PROPERTY_EDIT_POLICIES, PropertyDetails, type PropertyFormMemberOption, type PropertyFormValues, toManagerPropertyInputFields } from '@ocom/ui-community-shared'; +import { Button, Modal, Typography } from 'antd'; import type React from 'react'; import { useState } from 'react'; import type { AdminPropertiesDetailContainerPropertyFieldsFragment, PropertyUpdateInput } from '../generated.tsx'; -import { joinCommaList, PropertyForm, type PropertyFormMemberOption, type PropertyFormValues, toPropertyInputFields } from './property-form.tsx'; -const { Title, Text } = Typography; - -const formatDate = (value: string | null | undefined): string => (value ? dayjs(value).format('MM/DD/YYYY') : 'N/A'); +const { Text } = Typography; /** Update input fields emitted on save; the container adds the property id. */ export type PropertiesDetailSaveInput = Omit; @@ -17,84 +14,23 @@ export interface PropertiesDetailProps { members?: PropertyFormMemberOption[] | undefined; membersLoading?: boolean | undefined; onSave: (input: PropertiesDetailSaveInput) => Promise; - /** - * When provided, the form offers a "Save & Close" button that submits the - * same validated input here; the caller navigates back to the properties - * list after a successful save. - */ onSaveAndClose?: ((input: PropertiesDetailSaveInput) => Promise) | undefined; onRemove: () => Promise; saving?: boolean | undefined; removing?: boolean | undefined; } -/** Seeds the shared property form from the detail fragment. */ -const toFormValues = (data: AdminPropertiesDetailContainerPropertyFieldsFragment): PropertyFormValues => ({ - propertyName: data.propertyName, - propertyType: data.propertyType ?? undefined, - ownerId: data.owner ? String(data.owner.id) : undefined, - listedForSale: data.listedForSale, - listedForRent: data.listedForRent, - listedForLease: data.listedForLease, - listedInDirectory: data.listedInDirectory, - tags: joinCommaList(data.tags), - location: { - address: { - streetNumber: data.location?.address?.streetNumber ?? undefined, - streetName: data.location?.address?.streetName ?? undefined, - municipality: data.location?.address?.municipality ?? undefined, - countrySubdivision: data.location?.address?.countrySubdivision ?? undefined, - postalCode: data.location?.address?.postalCode ?? undefined, - country: data.location?.address?.country ?? undefined, - }, - }, - listingDetail: { - price: data.listingDetail?.price ?? undefined, - rentHigh: data.listingDetail?.rentHigh ?? undefined, - rentLow: data.listingDetail?.rentLow ?? undefined, - lease: data.listingDetail?.lease ?? undefined, - maxGuests: data.listingDetail?.maxGuests ?? undefined, - bedrooms: data.listingDetail?.bedrooms ?? undefined, - bathrooms: data.listingDetail?.bathrooms ?? undefined, - squareFeet: data.listingDetail?.squareFeet ?? undefined, - yearBuilt: data.listingDetail?.yearBuilt ?? undefined, - lotSize: data.listingDetail?.lotSize ?? undefined, - description: data.listingDetail?.description ?? undefined, - amenities: joinCommaList(data.listingDetail?.amenities), - bedroomDetails: (data.listingDetail?.bedroomDetails ?? []).map((detail) => ({ - roomName: detail.roomName ?? undefined, - bedDescriptions: joinCommaList(detail.bedDescriptions), - })), - additionalAmenities: (data.listingDetail?.additionalAmenities ?? []).map((amenity) => ({ - category: amenity.category ?? undefined, - amenities: joinCommaList(amenity.amenities), - })), - images: joinCommaList(data.listingDetail?.images), - video: data.listingDetail?.video ?? undefined, - floorPlan: data.listingDetail?.floorPlan ?? undefined, - floorPlanImages: joinCommaList(data.listingDetail?.floorPlanImages), - listingAgent: data.listingDetail?.listingAgent ?? undefined, - listingAgentPhone: data.listingDetail?.listingAgentPhone ?? undefined, - listingAgentEmail: data.listingDetail?.listingAgentEmail ?? undefined, - listingAgentWebsite: data.listingDetail?.listingAgentWebsite ?? undefined, - listingAgentCompany: data.listingDetail?.listingAgentCompany ?? undefined, - listingAgentCompanyPhone: data.listingDetail?.listingAgentCompanyPhone ?? undefined, - listingAgentCompanyEmail: data.listingDetail?.listingAgentCompanyEmail ?? undefined, - listingAgentCompanyWebsite: data.listingDetail?.listingAgentCompanyWebsite ?? undefined, - listingAgentCompanyAddress: data.listingDetail?.listingAgentCompanyAddress ?? undefined, - }, +const toManagerSaveInput = (data: AdminPropertiesDetailContainerPropertyFieldsFragment, values: PropertyFormValues): PropertiesDetailSaveInput => ({ + propertyName: values.propertyName ?? data.propertyName, + ...toManagerPropertyInputFields(values), }); +/** + * Keeps manager-only deletion confirmation in the admin route while sharing + * route-neutral detail presentation and form behavior with the member route. + */ export const PropertiesDetail: React.FC = (props) => { const [removeModalOpen, setRemoveModalOpen] = useState(false); - const data = props.data; - const onSaveAndClose = props.onSaveAndClose; - - // The stored owner stays selectable even while the member list is loading. - const members: PropertyFormMemberOption[] = [...(props.members ?? [])]; - if (data.owner && !members.some((member) => member.id === String(data.owner?.id))) { - members.push({ id: String(data.owner.id), memberName: data.owner.memberName }); - } const handleConfirmRemove = async () => { await props.onRemove(); @@ -102,53 +38,33 @@ export const PropertiesDetail: React.FC = (props) => { }; return ( - -
- {data.propertyName} - -
- - {data.id} - {formatDate(data.createdAt)} - {formatDate(data.updatedAt)} - - - + { - void props.onSave({ - propertyName: values.propertyName ?? data.propertyName, - ...toPropertyInputFields(values), - }); + void props.onSave(toManagerSaveInput(props.data, values)); }} onSubmitAndClose={ - onSaveAndClose + props.onSaveAndClose ? (values) => { - void onSaveAndClose({ - propertyName: values.propertyName ?? data.propertyName, - ...toPropertyInputFields(values), - }); + void props.onSaveAndClose?.(toManagerSaveInput(props.data, values)); } : undefined } + actions={ + + } /> = (props) => { onCancel={() => setRemoveModalOpen(false)} onOk={() => void handleConfirmRemove()} > - {`This will remove the property "${data.propertyName}" from the community.`} + {`This will remove the property "${props.data.propertyName}" from the community.`} -
+ ); }; diff --git a/packages/ocom/ui-community-route-admin/src/components/properties-list.tsx b/packages/ocom/ui-community-route-admin/src/components/properties-list.tsx index 79394a22c..e14cc4fa7 100644 --- a/packages/ocom/ui-community-route-admin/src/components/properties-list.tsx +++ b/packages/ocom/ui-community-route-admin/src/components/properties-list.tsx @@ -1,122 +1,6 @@ -import type { TableColumnsType } from 'antd'; -import { Button, Space, Table, Typography } from 'antd'; -import type React from 'react'; -import type { AdminPropertiesListContainerPropertyFieldsFragment } from '../generated.tsx'; -import { formatDisplayAddress } from './format-display-address.ts'; +import { PropertyList, type PropertyListProps } from '@ocom/ui-community-shared'; -const { Title } = Typography; +/** Manager adapter retaining the established Property list component API. */ +export type PropertiesListProps = PropertyListProps; -export interface PropertiesListProps { - data: AdminPropertiesListContainerPropertyFieldsFragment[]; - onPropertyView?: (propertyId: string) => void; - loading?: boolean; -} - -const formatListingNumber = (value: number | null | undefined): string => { - if (value === null || value === undefined) { - return 'N/A'; - } - return String(value); -}; - -const formatPrice = (value: number | null | undefined): string => { - if (value === null || value === undefined) { - return 'N/A'; - } - return `$${value.toLocaleString('en-US')}`; -}; - -export const PropertiesList: React.FC = (props) => { - const { data, onPropertyView, loading } = props; - - const columns: TableColumnsType = [ - { - title: 'Property Name', - dataIndex: 'propertyName', - key: 'propertyName', - }, - { - title: 'Property Type', - dataIndex: 'propertyType', - key: 'propertyType', - render: (propertyType: string | null) => propertyType || 'N/A', - }, - { - title: 'Price', - dataIndex: ['listingDetail', 'price'], - key: 'price', - render: (price: number | null) => formatPrice(price), - }, - { - title: 'Bedrooms', - dataIndex: ['listingDetail', 'bedrooms'], - key: 'bedrooms', - render: (bedrooms: number | null) => formatListingNumber(bedrooms), - }, - { - title: 'Bathrooms', - dataIndex: ['listingDetail', 'bathrooms'], - key: 'bathrooms', - render: (bathrooms: number | null) => formatListingNumber(bathrooms), - }, - { - title: 'Square Feet', - dataIndex: ['listingDetail', 'squareFeet'], - key: 'squareFeet', - render: (squareFeet: number | null) => formatListingNumber(squareFeet), - }, - { - title: 'Address', - key: 'address', - render: (_text: unknown, record: AdminPropertiesListContainerPropertyFieldsFragment) => formatDisplayAddress(record.location?.address), - }, - { - title: 'Owner', - key: 'owner', - render: (_text: unknown, record: AdminPropertiesListContainerPropertyFieldsFragment) => record.owner?.memberName || 'N/A', - }, - { - title: 'Updated', - dataIndex: 'updatedAt', - key: 'updatedAt', - render: (date: string) => { - if (!date) return 'N/A'; - return new Date(date).toLocaleDateString(); - }, - }, - { - title: 'Action', - key: 'action', - render: (_text: unknown, record: AdminPropertiesListContainerPropertyFieldsFragment) => ( - - ), - }, - ]; - - return ( - - Community Properties ({data.length}) - `${range[0]}-${range[1]} of ${total} properties`, - }} - /> - - ); -}; +export const PropertiesList = PropertyList; diff --git a/packages/ocom/ui-community-route-admin/src/components/property-form.tsx b/packages/ocom/ui-community-route-admin/src/components/property-form.tsx index 5d0125fec..05aa0e234 100644 --- a/packages/ocom/ui-community-route-admin/src/components/property-form.tsx +++ b/packages/ocom/ui-community-route-admin/src/components/property-form.tsx @@ -1,956 +1,5 @@ -import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; -import { Button, Col, Form, Image, Input, InputNumber, Row, Select, Space, Switch, Typography } from 'antd'; -import type React from 'react'; -import { useRef } from 'react'; -import type { PropertyUpdateInput } from '../generated.tsx'; -import { COUNTRY_SELECT_OPTIONS, stateSelectOptionsForCountry } from './address-options.ts'; -import { commaListRule, emailRules, halfStepRangeRule, integerRangeRule, maxLengthRule } from './property-form.validation.ts'; - -const { Title } = Typography; - -/** Values of one bedroom detail row; bed descriptions are comma-separated. */ -interface PropertyFormBedroomDetailValues { - roomName?: string | null | undefined; - bedDescriptions?: string | null | undefined; -} - -/** Values of one additional amenity row; amenities are comma-separated. */ -interface PropertyFormAdditionalAmenityValues { - category?: string | null | undefined; - amenities?: string | null | undefined; -} - -/** - * Form-internal value shape of the shared property form. List-like fields - * (tags, amenities, images, ...) are edited as comma-separated strings and - * converted to arrays at the form boundary. - */ -export interface PropertyFormValues { - propertyName?: string | undefined; - propertyType?: string | null | undefined; - ownerId?: string | null | undefined; - listedForSale?: boolean | undefined; - listedForRent?: boolean | undefined; - listedForLease?: boolean | undefined; - listedInDirectory?: boolean | undefined; - tags?: string | null | undefined; - location?: { - address?: { - streetNumber?: string | null | undefined; - streetName?: string | null | undefined; - municipality?: string | null | undefined; - countrySubdivision?: string | null | undefined; - postalCode?: string | null | undefined; - country?: string | null | undefined; - }; - }; - listingDetail?: { - price?: number | null | undefined; - rentHigh?: number | null | undefined; - rentLow?: number | null | undefined; - lease?: number | null | undefined; - maxGuests?: number | null | undefined; - bedrooms?: number | null | undefined; - bathrooms?: number | null | undefined; - squareFeet?: number | null | undefined; - yearBuilt?: number | null | undefined; - lotSize?: number | null | undefined; - description?: string | null | undefined; - amenities?: string | null | undefined; - bedroomDetails?: PropertyFormBedroomDetailValues[] | undefined; - additionalAmenities?: PropertyFormAdditionalAmenityValues[] | undefined; - images?: string | null | undefined; - video?: string | null | undefined; - floorPlan?: string | null | undefined; - floorPlanImages?: string | null | undefined; - listingAgent?: string | null | undefined; - listingAgentPhone?: string | null | undefined; - listingAgentEmail?: string | null | undefined; - listingAgentWebsite?: string | null | undefined; - listingAgentCompany?: string | null | undefined; - listingAgentCompanyPhone?: string | null | undefined; - listingAgentCompanyEmail?: string | null | undefined; - listingAgentCompanyWebsite?: string | null | undefined; - listingAgentCompanyAddress?: string | null | undefined; - }; -} - -/** Member option offered by the Owner select. */ -export interface PropertyFormMemberOption { - id: string; - memberName?: string | null | undefined; -} - -interface PropertyFormProps { - initialValues?: PropertyFormValues | undefined; - members?: PropertyFormMemberOption[] | undefined; - membersLoading?: boolean | undefined; - submitLabel: string; - submitting?: boolean | undefined; - onSubmit: (values: PropertyFormValues) => void; - /** - * When provided, a second "Save & Close" submit button is rendered that - * runs the same form validation and routes successfully validated values - * here instead of {@link PropertyFormProps.onSubmit}. - */ - onSubmitAndClose?: ((values: PropertyFormValues) => void) | undefined; -} - -/** Splits a comma-separated string into trimmed, non-empty entries. */ -const splitCommaList = (value?: string | null): string[] => - (value ?? '') - .split(',') - .map((part) => part.trim()) - .filter((part) => part.length > 0); - -/** Joins list entries into a comma-separated display string, dropping blanks. */ -export const joinCommaList = (values?: readonly (string | null | undefined)[] | null): string => (values ?? []).filter((value): value is string => typeof value === 'string' && value.trim().length > 0).join(', '); - -const trimmedOrNull = (value?: string | null): string | null => { - const trimmed = (value ?? '').trim(); - return trimmed.length > 0 ? trimmed : null; -}; - -/** - * Comma-list fields always submit an array — an emptied field submits `[]` so - * the backend clears the stored list instead of treating it as omitted. - */ -const listOrEmpty = (value?: string | null): string[] => splitCommaList(value); - -/** Property input fields shared by the create and update mutations. */ -type PropertyFormInputFields = Omit; - -/** - * Converts submitted form values into the shared create/update input fields. - * Emptied text fields are sent as explicit nulls so the backend clears them; - * comma-separated fields become arrays, sent as `[]` when emptied so the - * backend clears the stored list. - */ -export const toPropertyInputFields = (values: PropertyFormValues): PropertyFormInputFields => ({ - propertyType: trimmedOrNull(values.propertyType), - ownerId: values.ownerId ?? null, - listedForSale: values.listedForSale ?? false, - listedForRent: values.listedForRent ?? false, - listedForLease: values.listedForLease ?? false, - listedInDirectory: values.listedInDirectory ?? false, - tags: listOrEmpty(values.tags), - location: { - address: { - streetNumber: trimmedOrNull(values.location?.address?.streetNumber), - streetName: trimmedOrNull(values.location?.address?.streetName), - municipality: trimmedOrNull(values.location?.address?.municipality), - countrySubdivision: trimmedOrNull(values.location?.address?.countrySubdivision), - postalCode: trimmedOrNull(values.location?.address?.postalCode), - country: trimmedOrNull(values.location?.address?.country), - }, - }, - listingDetail: { - price: values.listingDetail?.price ?? null, - rentHigh: values.listingDetail?.rentHigh ?? null, - rentLow: values.listingDetail?.rentLow ?? null, - lease: values.listingDetail?.lease ?? null, - maxGuests: values.listingDetail?.maxGuests ?? null, - bedrooms: values.listingDetail?.bedrooms ?? null, - bathrooms: values.listingDetail?.bathrooms ?? null, - squareFeet: values.listingDetail?.squareFeet ?? null, - yearBuilt: values.listingDetail?.yearBuilt ?? null, - lotSize: values.listingDetail?.lotSize ?? null, - description: trimmedOrNull(values.listingDetail?.description), - amenities: listOrEmpty(values.listingDetail?.amenities), - bedroomDetails: (values.listingDetail?.bedroomDetails ?? []).map((row) => ({ - roomName: trimmedOrNull(row.roomName), - bedDescriptions: listOrEmpty(row.bedDescriptions), - })), - additionalAmenities: (values.listingDetail?.additionalAmenities ?? []).map((row) => ({ - category: trimmedOrNull(row.category), - amenities: listOrEmpty(row.amenities), - })), - images: listOrEmpty(values.listingDetail?.images), - video: trimmedOrNull(values.listingDetail?.video), - floorPlan: trimmedOrNull(values.listingDetail?.floorPlan), - floorPlanImages: listOrEmpty(values.listingDetail?.floorPlanImages), - listingAgent: trimmedOrNull(values.listingDetail?.listingAgent), - listingAgentPhone: trimmedOrNull(values.listingDetail?.listingAgentPhone), - listingAgentEmail: trimmedOrNull(values.listingDetail?.listingAgentEmail), - listingAgentWebsite: trimmedOrNull(values.listingDetail?.listingAgentWebsite), - listingAgentCompany: trimmedOrNull(values.listingDetail?.listingAgentCompany), - listingAgentCompanyPhone: trimmedOrNull(values.listingDetail?.listingAgentCompanyPhone), - listingAgentCompanyEmail: trimmedOrNull(values.listingDetail?.listingAgentCompanyEmail), - listingAgentCompanyWebsite: trimmedOrNull(values.listingDetail?.listingAgentCompanyWebsite), - listingAgentCompanyAddress: trimmedOrNull(values.listingDetail?.listingAgentCompanyAddress), - }, -}); - -const numberFieldProps = { min: 0, style: { width: '100%' } } as const; - -interface UrlGalleryProps { - urlsText?: string | undefined; -} - -/** Inline gallery preview for a comma-separated list of image URLs. */ -const UrlGallery: React.FC = (props) => { - const urls = splitCommaList(props.urlsText); - if (urls.length === 0) { - return null; - } - return ( - - - {urls.map((url) => ( - - ))} - - - ); -}; - -interface UrlLinkPreviewProps { - urlText?: string | undefined; -} - -/** Inline link preview for a single URL field. */ -const UrlLinkPreview: React.FC = (props) => { - const url = (props.urlText ?? '').trim(); - if (url.length === 0) { - return null; - } - // Only linkify http(s) URLs; other schemes (javascript:, data:, ...) render - // as inert text so a stored value can never execute in a viewer's browser. - if (!/^https?:\/\//i.test(url)) { - return {url}; - } - return ( - - {url} - - ); -}; - -/** - * Shared property form used by both the create and detail admin screens. It - * renders the full property field set in Overview, Location, and Listing - * sections; the containers convert between GraphQL data/input shapes and - * {@link PropertyFormValues}. - */ -export const PropertyForm: React.FC = (props) => { - const [form] = Form.useForm(); - // Which submit button triggered the pending submission; both buttons share - // the same validation and scroll-to-error behavior via htmlType="submit". - const submitIntentRef = useRef<'save' | 'saveAndClose'>('save'); - const imagesText = Form.useWatch(['listingDetail', 'images'], form); - const videoText = Form.useWatch(['listingDetail', 'video'], form); - const floorPlanText = Form.useWatch(['listingDetail', 'floorPlan'], form); - const floorPlanImagesText = Form.useWatch(['listingDetail', 'floorPlanImages'], form); - // Country drives the state/province cascade: countries with a subdivision - // list (US/Canada) get a Select, everything else a free-text Input. - const selectedCountry = Form.useWatch(['location', 'address', 'country'], form); - const stateOptions = stateSelectOptionsForCountry(selectedCountry); - - const ownerOptions = (props.members ?? []).map((member) => { - const memberName = member.memberName ?? member.id; - return { value: member.id, label: memberName, title: memberName }; - }); - - return ( -
{ - const intent = submitIntentRef.current; - // Consume the intent so a later Enter-key submit (which clicks no - // button) defaults to a plain save rather than replaying Save & Close. - submitIntentRef.current = 'save'; - if (intent === 'saveAndClose' && props.onSubmitAndClose) { - props.onSubmitAndClose(values); - return; - } - props.onSubmit(values); - }} - onFinishFailed={() => { - // Failed validation must not leave a stale Save & Close intent - // behind; after corrections, Enter-key submits stay on the page. - submitIntentRef.current = 'save'; - }} - > - Overview - -
- - - - - - - - - - - - - - - - - Location - - - - - - - - - - - - - - - - - - - - - {stateOptions ? ( - - )} - - - - - - - - - - - - - - - - - - - - - - )} - - - Amenities - - - - Additional Amenities - - {(fields, { add, remove }) => ( - <> - {fields.map(({ key, name }) => ( - - - - - - - - - - - - - - - - )} - - - Media - - - - - - - - - - - - - - - - - - Agent - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {props.onSubmitAndClose ? ( - - ) : null} - - - ); -}; +export { + PropertyForm, + type PropertyFormMemberOption, + type PropertyFormValues, +} from '@ocom/ui-community-shared'; diff --git a/packages/ocom/ui-community-route-admin/src/sub-page-layout.tsx b/packages/ocom/ui-community-route-admin/src/sub-page-layout.tsx index 6ef63e93f..a2fa602d9 100644 --- a/packages/ocom/ui-community-route-admin/src/sub-page-layout.tsx +++ b/packages/ocom/ui-community-route-admin/src/sub-page-layout.tsx @@ -1,50 +1 @@ -import { Layout, theme } from 'antd'; -import type React from 'react'; - -const { Header, Content, Footer } = Layout; - -interface SubPageLayoutProps { - header: React.JSX.Element; - fixedHeader?: boolean; - children?: React.ReactNode; -} - -export const SubPageLayout: React.FC = (props) => { - const { - token: { colorText, colorBgContainer }, - } = theme.useToken(); - const overFlow = props.fixedHeader ? 'scroll' : 'unset'; - return ( - <> -
- {props.header} -
-
- -
{props.children}
-
-
- Owner Community -
-
- - ); -}; +export { SubPageLayout } from '@ocom/ui-community-shared'; diff --git a/packages/ocom/ui-community-route-member/.storybook/main.ts b/packages/ocom/ui-community-route-member/.storybook/main.ts new file mode 100644 index 000000000..ae29a74cb --- /dev/null +++ b/packages/ocom/ui-community-route-member/.storybook/main.ts @@ -0,0 +1,20 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import type { StorybookConfig } from '@storybook/react-vite'; + +const require = createRequire(import.meta.url); + +function getAbsolutePath(value: string) { + return dirname(require.resolve(join(value, 'package.json'))); +} + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [getAbsolutePath('@chromatic-com/storybook'), getAbsolutePath('@storybook/addon-docs'), getAbsolutePath('@storybook/addon-a11y'), getAbsolutePath('@storybook/addon-vitest')], + framework: { + name: getAbsolutePath('@storybook/react-vite'), + options: {}, + }, +}; + +export default config; diff --git a/packages/ocom/ui-community-route-member/.storybook/preview.tsx b/packages/ocom/ui-community-route-member/.storybook/preview.tsx new file mode 100644 index 000000000..cba5a11c0 --- /dev/null +++ b/packages/ocom/ui-community-route-member/.storybook/preview.tsx @@ -0,0 +1,17 @@ +import type { Preview } from '@storybook/react'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + a11y: { + test: 'todo', + }, + }, +}; + +export default preview; diff --git a/packages/ocom/ui-community-route-member/package.json b/packages/ocom/ui-community-route-member/package.json new file mode 100644 index 000000000..f3c278e5f --- /dev/null +++ b/packages/ocom/ui-community-route-member/package.json @@ -0,0 +1,54 @@ +{ + "name": "@ocom/ui-community-route-member", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.tsx" + }, + "scripts": { + "prebuild": "pnpm run lint", + "build": "tsgo --noEmit", + "lint": "biome lint", + "test": "vitest run --silent --reporter=dot", + "test:coverage": "vitest run --coverage --silent --reporter=dot", + "test:watch": "vitest", + "test:arch": "vitest run --config vitest.arch.config.ts", + "storybook": "storybook dev -p 6008", + "build-storybook": "storybook build" + }, + "dependencies": { + "@ant-design/icons": "catalog:", + "@ant-design/pro-layout": "^7.22.7", + "@apollo/client": "^3.13.9", + "@cellix/ui-core": "workspace:*", + "@dr.pogodin/react-helmet": "^3.0.2", + "@graphql-typed-document-node/core": "^3.2.0", + "@ocom/ui-community-shared": "workspace:*", + "@ocom/ui-shared": "workspace:*", + "antd": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-router-dom": "catalog:" + }, + "devDependencies": { + "@cellix/archunit-tests": "workspace:*", + "@cellix/config-typescript": "workspace:*", + "@cellix/config-vitest": "workspace:*", + "@chromatic-com/storybook": "^5.2.1", + "@ocom-verification/archunit-tests": "workspace:*", + "@storybook/addon-a11y": "catalog:", + "@storybook/addon-docs": "catalog:", + "@storybook/addon-vitest": "catalog:", + "@storybook/react-vite": "catalog:", + "@storybook/react": "^10.4.2", + "@types/react": "^19.1.11", + "@types/react-dom": "^19.1.6", + "graphql": "catalog:", + "jsdom": "catalog:", + "storybook": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/ocom/ui-community-route-member/src/archunit-tests/frontend-architecture.test.ts b/packages/ocom/ui-community-route-member/src/archunit-tests/frontend-architecture.test.ts new file mode 100644 index 000000000..ffb5aca56 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/archunit-tests/frontend-architecture.test.ts @@ -0,0 +1,18 @@ +import { describeFrontendArchitectureTests, type FrontendArchitectureTestsConfig } from '@cellix/archunit-tests/frontend'; +import { describeFrontendArchitectureTests as describeOComFrontendArchitectureTests, type FrontendArchitectureTestsConfig as OComFrontendArchitectureTestsConfig } from '@ocom-verification/archunit-tests/frontend'; + +const cellixConfig: FrontendArchitectureTestsConfig = { + uiSourcePath: './src', + testName: 'UI Community Route Member', + requiredTopLevelDirectories: ['components'], + requiredComponentDirectories: [], + allowedLegacyDirectories: ['pages'], +}; + +const ocomConfig: OComFrontendArchitectureTestsConfig = { + uiSourcePath: './src', + testName: 'UI Community Route Member', +}; + +describeFrontendArchitectureTests(cellixConfig); +describeOComFrontendArchitectureTests(ocomConfig); diff --git a/packages/ocom/ui-community-route-member/src/archunit-tests/naming-conventions.test.ts b/packages/ocom/ui-community-route-member/src/archunit-tests/naming-conventions.test.ts new file mode 100644 index 000000000..d01580f4f --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/archunit-tests/naming-conventions.test.ts @@ -0,0 +1,5 @@ +import { describeNamingConventionTests } from '@cellix/archunit-tests/general'; +import { describeNamingConventionTests as describeOComNamingConventionTests } from '@ocom-verification/archunit-tests/frontend'; + +describeNamingConventionTests(); +describeOComNamingConventionTests(); diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-access-policy.test.ts b/packages/ocom/ui-community-route-member/src/components/member-properties-access-policy.test.ts new file mode 100644 index 000000000..c61ffe360 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-access-policy.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { type MemberPropertiesRouteMembership, resolveMemberPropertiesAccess } from './member-properties-access-policy.ts'; + +const communityId = 'community-id'; +const memberId = 'member-id'; +const userId = 'user-id'; + +const membership = (overrides: Partial = {}): MemberPropertiesRouteMembership => ({ + id: memberId, + community: { id: communityId }, + accounts: [{ statusCode: 'ACCEPTED', user: { id: userId } }], + role: { permissions: { propertyPermissions: { canManageProperties: false, canEditOwnProperty: true } } }, + ...overrides, +}); + +describe('resolveMemberPropertiesAccess', () => { + it('allows an accepted same-community own-property member', () => { + expect(resolveMemberPropertiesAccess([membership()], userId, communityId, memberId)).toBe('allowed'); + }); + + it('redirects an accepted same-community property manager', () => { + expect(resolveMemberPropertiesAccess([membership({ role: { permissions: { propertyPermissions: { canManageProperties: true, canEditOwnProperty: true } } } })], userId, communityId, memberId)).toBe('manager-redirect'); + }); + + it.each([ + ['missing own-property permission', membership({ role: { permissions: { propertyPermissions: { canManageProperties: false, canEditOwnProperty: false } } } }), communityId, memberId], + ['inactive account', membership({ accounts: [{ statusCode: 'CREATED', user: { id: userId } }] }), communityId, memberId], + ['mismatched route member', membership(), communityId, 'another-member'], + ['mismatched route community', membership(), 'another-community', memberId], + ])('denies %s', (_description, currentMembership, currentCommunityId, currentMemberId) => { + expect(resolveMemberPropertiesAccess([currentMembership], userId, currentCommunityId, currentMemberId)).toBe('denied'); + }); +}); diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-access-policy.ts b/packages/ocom/ui-community-route-member/src/components/member-properties-access-policy.ts new file mode 100644 index 000000000..b0e27848e --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-access-policy.ts @@ -0,0 +1,56 @@ +import { hasAcceptedAccountForUser } from '@ocom/ui-shared'; + +export interface MemberPropertiesRouteMembership { + id: string; + accounts?: + | ReadonlyArray<{ + statusCode?: string | null | undefined; + user?: { id?: string | null | undefined } | null | undefined; + } | null> + | null + | undefined; + role?: + | { + permissions?: + | { + propertyPermissions?: + | { + canManageProperties?: boolean | null | undefined; + canEditOwnProperty?: boolean | null | undefined; + } + | null + | undefined; + } + | null + | undefined; + } + | null + | undefined; + community?: { id?: string | null | undefined } | null | undefined; +} + +type MemberPropertiesAccessDecision = 'allowed' | 'manager-redirect' | 'denied'; + +/** + * Resolves member Property route access from an already authenticated user's + * memberships. Invalid identities and stale/inactive accounts fail closed. + */ +export const resolveMemberPropertiesAccess = ( + memberships: readonly MemberPropertiesRouteMembership[] | null | undefined, + currentEndUserId: string | null | undefined, + communityId: string | undefined, + memberId: string | undefined, +): MemberPropertiesAccessDecision => { + if (!communityId || !memberId) { + return 'denied'; + } + const currentMember = memberships?.find((member) => String(member.id) === memberId && String(member.community?.id) === communityId); + if (!currentMember || !hasAcceptedAccountForUser(currentMember.accounts, currentEndUserId)) { + return 'denied'; + } + const permissions = currentMember.role?.permissions?.propertyPermissions; + if (permissions?.canManageProperties) { + return 'manager-redirect'; + } + return permissions?.canEditOwnProperty ? 'allowed' : 'denied'; +}; diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-access.context.ts b/packages/ocom/ui-community-route-member/src/components/member-properties-access.context.ts new file mode 100644 index 000000000..b62f16520 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-access.context.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; + +interface MemberPropertiesAccess { + communityId: string; + memberId: string; + /** Rechecks authorization after a stale mutation failure. */ + revalidate: () => Promise; +} + +const MemberPropertiesAccessContext = createContext(undefined); + +export const MemberPropertiesAccessProvider = MemberPropertiesAccessContext.Provider; + +export const useMemberPropertiesAccess = (): MemberPropertiesAccess => { + const access = useContext(MemberPropertiesAccessContext); + if (!access) { + throw new Error('Member Property route access must be established before rendering Property content.'); + } + return access; +}; diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-create.container.graphql b/packages/ocom/ui-community-route-member/src/components/member-properties-create.container.graphql new file mode 100644 index 000000000..64590d66c --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-create.container.graphql @@ -0,0 +1,15 @@ +mutation MemberPropertyCreate($input: PropertyCreateInput!) { + propertyCreate(input: $input) { + status { + success + errorMessage + } + property { + id + propertyName + owner { + id + } + } + } +} diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-create.container.stories.tsx b/packages/ocom/ui-community-route-member/src/components/member-properties-create.container.stories.tsx new file mode 100644 index 000000000..4c7c795ca --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-create.container.stories.tsx @@ -0,0 +1,171 @@ +import { MockedProvider } from '@apollo/client/testing'; +import type { MemberPropertyCreateInput } from '@ocom/ui-community-shared'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { App, ConfigProvider } from 'antd'; +import type React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { expect, userEvent, within } from 'storybook/test'; +import { MemberPropertiesListDocument, MemberPropertyCreateDocument } from '../generated.tsx'; +import { MemberPropertiesAccessProvider } from './member-properties-access.context.ts'; +import { MemberPropertiesCreateContainer } from './member-properties-create.container.tsx'; + +const communityId = '65f1f77bcf86cd7994390001'; +const memberId = '65f1f77bcf86cd7994390002'; + +const emptyMemberCreateInput = (propertyName: string): MemberPropertyCreateInput => ({ + propertyName, + listedForSale: false, + listedForRent: false, + listedForLease: false, + listedInDirectory: false, + tags: [], + location: { + address: { + streetNumber: null, + streetName: null, + municipality: null, + countrySubdivision: null, + postalCode: null, + country: null, + }, + }, + listingDetail: { + price: null, + rentHigh: null, + rentLow: null, + lease: null, + maxGuests: null, + bedrooms: null, + bathrooms: null, + squareFeet: null, + yearBuilt: null, + lotSize: null, + description: null, + amenities: [], + bedroomDetails: [], + additionalAmenities: [], + images: [], + video: null, + floorPlan: null, + floorPlanImages: [], + listingAgent: null, + listingAgentPhone: null, + listingAgentEmail: null, + listingAgentWebsite: null, + listingAgentCompany: null, + listingAgentCompanyPhone: null, + listingAgentCompanyEmail: null, + listingAgentCompanyWebsite: null, + listingAgentCompanyAddress: null, + }, +}); + +const listRefetchMock = { + request: { query: MemberPropertiesListDocument, variables: { communityId } }, + result: { data: { propertiesByCommunityId: [] } }, +}; + +const decoratorFor = (mocks: Parameters[0]['mocks']) => (Story: React.ComponentType) => ( + + + + + undefined }}> + + + Properties List Route} + /> + } + /> + + + + + + + +); + +const meta = { + title: 'Components/Member/MemberPropertiesCreateContainer', + component: MemberPropertiesCreateContainer, + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** The member create form deliberately excludes manager-owned type and owner controls. */ +export const Default: Story = { + decorators: [decoratorFor([])], + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByLabelText('Property Name')).toBeInTheDocument(); + expect(canvas.getByRole('button', { name: /create property/i })).toBeInTheDocument(); + expect(canvas.queryByLabelText('Property Type')).not.toBeInTheDocument(); + expect(canvas.queryByLabelText('Owner')).not.toBeInTheDocument(); + }, +}; + +export const Success: Story = { + decorators: [ + decoratorFor([ + { + request: { query: MemberPropertyCreateDocument, variables: { input: emptyMemberCreateInput('Member Cottage') } }, + result: { + data: { + propertyCreate: { + __typename: 'PropertyMutationResult', + status: { __typename: 'MutationStatus', success: true, errorMessage: null }, + property: { + __typename: 'Property', + id: '65f1f77bcf86cd7994390110', + propertyName: 'Member Cottage', + owner: { __typename: 'PropertyOwnerOption', id: memberId }, + }, + }, + }, + }, + }, + listRefetchMock, + ]), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByLabelText('Property Name'), 'Member Cottage'); + await userEvent.click(canvas.getByRole('button', { name: /create property/i })); + expect(await canvas.findByText('Properties List Route')).toBeInTheDocument(); + }, +}; + +/** Mutation errors remain in the member form and display feedback without exposing manager controls. */ +export const MutationError: Story = { + decorators: [ + decoratorFor([ + { + request: { query: MemberPropertyCreateDocument, variables: { input: emptyMemberCreateInput('Duplicate Cottage') } }, + result: { + data: { + propertyCreate: { + __typename: 'PropertyMutationResult', + status: { __typename: 'MutationStatus', success: false, errorMessage: 'Property name already in use' }, + property: null, + }, + }, + }, + }, + ]), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByLabelText('Property Name'), 'Duplicate Cottage'); + await userEvent.click(canvas.getByRole('button', { name: /create property/i })); + const body = within(canvasElement.ownerDocument.body); + expect(await body.findByText('Property name already in use')).toBeInTheDocument(); + expect(canvas.queryByLabelText('Owner')).not.toBeInTheDocument(); + }, +}; diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-create.container.tsx b/packages/ocom/ui-community-route-member/src/components/member-properties-create.container.tsx new file mode 100644 index 000000000..0e8c66bff --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-create.container.tsx @@ -0,0 +1,36 @@ +import { useMutation } from '@apollo/client'; +import { App } from 'antd'; +import type React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { MemberPropertiesListDocument, MemberPropertyCreateDocument } from '../generated.tsx'; +import { useMemberPropertiesAccess } from './member-properties-access.context.ts'; +import { MemberPropertiesCreate } from './member-properties-create.tsx'; + +export const MemberPropertiesCreateContainer: React.FC = () => { + const navigate = useNavigate(); + const { communityId, revalidate } = useMemberPropertiesAccess(); + const { message } = App.useApp(); + const [propertyCreate, { loading }] = useMutation(MemberPropertyCreateDocument, { + refetchQueries: [{ query: MemberPropertiesListDocument, variables: { communityId } }], + }); + + return ( + { + try { + const result = await propertyCreate({ variables: { input } }); + if (result.data?.propertyCreate.status.success) { + message.success('Property Created'); + navigate('..', { replace: true }); + return; + } + message.error(result.data?.propertyCreate.status.errorMessage || 'Failed to create property'); + } catch (error) { + message.error(error instanceof Error ? error.message : 'An error occurred while creating property'); + } + void revalidate(); + }} + /> + ); +}; diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-create.tsx b/packages/ocom/ui-community-route-member/src/components/member-properties-create.tsx new file mode 100644 index 000000000..87e5a8942 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-create.tsx @@ -0,0 +1,17 @@ +import { type MemberPropertyCreateInput, PROPERTY_EDIT_POLICIES, PropertyForm, type PropertyFormValues, toMemberPropertyCreateInput } from '@ocom/ui-community-shared'; +import type React from 'react'; + +interface MemberPropertiesCreateProps { + submitting?: boolean | undefined; + onSave: (input: MemberPropertyCreateInput) => void; +} + +/** Member create form contains only the server-safe self-owned input fields. */ +export const MemberPropertiesCreate: React.FC = (props) => ( + props.onSave(toMemberPropertyCreateInput(values))} + /> +); diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-detail.container.graphql b/packages/ocom/ui-community-route-member/src/components/member-properties-detail.container.graphql new file mode 100644 index 000000000..45ccf3c02 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-detail.container.graphql @@ -0,0 +1,80 @@ +query MemberPropertyDetail($id: ObjectID!) { + property(id: $id) { + ...MemberPropertyDetailFields + } +} + +mutation MemberPropertyUpdate($input: PropertyUpdateInput!) { + propertyUpdate(input: $input) { + status { + success + errorMessage + } + property { + ...MemberPropertyDetailFields + } + } +} + +fragment MemberPropertyDetailFields on Property { + id + propertyName + propertyType + listedForSale + listedForRent + listedForLease + listedInDirectory + tags + owner { + id + } + location { + address { + streetNumber + streetName + municipality + countrySubdivision + postalCode + country + } + } + listingDetail { + price + rentHigh + rentLow + lease + maxGuests + bedrooms + bathrooms + squareFeet + yearBuilt + lotSize + description + amenities + bedroomDetails { + id + roomName + bedDescriptions + } + additionalAmenities { + id + category + amenities + } + images + video + floorPlan + floorPlanImages + listingAgent + listingAgentPhone + listingAgentEmail + listingAgentWebsite + listingAgentCompany + listingAgentCompanyPhone + listingAgentCompanyEmail + listingAgentCompanyWebsite + listingAgentCompanyAddress + } + createdAt + updatedAt +} diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-detail.container.tsx b/packages/ocom/ui-community-route-member/src/components/member-properties-detail.container.tsx new file mode 100644 index 000000000..d0f43f9ef --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-detail.container.tsx @@ -0,0 +1,79 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import { App, Result } from 'antd'; +import type React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { MemberPropertiesListDocument, MemberPropertyDetailDocument, MemberPropertyUpdateDocument } from '../generated.tsx'; +import { useMemberPropertiesAccess } from './member-properties-access.context.ts'; +import { MemberPropertiesDetail } from './member-properties-detail.tsx'; + +interface MemberPropertiesDetailContainerProps { + id: string; +} + +const notFound = ( + +); + +export const MemberPropertiesDetailContainer: React.FC = ({ id }) => { + const navigate = useNavigate(); + const { communityId, memberId, revalidate } = useMemberPropertiesAccess(); + const { message } = App.useApp(); + const { data, loading, error } = useQuery(MemberPropertyDetailDocument, { + variables: { id }, + skip: !id, + fetchPolicy: 'network-only', + }); + const [propertyUpdate, { loading: saving }] = useMutation(MemberPropertyUpdateDocument, { + refetchQueries: [{ query: MemberPropertiesListDocument, variables: { communityId } }], + }); + + const saveProperty = async (input: Parameters['onSave']>[0]): Promise => { + try { + const result = await propertyUpdate({ variables: { input } }); + if (result.data?.propertyUpdate.status.success) { + message.success('Saved'); + return true; + } + message.error(result.data?.propertyUpdate.status.errorMessage || 'Failed to update property'); + } catch (mutationError) { + message.error(mutationError instanceof Error ? mutationError.message : 'An error occurred while updating property'); + } + void revalidate(); + return false; + }; + + const property = data?.property; + return ( + { + if (await saveProperty(input)) { + navigate('..'); + return true; + } + return false; + }} + /> + ) : ( + notFound + ) + } + noDataComponent={notFound} + error={error} + /> + ); +}; diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-detail.stories.tsx b/packages/ocom/ui-community-route-member/src/components/member-properties-detail.stories.tsx new file mode 100644 index 000000000..e29dff74e --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-detail.stories.tsx @@ -0,0 +1,73 @@ +import { MockedProvider } from '@apollo/client/testing'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { App, ConfigProvider } from 'antd'; +import type React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { MemberPropertyDetailDocument } from '../generated.tsx'; +import { MemberPropertiesAccessProvider } from './member-properties-access.context.ts'; +import { MemberPropertiesDetailContainer } from './member-properties-detail.container.tsx'; + +const communityId = '65f1f77bcf86cd7994390001'; +const ownMemberId = '65f1f77bcf86cd7994390002'; +const propertyId = '65f1f77bcf86cd7994390101'; + +const property = (ownerId: string) => ({ + __typename: 'Property' as const, + id: propertyId, + propertyName: 'Harborview Unit 205', + propertyType: 'condo', + listedForSale: true, + listedForRent: false, + listedForLease: false, + listedInDirectory: true, + tags: ['waterfront'], + owner: { __typename: 'PropertyOwnerOption' as const, id: ownerId }, + location: null, + listingDetail: null, + createdAt: null, + updatedAt: null, +}); + +const detailMock = (result: unknown) => ({ + request: { query: MemberPropertyDetailDocument, variables: { id: propertyId } }, + result, +}); + +const decoratorFor = (mocks: ReturnType[]) => (Story: React.ComponentType) => ( + + + + + undefined }}> + + + + + + +); + +const meta = { + title: 'Components/Member/MemberPropertiesDetailContainer', + component: MemberPropertiesDetailContainer, + args: { id: propertyId }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const OwnedEdit: Story = { + decorators: [decoratorFor([detailMock({ data: { property: property(ownMemberId) } })])], +}; + +export const ForeignReadOnly: Story = { + decorators: [decoratorFor([detailMock({ data: { property: property('65f1f77bcf86cd7994390003') } })])], +}; + +export const NotFound: Story = { + decorators: [decoratorFor([detailMock({ data: { property: null } })])], +}; + +export const QueryError: Story = { + decorators: [decoratorFor([{ request: { query: MemberPropertyDetailDocument, variables: { id: propertyId } }, error: new Error('Unable to load property') }])], +}; diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-detail.tsx b/packages/ocom/ui-community-route-member/src/components/member-properties-detail.tsx new file mode 100644 index 000000000..a2bcf403d --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-detail.tsx @@ -0,0 +1,45 @@ +import { PROPERTY_EDIT_POLICIES, PropertyDetails, type PropertyFormValues, type PropertyRecord, toMemberPropertyUpdateInput } from '@ocom/ui-community-shared'; +import type React from 'react'; + +interface MemberPropertiesDetailProps { + data: PropertyRecord; + memberId: string; + saving?: boolean | undefined; + onSave: (input: ReturnType) => Promise; + onSaveAndClose?: ((input: ReturnType) => Promise) | undefined; +} + +/** + * An owner receives the listing-only editor. A foreign or unowned property + * receives a separate read-only presentation without owner identity. + */ +export const MemberPropertiesDetail: React.FC = (props) => { + const isOwnedByCurrentMember = props.data.owner?.id === props.memberId; + const editPolicy = isOwnedByCurrentMember ? PROPERTY_EDIT_POLICIES.memberEdit : PROPERTY_EDIT_POLICIES.readOnly; + + const submit = (values: PropertyFormValues): Promise => props.onSave(toMemberPropertyUpdateInput(props.data.id, values)); + const submitAndClose = props.onSaveAndClose ? (values: PropertyFormValues): Promise => props.onSaveAndClose?.(toMemberPropertyUpdateInput(props.data.id, values)) ?? Promise.resolve(false) : undefined; + + return ( + { + void submit(values); + } + : undefined + } + onSubmitAndClose={ + isOwnedByCurrentMember && submitAndClose + ? (values) => { + void submitAndClose(values); + } + : undefined + } + /> + ); +}; diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-list.container.graphql b/packages/ocom/ui-community-route-member/src/components/member-properties-list.container.graphql new file mode 100644 index 000000000..886644b23 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-list.container.graphql @@ -0,0 +1,28 @@ +query MemberPropertiesList($communityId: ObjectID!) { + propertiesByCommunityId(communityId: $communityId) { + ...MemberPropertiesListPropertyFields + } +} + +fragment MemberPropertiesListPropertyFields on Property { + id + propertyName + propertyType + listingDetail { + price + bedrooms + bathrooms + squareFeet + } + location { + address { + streetNumber + streetName + municipality + countrySubdivision + postalCode + country + } + } + updatedAt +} diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-list.container.tsx b/packages/ocom/ui-community-route-member/src/components/member-properties-list.container.tsx new file mode 100644 index 000000000..0c751b0ed --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-list.container.tsx @@ -0,0 +1,30 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import type React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { MemberPropertiesListDocument } from '../generated.tsx'; +import { useMemberPropertiesAccess } from './member-properties-access.context.ts'; +import { MemberPropertiesList } from './member-properties-list.tsx'; + +export const MemberPropertiesListContainer: React.FC = () => { + const navigate = useNavigate(); + const { communityId } = useMemberPropertiesAccess(); + const { data, loading, error } = useQuery(MemberPropertiesListDocument, { + variables: { communityId }, + skip: !communityId, + }); + + return ( + navigate(propertyId)} + /> + } + error={error} + /> + ); +}; diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-list.stories.tsx b/packages/ocom/ui-community-route-member/src/components/member-properties-list.stories.tsx new file mode 100644 index 000000000..9fc06f219 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-list.stories.tsx @@ -0,0 +1,64 @@ +import { MockedProvider } from '@apollo/client/testing'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { App, ConfigProvider } from 'antd'; +import type React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { MemberPropertiesListDocument } from '../generated.tsx'; +import { MemberPropertiesAccessProvider } from './member-properties-access.context.ts'; +import { MemberPropertiesListContainer } from './member-properties-list.container.tsx'; + +const communityId = '65f1f77bcf86cd7994390001'; +const memberId = '65f1f77bcf86cd7994390002'; + +const listMock = (propertiesByCommunityId: unknown[]) => ({ + request: { query: MemberPropertiesListDocument, variables: { communityId } }, + result: { data: { propertiesByCommunityId } }, +}); + +const decoratorFor = (mocks: ReturnType[]) => (Story: React.ComponentType) => ( + + + + + undefined }}> + + + + + + +); + +const meta = { + title: 'Components/Member/MemberPropertiesListContainer', + component: MemberPropertiesListContainer, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Allowed: Story = { + decorators: [ + decoratorFor([ + listMock([ + { + __typename: 'Property', + id: '65f1f77bcf86cd7994390101', + propertyName: 'Neighbor Cottage', + propertyType: 'condo', + listingDetail: null, + location: null, + updatedAt: null, + }, + ]), + ]), + ], +}; + +export const Empty: Story = { + decorators: [decoratorFor([listMock([])])], +}; + +export const QueryError: Story = { + decorators: [decoratorFor([{ request: { query: MemberPropertiesListDocument, variables: { communityId } }, error: new Error('Unable to load properties') }])], +}; diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-list.tsx b/packages/ocom/ui-community-route-member/src/components/member-properties-list.tsx new file mode 100644 index 000000000..497d75fc8 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-list.tsx @@ -0,0 +1,18 @@ +import { PropertyList, type PropertyRecord } from '@ocom/ui-community-shared'; +import type React from 'react'; + +interface MemberPropertiesListProps { + data: readonly PropertyRecord[]; + onPropertyView?: (propertyId: string) => void; + loading?: boolean | undefined; +} + +/** Member directory presentation deliberately excludes the manager Owner column. */ +export const MemberPropertiesList: React.FC = (props) => ( + +); diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-route-guard.container.graphql b/packages/ocom/ui-community-route-member/src/components/member-properties-route-guard.container.graphql new file mode 100644 index 000000000..25e057574 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-route-guard.container.graphql @@ -0,0 +1,25 @@ +query MemberPropertiesRouteGuardMembersForCurrentEndUser { + currentEndUserAndCreateIfNotExists { + id + } + membersForCurrentEndUser { + id + accounts { + statusCode + user { + id + } + } + role { + permissions { + propertyPermissions { + canManageProperties + canEditOwnProperty + } + } + } + community { + id + } + } +} diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-route-guard.container.stories.tsx b/packages/ocom/ui-community-route-member/src/components/member-properties-route-guard.container.stories.tsx new file mode 100644 index 000000000..0cc0c848b --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-route-guard.container.stories.tsx @@ -0,0 +1,105 @@ +import { MockedProvider } from '@apollo/client/testing'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { App, ConfigProvider } from 'antd'; +import type React from 'react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { MemberPropertiesRouteGuardMembersForCurrentEndUserDocument } from '../generated.tsx'; +import { MemberPropertiesRouteGuardContainer } from './member-properties-route-guard.container.tsx'; + +const communityId = '65f1f77bcf86cd7994390001'; +const memberId = '65f1f77bcf86cd7994390002'; +const currentEndUserId = '65f1f77bcf86cd7994390009'; +interface GuardOptions { + canEditOwnProperty?: boolean; + canManageProperties?: boolean; + accountStatus?: string; + routeMemberId?: string; + routeCommunityId?: string; +} + +const guardMock = (options: GuardOptions = {}) => ({ + request: { query: MemberPropertiesRouteGuardMembersForCurrentEndUserDocument }, + result: { + data: { + currentEndUserAndCreateIfNotExists: { __typename: 'EndUser', id: currentEndUserId }, + membersForCurrentEndUser: [ + { + __typename: 'Member', + id: memberId, + accounts: [{ __typename: 'MemberAccount', statusCode: options.accountStatus ?? 'ACCEPTED', user: { __typename: 'EndUser', id: currentEndUserId } }], + role: { + __typename: 'EndUserRole', + permissions: { + __typename: 'EndUserRolePermissions', + propertyPermissions: { + __typename: 'EndUserRolePropertyPermissions', + canManageProperties: options.canManageProperties ?? false, + canEditOwnProperty: options.canEditOwnProperty ?? true, + }, + }, + }, + community: { __typename: 'Community', id: communityId }, + }, + ], + }, + }, +}); + +const RouteOutput: React.FC = () => { + const location = useLocation(); + return {location.pathname}; +}; + +const decoratorFor = (options: GuardOptions = {}) => { + const path = `/community/${options.routeCommunityId ?? communityId}/member/${options.routeMemberId ?? memberId}/properties`; + return (Story: React.ComponentType) => ( + + + + + + } + /> + } + /> + + + + + + ); +}; + +const meta = { + title: 'Components/Member/MemberPropertiesRouteGuard', + component: MemberPropertiesRouteGuardContainer, + args: { children:
Allowed member Property content
}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Allowed: Story = { + decorators: [decoratorFor()], +}; + +export const DeniedRole: Story = { + decorators: [decoratorFor({ canEditOwnProperty: false })], +}; + +export const InactiveAccount: Story = { + decorators: [decoratorFor({ accountStatus: 'CREATED' })], +}; + +export const RouteMismatch: Story = { + decorators: [decoratorFor({ routeMemberId: '65f1f77bcf86cd7994390099' })], +}; + +/** The output route confirms manager traffic leaves the member subtree. */ +export const ManagerRedirect: Story = { + decorators: [decoratorFor({ canManageProperties: true })], +}; diff --git a/packages/ocom/ui-community-route-member/src/components/member-properties-route-guard.container.tsx b/packages/ocom/ui-community-route-member/src/components/member-properties-route-guard.container.tsx new file mode 100644 index 000000000..c8a4248bc --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-properties-route-guard.container.tsx @@ -0,0 +1,69 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import { Result } from 'antd'; +import type React from 'react'; +import { Navigate, useLocation, useParams } from 'react-router-dom'; +import { MemberPropertiesRouteGuardMembersForCurrentEndUserDocument } from '../generated.tsx'; +import { MemberPropertiesAccessProvider } from './member-properties-access.context.ts'; +import { resolveMemberPropertiesAccess } from './member-properties-access-policy.ts'; + +interface MemberPropertiesRouteGuardContainerProps { + children: React.ReactNode; +} + +const deniedResult = ( + +); + +/** + * Validates the current authenticated membership before any Property request + * mounts. Property managers are deliberately redirected to their equivalent + * manager route so their all-owner controls remain available. + */ +export const MemberPropertiesRouteGuardContainer: React.FC = (props) => { + const params = useParams<{ communityId?: string; memberId?: string }>(); + const location = useLocation(); + const { data, loading, error, refetch } = useQuery(MemberPropertiesRouteGuardMembersForCurrentEndUserDocument, { + fetchPolicy: 'network-only', + }); + + const accessDecision = resolveMemberPropertiesAccess(data?.membersForCurrentEndUser, data?.currentEndUserAndCreateIfNotExists?.id, params.communityId, params.memberId); + + const memberPropertiesPath = params.communityId && params.memberId ? `/community/${params.communityId}/member/${params.memberId}/properties` : undefined; + const remainingPath = memberPropertiesPath && location.pathname.startsWith(memberPropertiesPath) ? location.pathname.slice(memberPropertiesPath.length) : ''; + const managerRedirectPath = params.communityId && params.memberId ? `/community/${params.communityId}/admin/${params.memberId}/properties${remainingPath}${location.search}${location.hash}` : '/community/accounts'; + + const allowedContent = + accessDecision === 'manager-redirect' ? ( + + ) : accessDecision === 'allowed' && params.communityId && params.memberId ? ( + refetch(), + }} + > + {props.children} + + ) : ( + deniedResult + ); + + return ( + + ); +}; diff --git a/packages/ocom/ui-community-route-member/src/components/member-property-graphql-contract.test.ts b/packages/ocom/ui-community-route-member/src/components/member-property-graphql-contract.test.ts new file mode 100644 index 000000000..54126aef1 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-property-graphql-contract.test.ts @@ -0,0 +1,22 @@ +import { print } from 'graphql'; +import { describe, expect, it } from 'vitest'; +import { MemberPropertiesListDocument, MemberPropertiesRouteGuardMembersForCurrentEndUserDocument, MemberPropertyCreateDocument, MemberPropertyDetailDocument, MemberPropertyUpdateDocument } from '../generated.tsx'; + +describe('member Property GraphQL documents', () => { + it('never requests manager-only owner options or deletion operations', () => { + const memberDocuments = [MemberPropertiesRouteGuardMembersForCurrentEndUserDocument, MemberPropertiesListDocument, MemberPropertyDetailDocument, MemberPropertyCreateDocument, MemberPropertyUpdateDocument].map((document) => + print(document), + ); + + for (const document of memberDocuments) { + expect(document).not.toContain('propertyOwnerOptions'); + expect(document).not.toContain('propertyDelete'); + } + }); + + it('uses owner ids only in the detail projection needed for ownership policy', () => { + expect(print(MemberPropertiesListDocument)).not.toContain('owner'); + expect(print(MemberPropertyDetailDocument)).toContain('owner'); + expect(print(MemberPropertyDetailDocument)).not.toContain('memberName'); + }); +}); diff --git a/packages/ocom/ui-community-route-member/src/components/member-section-layout.tsx b/packages/ocom/ui-community-route-member/src/components/member-section-layout.tsx new file mode 100644 index 000000000..754fe2ed5 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/components/member-section-layout.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; +import { Outlet } from 'react-router-dom'; + +/** Neutral shell so member landing remains accessible without Property permission. */ +export const MemberSectionLayout: React.FC = () => ; diff --git a/packages/ocom/ui-community-route-member/src/index.tsx b/packages/ocom/ui-community-route-member/src/index.tsx new file mode 100644 index 000000000..c4286de71 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/index.tsx @@ -0,0 +1,35 @@ +import type React from 'react'; +import { Route, Routes } from 'react-router-dom'; +import { MemberSectionLayout } from './components/member-section-layout.tsx'; +import { MemberHome } from './pages/member-home.tsx'; +import { MemberProperties } from './pages/member-properties.tsx'; + +export const Member: React.FC = () => ( + + } + > + } + /> + } + /> + + +); + +export { MemberPropertiesCreate } from './components/member-properties-create.tsx'; +export { MemberPropertiesDetail } from './components/member-properties-detail.tsx'; +export { MemberPropertiesList } from './components/member-properties-list.tsx'; +export { MemberPropertiesRouteGuardContainer } from './components/member-properties-route-guard.container.tsx'; +export { + MemberPropertiesListDocument, + MemberPropertiesRouteGuardMembersForCurrentEndUserDocument, + MemberPropertyCreateDocument, + MemberPropertyDetailDocument, + MemberPropertyUpdateDocument, +} from './generated.tsx'; diff --git a/packages/ocom/ui-community-route-member/src/pages/member-home.stories.tsx b/packages/ocom/ui-community-route-member/src/pages/member-home.stories.tsx new file mode 100644 index 000000000..9deacd097 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/pages/member-home.stories.tsx @@ -0,0 +1,78 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { App as AntdApp } from 'antd'; +import type React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MemberPropertiesRouteGuardMembersForCurrentEndUserDocument } from '../generated.tsx'; +import { MemberHome } from './member-home.tsx'; + +const communityId = '65f1f77bcf86cd7994390001'; +const memberId = '65f1f77bcf86cd7994390002'; +const currentEndUserId = '65f1f77bcf86cd7994390009'; + +const membershipMock = (canEditOwnProperty: boolean, canManageProperties = false) => ({ + request: { query: MemberPropertiesRouteGuardMembersForCurrentEndUserDocument }, + result: { + data: { + currentEndUserAndCreateIfNotExists: { __typename: 'EndUser', id: currentEndUserId }, + membersForCurrentEndUser: [ + { + __typename: 'Member', + id: memberId, + accounts: [{ __typename: 'MemberAccount', statusCode: 'ACCEPTED', user: { __typename: 'EndUser', id: currentEndUserId } }], + role: { + __typename: 'EndUserRole', + permissions: { + __typename: 'EndUserRolePermissions', + propertyPermissions: { + __typename: 'EndUserRolePropertyPermissions', + canManageProperties, + canEditOwnProperty, + }, + }, + }, + community: { __typename: 'Community', id: communityId }, + }, + ], + }, + }, +}); + +const decoratorFor = (mocks: Parameters[0]['mocks']) => (Story: React.ComponentType) => ( + + + + + + } + /> + + + + + +); + +const meta = { + title: 'Pages/Member/Home', + component: MemberHome, + parameters: { layout: 'fullscreen' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const OwnPropertyMember: Story = { + decorators: [decoratorFor([membershipMock(true)])], +}; + +export const PropertyManager: Story = { + decorators: [decoratorFor([membershipMock(false, true)])], +}; + +export const NoPropertyPermission: Story = { + decorators: [decoratorFor([membershipMock(false)])], +}; diff --git a/packages/ocom/ui-community-route-member/src/pages/member-home.tsx b/packages/ocom/ui-community-route-member/src/pages/member-home.tsx new file mode 100644 index 000000000..c28f1dda6 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/pages/member-home.tsx @@ -0,0 +1,41 @@ +import { useQuery } from '@apollo/client'; +import { Helmet } from '@dr.pogodin/react-helmet'; +import { Button, Result } from 'antd'; +import type React from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { resolveMemberPropertiesAccess } from '../components/member-properties-access-policy.ts'; +import { MemberPropertiesRouteGuardMembersForCurrentEndUserDocument } from '../generated.tsx'; + +/** A neutral member landing with a permission-gated entry into Properties. */ +export const MemberHome: React.FC = () => { + const navigate = useNavigate(); + const { communityId, memberId } = useParams<{ communityId?: string; memberId?: string }>(); + const { data } = useQuery(MemberPropertiesRouteGuardMembersForCurrentEndUserDocument, { + skip: !communityId || !memberId, + }); + const accessDecision = resolveMemberPropertiesAccess(data?.membersForCurrentEndUser, data?.currentEndUserAndCreateIfNotExists?.id, communityId, memberId); + const propertiesDestination = accessDecision === 'allowed' ? 'properties' : accessDecision === 'manager-redirect' && communityId && memberId ? `/community/${communityId}/admin/${memberId}/properties` : undefined; + + return ( + <> + + Community Member + + navigate(propertiesDestination)} + > + {accessDecision === 'manager-redirect' ? 'Manage Properties' : 'Properties'} + + ) : undefined + } + /> + + ); +}; diff --git a/packages/ocom/ui-community-route-member/src/pages/member-properties-create.stories.tsx b/packages/ocom/ui-community-route-member/src/pages/member-properties-create.stories.tsx new file mode 100644 index 000000000..87503d1fa --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/pages/member-properties-create.stories.tsx @@ -0,0 +1,43 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { App as AntdApp } from 'antd'; +import type React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MemberPropertiesAccessProvider } from '../components/member-properties-access.context.ts'; +import { MemberPropertiesCreatePage } from './member-properties-create.tsx'; + +const communityId = '65f1f77bcf86cd7994390001'; +const memberId = '65f1f77bcf86cd7994390002'; + +const decorator = (Story: React.ComponentType) => ( + + + + + undefined }}> + + } + /> + + + + + + +); + +const meta = { + title: 'Pages/Member/PropertiesCreate', + component: MemberPropertiesCreatePage, + parameters: { layout: 'fullscreen' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + decorators: [decorator], +}; diff --git a/packages/ocom/ui-community-route-member/src/pages/member-properties-create.tsx b/packages/ocom/ui-community-route-member/src/pages/member-properties-create.tsx new file mode 100644 index 000000000..8674de7f2 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/pages/member-properties-create.tsx @@ -0,0 +1,27 @@ +import { PageHeader } from '@ant-design/pro-layout'; +import { Helmet } from '@dr.pogodin/react-helmet'; +import { SubPageLayout } from '@ocom/ui-community-shared'; +import type React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { MemberPropertiesCreateContainer } from '../components/member-properties-create.container.tsx'; + +export const MemberPropertiesCreatePage: React.FC = () => { + const navigate = useNavigate(); + + return ( + navigate('../')} + /> + } + > + + Add Property + + + + ); +}; diff --git a/packages/ocom/ui-community-route-member/src/pages/member-properties-detail.stories.tsx b/packages/ocom/ui-community-route-member/src/pages/member-properties-detail.stories.tsx new file mode 100644 index 000000000..914733da4 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/pages/member-properties-detail.stories.tsx @@ -0,0 +1,73 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { App as AntdApp } from 'antd'; +import type React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MemberPropertiesAccessProvider } from '../components/member-properties-access.context.ts'; +import { MemberPropertyDetailDocument } from '../generated.tsx'; +import { MemberPropertiesDetailPage } from './member-properties-detail.tsx'; + +const communityId = '65f1f77bcf86cd7994390001'; +const memberId = '65f1f77bcf86cd7994390002'; +const propertyId = '65f1f77bcf86cd7994390101'; + +const propertyMock = (ownerId: string) => ({ + request: { query: MemberPropertyDetailDocument, variables: { id: propertyId } }, + result: { + data: { + property: { + __typename: 'Property', + id: propertyId, + propertyName: 'Neighbor Cottage', + propertyType: 'cottage', + listedForSale: true, + listedForRent: false, + listedForLease: false, + listedInDirectory: true, + tags: ['garden'], + owner: { __typename: 'PropertyOwnerOption', id: ownerId }, + location: null, + listingDetail: null, + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-15T12:00:00.000Z', + }, + }, + }, +}); + +const decoratorFor = (mocks: Parameters[0]['mocks']) => (Story: React.ComponentType) => ( + + + + + undefined }}> + + } + /> + + + + + + +); + +const meta = { + title: 'Pages/Member/PropertiesDetail', + component: MemberPropertiesDetailPage, + parameters: { layout: 'fullscreen' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const OwnedEditable: Story = { + decorators: [decoratorFor([propertyMock(memberId)])], +}; + +export const ForeignReadOnly: Story = { + decorators: [decoratorFor([propertyMock('65f1f77bcf86cd7994390003')])], +}; diff --git a/packages/ocom/ui-community-route-member/src/pages/member-properties-detail.tsx b/packages/ocom/ui-community-route-member/src/pages/member-properties-detail.tsx new file mode 100644 index 000000000..e493da9d5 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/pages/member-properties-detail.tsx @@ -0,0 +1,28 @@ +import { PageHeader } from '@ant-design/pro-layout'; +import { Helmet } from '@dr.pogodin/react-helmet'; +import { SubPageLayout } from '@ocom/ui-community-shared'; +import type React from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { MemberPropertiesDetailContainer } from '../components/member-properties-detail.container.tsx'; + +export const MemberPropertiesDetailPage: React.FC = () => { + const navigate = useNavigate(); + const { id } = useParams<{ id?: string }>(); + + return ( + navigate('../')} + /> + } + > + + Property Details + + + + ); +}; diff --git a/packages/ocom/ui-community-route-member/src/pages/member-properties-list.stories.tsx b/packages/ocom/ui-community-route-member/src/pages/member-properties-list.stories.tsx new file mode 100644 index 000000000..91d7fba58 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/pages/member-properties-list.stories.tsx @@ -0,0 +1,78 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { App as AntdApp } from 'antd'; +import type React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MemberPropertiesAccessProvider } from '../components/member-properties-access.context.ts'; +import { MemberPropertiesListDocument } from '../generated.tsx'; +import { MemberPropertiesListPage } from './member-properties-list.tsx'; + +const communityId = '65f1f77bcf86cd7994390001'; +const memberId = '65f1f77bcf86cd7994390002'; + +const listMock = (propertiesByCommunityId: unknown[]) => ({ + request: { query: MemberPropertiesListDocument, variables: { communityId } }, + result: { data: { propertiesByCommunityId } }, +}); + +const decoratorFor = (mocks: Parameters[0]['mocks']) => (Story: React.ComponentType) => ( + + + + + undefined }}> + + } + /> + + + + + + +); + +const meta = { + title: 'Pages/Member/PropertiesList', + component: MemberPropertiesListPage, + parameters: { layout: 'fullscreen' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + decoratorFor([ + listMock([ + { + __typename: 'Property', + id: '65f1f77bcf86cd7994390101', + propertyName: 'Neighbor Cottage', + propertyType: 'cottage', + listingDetail: { __typename: 'PropertyListingDetail', price: 350000, bedrooms: 3, bathrooms: 2, squareFeet: 1800 }, + location: { + __typename: 'PropertyLocation', + address: { + __typename: 'PropertyAddress', + streetNumber: '20', + streetName: 'Pine Lane', + municipality: 'Shorewood', + countrySubdivision: 'WI', + postalCode: '53211', + country: 'USA', + }, + }, + updatedAt: '2024-01-15T12:00:00.000Z', + }, + ]), + ]), + ], +}; + +export const Empty: Story = { + decorators: [decoratorFor([listMock([])])], +}; diff --git a/packages/ocom/ui-community-route-member/src/pages/member-properties-list.tsx b/packages/ocom/ui-community-route-member/src/pages/member-properties-list.tsx new file mode 100644 index 000000000..00d11cd60 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/pages/member-properties-list.tsx @@ -0,0 +1,37 @@ +import { HomeOutlined } from '@ant-design/icons'; +import { PageHeader } from '@ant-design/pro-layout'; +import { SubPageLayout } from '@ocom/ui-community-shared'; +import { Button, theme } from 'antd'; +import type React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { MemberPropertiesListContainer } from '../components/member-properties-list.container.tsx'; + +export const MemberPropertiesListPage: React.FC = () => { + const navigate = useNavigate(); + const { + token: { colorTextBase }, + } = theme.useToken(); + + return ( + Properties} + extra={[ + , + ]} + /> + } + > + + + ); +}; diff --git a/packages/ocom/ui-community-route-member/src/pages/member-properties.stories.tsx b/packages/ocom/ui-community-route-member/src/pages/member-properties.stories.tsx new file mode 100644 index 000000000..b81b0908e --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/pages/member-properties.stories.tsx @@ -0,0 +1,93 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { App as AntdApp } from 'antd'; +import type React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MemberPropertiesListDocument, MemberPropertiesRouteGuardMembersForCurrentEndUserDocument } from '../generated.tsx'; +import { MemberProperties } from './member-properties.tsx'; + +const communityId = '65f1f77bcf86cd7994390001'; +const memberId = '65f1f77bcf86cd7994390002'; +const currentEndUserId = '65f1f77bcf86cd7994390009'; + +const guardMock = (canEditOwnProperty: boolean) => ({ + request: { query: MemberPropertiesRouteGuardMembersForCurrentEndUserDocument }, + result: { + data: { + currentEndUserAndCreateIfNotExists: { __typename: 'EndUser', id: currentEndUserId }, + membersForCurrentEndUser: [ + { + __typename: 'Member', + id: memberId, + accounts: [{ __typename: 'MemberAccount', statusCode: 'ACCEPTED', user: { __typename: 'EndUser', id: currentEndUserId } }], + role: { + __typename: 'EndUserRole', + permissions: { + __typename: 'EndUserRolePermissions', + propertyPermissions: { + __typename: 'EndUserRolePropertyPermissions', + canManageProperties: false, + canEditOwnProperty, + }, + }, + }, + community: { __typename: 'Community', id: communityId }, + }, + ], + }, + }, +}); + +const listMock = { + request: { query: MemberPropertiesListDocument, variables: { communityId } }, + result: { + data: { + propertiesByCommunityId: [ + { + __typename: 'Property', + id: '65f1f77bcf86cd7994390101', + propertyName: 'Neighbor Cottage', + propertyType: 'cottage', + listingDetail: null, + location: null, + updatedAt: null, + }, + ], + }, + }, +}; + +const decoratorFor = (mocks: Parameters[0]['mocks']) => (Story: React.ComponentType) => ( + + + + + + } + /> + + + + + +); + +const meta = { + title: 'Pages/Member/Properties', + component: MemberProperties, + parameters: { layout: 'fullscreen' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const AuthorizedList: Story = { + decorators: [decoratorFor([guardMock(true), listMock])], +}; + +export const PermissionDenied: Story = { + decorators: [decoratorFor([guardMock(false)])], +}; diff --git a/packages/ocom/ui-community-route-member/src/pages/member-properties.tsx b/packages/ocom/ui-community-route-member/src/pages/member-properties.tsx new file mode 100644 index 000000000..854453718 --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/pages/member-properties.tsx @@ -0,0 +1,31 @@ +import { Helmet } from '@dr.pogodin/react-helmet'; +import type React from 'react'; +import { Route, Routes } from 'react-router-dom'; +import { MemberPropertiesRouteGuardContainer } from '../components/member-properties-route-guard.container.tsx'; +import { MemberPropertiesCreatePage } from './member-properties-create.tsx'; +import { MemberPropertiesDetailPage } from './member-properties-detail.tsx'; +import { MemberPropertiesListPage } from './member-properties-list.tsx'; + +export const MemberProperties: React.FC = () => ( + <> + + Properties + + + + } + /> + } + /> + } + /> + + + +); diff --git a/packages/ocom/ui-community-route-member/src/vite-env.d.ts b/packages/ocom/ui-community-route-member/src/vite-env.d.ts new file mode 100644 index 000000000..94140be2f --- /dev/null +++ b/packages/ocom/ui-community-route-member/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/ocom/ui-community-route-member/tsconfig.json b/packages/ocom/ui-community-route-member/tsconfig.json new file mode 100644 index 000000000..7d9293af1 --- /dev/null +++ b/packages/ocom/ui-community-route-member/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@cellix/config-typescript/base", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "jsx": "react-jsx", + "lib": ["ES2023", "DOM"], + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"], + "exclude": ["src/**/*.stories.ts", "src/**/*.stories.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/ocom/ui-community-route-member/tsconfig.vitest.json b/packages/ocom/ui-community-route-member/tsconfig.vitest.json new file mode 100644 index 000000000..4f806efbc --- /dev/null +++ b/packages/ocom/ui-community-route-member/tsconfig.vitest.json @@ -0,0 +1,3 @@ +{ + "extends": ["./tsconfig.json", "@cellix/config-typescript/vitest"] +} diff --git a/packages/ocom/ui-community-route-member/vitest.arch.config.ts b/packages/ocom/ui-community-route-member/vitest.arch.config.ts new file mode 100644 index 000000000..6413fd7c8 --- /dev/null +++ b/packages/ocom/ui-community-route-member/vitest.arch.config.ts @@ -0,0 +1,4 @@ +import { archConfig } from '@cellix/config-vitest'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig(archConfig, defineConfig({})); diff --git a/packages/ocom/ui-community-route-member/vitest.config.ts b/packages/ocom/ui-community-route-member/vitest.config.ts new file mode 100644 index 000000000..832d97a41 --- /dev/null +++ b/packages/ocom/ui-community-route-member/vitest.config.ts @@ -0,0 +1,19 @@ +import { createStorybookVitestConfig, getDirnameFromImportMetaUrl } from '@cellix/config-vitest'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +const dirname = getDirnameFromImportMetaUrl(import.meta.url); + +export default mergeConfig( + defineConfig( + createStorybookVitestConfig(dirname, { + additionalCoverageExclude: ['src/index.ts'], + }), + ), + defineConfig({ + test: { + coverage: { + include: ['src/components/**/*.{ts,tsx}'], + }, + }, + }), +); diff --git a/packages/ocom/ui-community-shared/package.json b/packages/ocom/ui-community-shared/package.json index 5ed161480..975583faa 100644 --- a/packages/ocom/ui-community-shared/package.json +++ b/packages/ocom/ui-community-shared/package.json @@ -15,6 +15,7 @@ "test:watch": "vitest" }, "dependencies": { + "@ant-design/icons": "catalog:", "@ocom/ui-shared": "workspace:*", "@apollo/client": "^3.13.9", "@graphql-typed-document-node/core": "^3.2.0", @@ -26,7 +27,6 @@ "react-router-dom": "catalog:" }, "devDependencies": { - "@ant-design/icons": "catalog:", "@cellix/config-typescript": "workspace:*", "@cellix/config-vitest": "workspace:*", "@chromatic-com/storybook": "^5.2.1", diff --git a/packages/ocom/ui-community-route-admin/src/components/address-options.ts b/packages/ocom/ui-community-shared/src/components/property/address-options.ts similarity index 100% rename from packages/ocom/ui-community-route-admin/src/components/address-options.ts rename to packages/ocom/ui-community-shared/src/components/property/address-options.ts diff --git a/packages/ocom/ui-community-route-admin/src/components/countries.ts b/packages/ocom/ui-community-shared/src/components/property/countries.ts similarity index 100% rename from packages/ocom/ui-community-route-admin/src/components/countries.ts rename to packages/ocom/ui-community-shared/src/components/property/countries.ts diff --git a/packages/ocom/ui-community-shared/src/components/property/format-display-address.test.ts b/packages/ocom/ui-community-shared/src/components/property/format-display-address.test.ts new file mode 100644 index 000000000..37f2fdf2e --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/format-display-address.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { formatDisplayAddress } from './format-display-address.ts'; + +describe('formatDisplayAddress', () => { + it('joins populated address parts and ignores empty parts', () => { + expect( + formatDisplayAddress({ + streetNumber: '125', + streetName: 'Harbor Way', + municipality: 'Shorewood', + countrySubdivision: 'WI', + postalCode: '53211', + }), + ).toBe('125 Harbor Way, Shorewood, WI 53211'); + expect(formatDisplayAddress({ streetNumber: ' ', streetName: ' Main St ', municipality: '' })).toBe('Main St'); + }); + + it('uses a stable fallback for a missing address', () => { + expect(formatDisplayAddress(undefined)).toBe('N/A'); + }); +}); diff --git a/packages/ocom/ui-community-shared/src/components/property/format-display-address.ts b/packages/ocom/ui-community-shared/src/components/property/format-display-address.ts new file mode 100644 index 000000000..d4891e517 --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/format-display-address.ts @@ -0,0 +1,30 @@ +/** Address parts accepted by {@link formatDisplayAddress}. */ +interface DisplayAddressParts { + streetNumber?: string | null | undefined; + streetName?: string | null | undefined; + municipality?: string | null | undefined; + countrySubdivision?: string | null | undefined; + postalCode?: string | null | undefined; + country?: string | null | undefined; +} + +const nonEmpty = (value?: string | null): value is string => typeof value === 'string' && value.trim().length > 0; + +/** + * Formats an address as `streetNumber streetName, municipality, + * countrySubdivision postalCode`, joining only the non-empty parts. + * Returns `N/A` when no part is set. + */ +export const formatDisplayAddress = (address?: DisplayAddressParts | null): string => { + const street = [address?.streetNumber, address?.streetName] + .filter(nonEmpty) + .map((part) => part.trim()) + .join(' '); + const region = [address?.countrySubdivision, address?.postalCode] + .filter(nonEmpty) + .map((part) => part.trim()) + .join(' '); + const municipality = nonEmpty(address?.municipality) ? address.municipality.trim() : ''; + const parts = [street, municipality, region].filter((part) => part.length > 0); + return parts.length > 0 ? parts.join(', ') : 'N/A'; +}; diff --git a/packages/ocom/ui-community-shared/src/components/property/properties-detail.stories.tsx b/packages/ocom/ui-community-shared/src/components/property/properties-detail.stories.tsx new file mode 100644 index 000000000..5460bfa9c --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/properties-detail.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { PropertyDetails } from './properties-detail.tsx'; +import { PROPERTY_EDIT_POLICIES } from './property-edit-policy.ts'; +import type { PropertyRecord } from './property-types.ts'; + +const property: PropertyRecord = { + id: 'property-1', + propertyName: 'Harborview Unit 205', + propertyType: 'condo', + listedForSale: true, + listedInDirectory: true, + tags: ['waterfront', 'pool'], + owner: { id: 'member-2', memberName: 'Foreign Same-Community Owner' }, + location: { address: { streetNumber: '125', streetName: 'Harbor Way', municipality: 'Shorewood', countrySubdivision: 'WI', postalCode: '53211', country: 'United States' } }, + listingDetail: { price: 450000, bedrooms: 3, bathrooms: 2.5, description: 'Bright corner unit overlooking the harbor.', amenities: ['gym', 'sauna'] }, + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-15T12:00:00.000Z', +}; + +const meta = { + title: 'Components/Property/PropertyDetails', + component: PropertyDetails, + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ManagerEdit: Story = { + args: { + data: property, + editPolicy: PROPERTY_EDIT_POLICIES.managerEdit, + members: [{ id: 'member-2', memberName: 'Foreign Same-Community Owner' }], + onSubmit: () => undefined, + }, +}; + +/** Owner identity is intentionally not rendered in this shared read-only view. */ +export const ForeignOrUnownedReadOnly: Story = { + args: { + data: property, + editPolicy: PROPERTY_EDIT_POLICIES.readOnly, + }, +}; diff --git a/packages/ocom/ui-community-shared/src/components/property/properties-detail.tsx b/packages/ocom/ui-community-shared/src/components/property/properties-detail.tsx new file mode 100644 index 000000000..467a929ae --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/properties-detail.tsx @@ -0,0 +1,66 @@ +import { Space, Typography } from 'antd'; +import type React from 'react'; +import { PROPERTY_EDIT_POLICIES, type PropertyEditPolicy } from './property-edit-policy.ts'; +import { PropertyForm } from './property-form.tsx'; +import { propertyRecordToFormValues } from './property-input-mappers.ts'; +import { PropertyMetadata, PropertyReadOnlyDetail, ReadOnlyPropertyNotice } from './property-read-only-detail.tsx'; +import type { PropertyFormMemberOption, PropertyFormValues, PropertyRecord } from './property-types.ts'; + +const { Title } = Typography; + +export interface PropertyDetailsProps { + data: PropertyRecord; + editPolicy?: PropertyEditPolicy | undefined; + members?: PropertyFormMemberOption[] | undefined; + membersLoading?: boolean | undefined; + submitLabel?: string | undefined; + submitting?: boolean | undefined; + onSubmit?: ((values: PropertyFormValues) => void) | undefined; + onSubmitAndClose?: ((values: PropertyFormValues) => void) | undefined; + actions?: React.ReactNode; +} + +/** + * Route-neutral detail presentation. Route containers own mutations and + * manager-only deletion confirmation; this component only selects the + * editable or read-only presentation from an explicit policy. + */ +export const PropertyDetails: React.FC = (props) => { + const editPolicy = props.editPolicy ?? PROPERTY_EDIT_POLICIES.managerEdit; + const members: PropertyFormMemberOption[] = [...(props.members ?? [])]; + if (editPolicy.canEditOwner && props.data.owner && !members.some((member) => member.id === String(props.data.owner?.id))) { + members.push({ id: String(props.data.owner.id), memberName: props.data.owner.memberName }); + } + + return ( + +
+ {props.data.propertyName} + {props.actions} +
+ + {editPolicy.canEditListingContent ? ( + undefined)} + onSubmitAndClose={props.onSubmitAndClose} + /> + ) : ( + <> + + + + )} +
+ ); +}; diff --git a/packages/ocom/ui-community-shared/src/components/property/properties-list.stories.tsx b/packages/ocom/ui-community-shared/src/components/property/properties-list.stories.tsx new file mode 100644 index 000000000..c232cc6ac --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/properties-list.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { PropertyList } from './properties-list.tsx'; +import type { PropertyRecord } from './property-types.ts'; + +const properties: PropertyRecord[] = [ + { + id: 'property-1', + propertyName: 'Harborview Unit 101', + propertyType: 'condo', + listingDetail: { price: 450000, bedrooms: 2, bathrooms: 1.5, squareFeet: 1200 }, + location: { address: { streetNumber: '125', streetName: 'Harbor Way', municipality: 'Shorewood', countrySubdivision: 'WI', postalCode: '53211' } }, + owner: { id: 'member-1', memberName: 'Property Manager' }, + updatedAt: '2024-01-15T12:00:00.000Z', + }, +]; + +const meta = { + title: 'Components/Property/PropertyList', + component: PropertyList, + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ManagerDirectory: Story = { + args: { data: properties, onPropertyView: () => undefined }, +}; + +/** Member projection verifies the owner column is not part of the table. */ +export const MemberDirectoryWithoutOwnerIdentity: Story = { + args: { data: properties, onPropertyView: () => undefined, showOwner: false }, +}; + +export const Empty: Story = { + args: { data: [], onPropertyView: () => undefined, showOwner: false }, +}; + +export const Loading: Story = { + args: { data: [], loading: true, showOwner: false }, +}; diff --git a/packages/ocom/ui-community-shared/src/components/property/properties-list.tsx b/packages/ocom/ui-community-shared/src/components/property/properties-list.tsx new file mode 100644 index 000000000..c0ccc768d --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/properties-list.tsx @@ -0,0 +1,128 @@ +import type { TableColumnsType } from 'antd'; +import { Button, Space, Table, Typography } from 'antd'; +import type React from 'react'; +import { formatDisplayAddress } from './format-display-address.ts'; +import type { PropertyRecord } from './property-types.ts'; + +const { Title } = Typography; + +export interface PropertyListProps { + data: readonly PropertyRecord[]; + onPropertyView?: ((propertyId: string) => void) | undefined; + loading?: boolean | undefined; + /** Manager lists show owner names; member lists must keep them hidden. */ + showOwner?: boolean | undefined; +} + +const formatListingNumber = (value: number | null | undefined): string => { + if (value === null || value === undefined) { + return 'N/A'; + } + return String(value); +}; + +const formatPrice = (value: number | null | undefined): string => { + if (value === null || value === undefined) { + return 'N/A'; + } + return `$${value.toLocaleString('en-US')}`; +}; + +export const PropertyList: React.FC = (props) => { + const { data, onPropertyView, loading, showOwner = true } = props; + + const columns: TableColumnsType = [ + { + title: 'Property Name', + dataIndex: 'propertyName', + key: 'propertyName', + }, + { + title: 'Property Type', + dataIndex: 'propertyType', + key: 'propertyType', + render: (propertyType: string | null) => propertyType || 'N/A', + }, + { + title: 'Price', + dataIndex: ['listingDetail', 'price'], + key: 'price', + render: (price: number | null) => formatPrice(price), + }, + { + title: 'Bedrooms', + dataIndex: ['listingDetail', 'bedrooms'], + key: 'bedrooms', + render: (bedrooms: number | null) => formatListingNumber(bedrooms), + }, + { + title: 'Bathrooms', + dataIndex: ['listingDetail', 'bathrooms'], + key: 'bathrooms', + render: (bathrooms: number | null) => formatListingNumber(bathrooms), + }, + { + title: 'Square Feet', + dataIndex: ['listingDetail', 'squareFeet'], + key: 'squareFeet', + render: (squareFeet: number | null) => formatListingNumber(squareFeet), + }, + { + title: 'Address', + key: 'address', + render: (_text: unknown, record: PropertyRecord) => formatDisplayAddress(record.location?.address), + }, + ...(showOwner + ? [ + { + title: 'Owner', + key: 'owner', + render: (_text: unknown, record: PropertyRecord) => record.owner?.memberName || 'N/A', + }, + ] + : []), + { + title: 'Updated', + dataIndex: 'updatedAt', + key: 'updatedAt', + render: (date: string | Date | null | undefined) => { + if (!date) return 'N/A'; + return new Date(date).toLocaleDateString(); + }, + }, + { + title: 'Action', + key: 'action', + render: (_text: unknown, record: PropertyRecord) => ( + + ), + }, + ]; + + return ( + + Community Properties ({data.length}) +
`${range[0]}-${range[1]} of ${total} properties`, + }} + /> + + ); +}; diff --git a/packages/ocom/ui-community-shared/src/components/property/property-edit-policy.test.ts b/packages/ocom/ui-community-shared/src/components/property/property-edit-policy.test.ts new file mode 100644 index 000000000..85dad5607 --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/property-edit-policy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { PROPERTY_EDIT_POLICIES } from './property-edit-policy.ts'; + +describe('Property edit policies', () => { + it('keeps manager-only controls out of member policies', () => { + expect(PROPERTY_EDIT_POLICIES.memberCreate).toMatchObject({ + canEditPropertyName: true, + canEditPropertyType: false, + canEditOwner: false, + canEditListingContent: true, + }); + expect(PROPERTY_EDIT_POLICIES.memberEdit).toMatchObject({ + canEditPropertyName: false, + canEditPropertyType: false, + canEditOwner: false, + canEditListingContent: true, + }); + }); + + it('has a genuinely non-editable read-only policy', () => { + expect(PROPERTY_EDIT_POLICIES.readOnly).toEqual({ + canEditPropertyName: false, + canEditPropertyType: false, + canEditOwner: false, + canEditListingContent: false, + canSubmit: false, + }); + }); +}); diff --git a/packages/ocom/ui-community-shared/src/components/property/property-edit-policy.ts b/packages/ocom/ui-community-shared/src/components/property/property-edit-policy.ts new file mode 100644 index 000000000..894a4c82b --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/property-edit-policy.ts @@ -0,0 +1,49 @@ +/** + * Presentation capability policy. Route containers decide which policy is + * appropriate; this policy only controls visible fields and actions. + */ +export interface PropertyEditPolicy { + readonly canEditPropertyName: boolean; + readonly canEditPropertyType: boolean; + readonly canEditOwner: boolean; + readonly canEditListingContent: boolean; + readonly canSubmit: boolean; +} + +export const PROPERTY_EDIT_POLICIES = { + managerCreate: { + canEditPropertyName: true, + canEditPropertyType: true, + canEditOwner: true, + canEditListingContent: true, + canSubmit: true, + }, + managerEdit: { + canEditPropertyName: true, + canEditPropertyType: true, + canEditOwner: true, + canEditListingContent: true, + canSubmit: true, + }, + memberCreate: { + canEditPropertyName: true, + canEditPropertyType: false, + canEditOwner: false, + canEditListingContent: true, + canSubmit: true, + }, + memberEdit: { + canEditPropertyName: false, + canEditPropertyType: false, + canEditOwner: false, + canEditListingContent: true, + canSubmit: true, + }, + readOnly: { + canEditPropertyName: false, + canEditPropertyType: false, + canEditOwner: false, + canEditListingContent: false, + canSubmit: false, + }, +} as const satisfies Record; diff --git a/packages/ocom/ui-community-shared/src/components/property/property-form.stories.tsx b/packages/ocom/ui-community-shared/src/components/property/property-form.stories.tsx new file mode 100644 index 000000000..a60609a63 --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/property-form.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { PROPERTY_EDIT_POLICIES } from './property-edit-policy.ts'; +import { PropertyForm } from './property-form.tsx'; +import type { PropertyFormValues } from './property-types.ts'; + +const memberOptions = [ + { id: 'member-1', memberName: 'Property Manager' }, + { id: 'member-2', memberName: 'Resident Owner' }, +]; + +const populatedValues: PropertyFormValues = { + propertyName: 'Harborview Unit 205', + propertyType: 'condo', + ownerId: 'member-2', + listedForSale: true, + listedInDirectory: true, + tags: 'waterfront, pool', + location: { address: { streetNumber: '125', streetName: 'Harbor Way', municipality: 'Shorewood', countrySubdivision: 'WI', country: 'United States' } }, + listingDetail: { + price: 450000, + bedrooms: 3, + bathrooms: 2.5, + amenities: 'gym, sauna', + bedroomDetails: [{ roomName: 'Primary Suite', bedDescriptions: 'king' }], + additionalAmenities: [{ category: 'Outdoor', amenities: 'patio, grill' }], + images: 'https://example.com/front.jpg', + }, +}; + +const meta = { + title: 'Components/Property/PropertyForm', + component: PropertyForm, + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Full manager create controls, including owner and property type. */ +export const ManagerCreate: Story = { + args: { + editPolicy: PROPERTY_EDIT_POLICIES.managerCreate, + members: memberOptions, + submitLabel: 'Create Property', + onSubmit: () => undefined, + }, +}; + +/** Server-owned member creation intentionally has no owner or type control. */ +export const MemberCreate: Story = { + args: { + editPolicy: PROPERTY_EDIT_POLICIES.memberCreate, + submitLabel: 'Create Property', + onSubmit: () => undefined, + }, +}; + +/** Owners can edit listing content but never rename, retype, or transfer. */ +export const OwnedMemberEdit: Story = { + args: { + editPolicy: PROPERTY_EDIT_POLICIES.memberEdit, + initialValues: populatedValues, + submitLabel: 'Save', + onSubmit: () => undefined, + }, +}; + +/** This state demonstrates validation-ready dynamic rows at the domain cap. */ +export const DynamicListLimit: Story = { + args: { + editPolicy: PROPERTY_EDIT_POLICIES.memberEdit, + initialValues: { + ...populatedValues, + listingDetail: { + ...populatedValues.listingDetail, + bedroomDetails: Array.from({ length: 50 }, (_value, index) => ({ roomName: `Bedroom ${index + 1}` })), + additionalAmenities: Array.from({ length: 50 }, (_value, index) => ({ category: `Category ${index + 1}` })), + }, + }, + submitLabel: 'Save', + onSubmit: () => undefined, + }, +}; + +/** Empty values are available to exercise central inline validation manually. */ +export const ValidationState: Story = { + args: { + editPolicy: PROPERTY_EDIT_POLICIES.memberCreate, + submitLabel: 'Create Property', + onSubmit: () => undefined, + }, +}; diff --git a/packages/ocom/ui-community-shared/src/components/property/property-form.tsx b/packages/ocom/ui-community-shared/src/components/property/property-form.tsx new file mode 100644 index 000000000..380c74410 --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/property-form.tsx @@ -0,0 +1,824 @@ +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; +import { Button, Col, Form, Image, Input, InputNumber, Row, Select, Space, Switch, Typography } from 'antd'; +import type React from 'react'; +import { useRef } from 'react'; +import { COUNTRY_SELECT_OPTIONS, stateSelectOptionsForCountry } from './address-options.ts'; +import { PROPERTY_EDIT_POLICIES, type PropertyEditPolicy } from './property-edit-policy.ts'; +import { commaListRule, emailRules, halfStepRangeRule, integerRangeRule, isSafeHttpUrl, maxLengthRule, safeHttpUrlListRule, safeHttpUrlRule } from './property-form.validation.ts'; +import type { PropertyFormMemberOption, PropertyFormValues } from './property-types.ts'; + +const { Title } = Typography; + +export interface PropertyFormProps { + initialValues?: PropertyFormValues | undefined; + members?: PropertyFormMemberOption[] | undefined; + membersLoading?: boolean | undefined; + submitLabel: string; + submitting?: boolean | undefined; + editPolicy?: PropertyEditPolicy | undefined; + onSubmit: (values: PropertyFormValues) => void; + /** + * When provided, a second "Save & Close" submit button is rendered that + * runs the same form validation and routes successfully validated values + * here instead of {@link PropertyFormProps.onSubmit}. + */ + onSubmitAndClose?: ((values: PropertyFormValues) => void) | undefined; +} + +/** Splits a comma-separated string into trimmed, non-empty entries. */ +const splitCommaList = (value?: string | null): string[] => + (value ?? '') + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + +const numberFieldProps = { min: 0, style: { width: '100%' } } as const; + +interface UrlGalleryProps { + urlsText?: string | undefined; +} + +/** Inline gallery preview for a comma-separated list of image URLs. */ +const UrlGallery: React.FC = (props) => { + const urls = splitCommaList(props.urlsText).filter(isSafeHttpUrl); + if (urls.length === 0) { + return null; + } + return ( + + + {urls.map((url) => ( + + ))} + + + ); +}; + +interface UrlLinkPreviewProps { + urlText?: string | undefined; +} + +/** Inline link preview for a single URL field. */ +const UrlLinkPreview: React.FC = (props) => { + const url = (props.urlText ?? '').trim(); + if (url.length === 0) { + return null; + } + // Only linkify http(s) URLs; other schemes (javascript:, data:, ...) render + // as inert text so a stored value can never execute in a viewer's browser. + if (!isSafeHttpUrl(url)) { + return {url}; + } + return ( + + {url} + + ); +}; + +/** + * Shared property form used by both the create and detail admin screens. It + * renders the full property field set in Overview, Location, and Listing + * sections; the containers convert between GraphQL data/input shapes and + * {@link PropertyFormValues}. + */ +export const PropertyForm: React.FC = (props) => { + const [form] = Form.useForm(); + const editPolicy = props.editPolicy ?? PROPERTY_EDIT_POLICIES.managerCreate; + // Which submit button triggered the pending submission; both buttons share + // the same validation and scroll-to-error behavior via htmlType="submit". + const submitIntentRef = useRef<'save' | 'saveAndClose'>('save'); + const imagesText = Form.useWatch(['listingDetail', 'images'], form); + const videoText = Form.useWatch(['listingDetail', 'video'], form); + const floorPlanText = Form.useWatch(['listingDetail', 'floorPlan'], form); + const floorPlanImagesText = Form.useWatch(['listingDetail', 'floorPlanImages'], form); + // Country drives the state/province cascade: countries with a subdivision + // list (US/Canada) get a Select, everything else a free-text Input. + const selectedCountry = Form.useWatch(['location', 'address', 'country'], form); + const stateOptions = stateSelectOptionsForCountry(selectedCountry); + + const ownerOptions = (props.members ?? []).map((member) => { + const memberName = member.memberName ?? member.id; + return { value: member.id, label: memberName, title: memberName }; + }); + + if (!editPolicy.canEditListingContent) { + return null; + } + + return ( +
{ + const intent = submitIntentRef.current; + // Consume the intent so a later Enter-key submit (which clicks no + // button) defaults to a plain save rather than replaying Save & Close. + submitIntentRef.current = 'save'; + if (intent === 'saveAndClose' && props.onSubmitAndClose) { + props.onSubmitAndClose(values); + return; + } + props.onSubmit(values); + }} + onFinishFailed={() => { + // Failed validation must not leave a stale Save & Close intent + // behind; after corrections, Enter-key submits stay on the page. + submitIntentRef.current = 'save'; + }} + > + Overview + {editPolicy.canEditPropertyName || editPolicy.canEditPropertyType ? ( + + {editPolicy.canEditPropertyName ? ( +
+ + + + + ) : null} + {editPolicy.canEditPropertyType ? ( + + + + + + ) : null} + + ) : null} + {editPolicy.canEditOwner ? ( + + + + + + + Location + + + + + + + + + + + + + + + + + + + + + {stateOptions ? ( + + )} + + + + + + + + + + + + + + + + + + + + + + )} + + + Amenities + + + + Additional Amenities + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name }) => ( + + + + + + + + + + + + + + + + )} + + + Media + + + + + + + + + + + + + + + + + + Agent + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {editPolicy.canSubmit ? ( + + + {props.onSubmitAndClose ? ( + + ) : null} + + ) : null} + + ); +}; diff --git a/packages/ocom/ui-community-route-admin/src/components/property-form.validation.ts b/packages/ocom/ui-community-shared/src/components/property/property-form.validation.ts similarity index 80% rename from packages/ocom/ui-community-route-admin/src/components/property-form.validation.ts rename to packages/ocom/ui-community-shared/src/components/property/property-form.validation.ts index 9dae1c64f..f94171b22 100644 --- a/packages/ocom/ui-community-route-admin/src/components/property-form.validation.ts +++ b/packages/ocom/ui-community-shared/src/components/property/property-form.validation.ts @@ -86,6 +86,20 @@ const commaSegments = (value?: string | null): string[] => .map((segment) => segment.trim()) .filter((segment) => segment.length > 0); +/** True only for absolute http(s) links that are safe to render as browser links. */ +export const isSafeHttpUrl = (value?: string | null): boolean => { + const trimmed = value?.trim(); + if (!trimmed) { + return false; + } + try { + const parsed = new URL(trimmed); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +}; + /** * Rule for comma-separated list fields, validating each segment against the * domain's per-item maxLength and, when the domain caps the array, the @@ -104,3 +118,24 @@ export const commaListRule = (label: string, maxItemLength: number, maxItems?: n return Promise.resolve(); }, }); + +/** Optional media link rule that only permits absolute http(s) URLs. */ +export const safeHttpUrlRule = (label: string): FormRule => ({ + validator: (_rule, value: string | null | undefined) => { + if (!value?.trim() || isSafeHttpUrl(value)) { + return Promise.resolve(); + } + return Promise.reject(new Error(`${label} must be a valid http(s) URL`)); + }, +}); + +/** Optional comma-separated media links; every populated item must be safe. */ +export const safeHttpUrlListRule = (label: string): FormRule => ({ + validator: (_rule, value: string | null | undefined) => { + const unsafe = commaSegments(value).find((entry) => !isSafeHttpUrl(entry)); + if (!unsafe) { + return Promise.resolve(); + } + return Promise.reject(new Error(`Each ${label} must be a valid http(s) URL`)); + }, +}); diff --git a/packages/ocom/ui-community-shared/src/components/property/property-input-mappers.test.ts b/packages/ocom/ui-community-shared/src/components/property/property-input-mappers.test.ts new file mode 100644 index 000000000..32ce5c0d9 --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/property-input-mappers.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { toManagerPropertyInputFields, toMemberPropertyCreateInput, toMemberPropertyUpdateInput } from './property-input-mappers.ts'; +import type { PropertyFormValues } from './property-types.ts'; + +const formValues: PropertyFormValues = { + propertyName: ' Maple Cottage ', + propertyType: 'condo', + ownerId: 'owner-id', + listedForSale: true, + listedInDirectory: true, + tags: 'quiet, garden', + location: { + address: { + streetNumber: '12', + streetName: 'Maple Street', + }, + }, + listingDetail: { + bedrooms: 2, + amenities: 'fireplace, deck', + }, +}; + +describe('member Property input mappers', () => { + it('uses a positive create allowlist and omits manager-only fields', () => { + const input = toMemberPropertyCreateInput(formValues); + + expect(input).toMatchObject({ + propertyName: 'Maple Cottage', + listedForSale: true, + listedInDirectory: true, + tags: ['quiet', 'garden'], + location: { address: { streetNumber: '12', streetName: 'Maple Street' } }, + listingDetail: { bedrooms: 2, amenities: ['fireplace', 'deck'] }, + }); + expect(input).not.toHaveProperty('ownerId'); + expect(input).not.toHaveProperty('propertyType'); + }); + + it('uses a positive update allowlist and never serializes name, owner, or type', () => { + const input = toMemberPropertyUpdateInput('property-id', formValues); + + expect(input).toMatchObject({ + id: 'property-id', + listedForSale: true, + listedInDirectory: true, + }); + expect(input).not.toHaveProperty('propertyName'); + expect(input).not.toHaveProperty('ownerId'); + expect(input).not.toHaveProperty('propertyType'); + }); + + it('keeps full owner and type fields available only to the manager mapper', () => { + expect(toManagerPropertyInputFields(formValues)).toMatchObject({ + propertyType: 'condo', + ownerId: 'owner-id', + listedForSale: true, + }); + }); +}); diff --git a/packages/ocom/ui-community-shared/src/components/property/property-input-mappers.ts b/packages/ocom/ui-community-shared/src/components/property/property-input-mappers.ts new file mode 100644 index 000000000..3634bad11 --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/property-input-mappers.ts @@ -0,0 +1,153 @@ +import type { ManagerPropertyInputFields, MemberPropertyCreateInput, MemberPropertyUpdateInput, PropertyFormValues, PropertyListingContentInput, PropertyRecord } from './property-types.ts'; + +/** Splits a comma-separated string into trimmed, non-empty entries. */ +const splitCommaList = (value?: string | null): string[] => + (value ?? '') + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + +const trimmedOrNull = (value?: string | null): string | null => { + const trimmed = (value ?? '').trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +/** Joins list entries into comma-separated form text, dropping blank values. */ +export const joinCommaList = (values?: readonly (string | null | undefined)[] | null): string => (values ?? []).filter((value): value is string => typeof value === 'string' && value.trim().length > 0).join(', '); + +/** + * Converts a record into form values without leaking route or generated-type + * dependencies into presentation components. + */ +export const propertyRecordToFormValues = (data: PropertyRecord): PropertyFormValues => ({ + propertyName: data.propertyName, + propertyType: data.propertyType ?? undefined, + ownerId: data.owner ? String(data.owner.id) : undefined, + listedForSale: data.listedForSale ?? false, + listedForRent: data.listedForRent ?? false, + listedForLease: data.listedForLease ?? false, + listedInDirectory: data.listedInDirectory ?? false, + tags: joinCommaList(data.tags), + location: { + address: { + streetNumber: data.location?.address?.streetNumber ?? undefined, + streetName: data.location?.address?.streetName ?? undefined, + municipality: data.location?.address?.municipality ?? undefined, + countrySubdivision: data.location?.address?.countrySubdivision ?? undefined, + postalCode: data.location?.address?.postalCode ?? undefined, + country: data.location?.address?.country ?? undefined, + }, + }, + listingDetail: { + price: data.listingDetail?.price ?? undefined, + rentHigh: data.listingDetail?.rentHigh ?? undefined, + rentLow: data.listingDetail?.rentLow ?? undefined, + lease: data.listingDetail?.lease ?? undefined, + maxGuests: data.listingDetail?.maxGuests ?? undefined, + bedrooms: data.listingDetail?.bedrooms ?? undefined, + bathrooms: data.listingDetail?.bathrooms ?? undefined, + squareFeet: data.listingDetail?.squareFeet ?? undefined, + yearBuilt: data.listingDetail?.yearBuilt ?? undefined, + lotSize: data.listingDetail?.lotSize ?? undefined, + description: data.listingDetail?.description ?? undefined, + amenities: joinCommaList(data.listingDetail?.amenities), + bedroomDetails: (data.listingDetail?.bedroomDetails ?? []).map((detail) => ({ + roomName: detail.roomName ?? undefined, + bedDescriptions: joinCommaList(detail.bedDescriptions), + })), + additionalAmenities: (data.listingDetail?.additionalAmenities ?? []).map((amenity) => ({ + category: amenity.category ?? undefined, + amenities: joinCommaList(amenity.amenities), + })), + images: joinCommaList(data.listingDetail?.images), + video: data.listingDetail?.video ?? undefined, + floorPlan: data.listingDetail?.floorPlan ?? undefined, + floorPlanImages: joinCommaList(data.listingDetail?.floorPlanImages), + listingAgent: data.listingDetail?.listingAgent ?? undefined, + listingAgentPhone: data.listingDetail?.listingAgentPhone ?? undefined, + listingAgentEmail: data.listingDetail?.listingAgentEmail ?? undefined, + listingAgentWebsite: data.listingDetail?.listingAgentWebsite ?? undefined, + listingAgentCompany: data.listingDetail?.listingAgentCompany ?? undefined, + listingAgentCompanyPhone: data.listingDetail?.listingAgentCompanyPhone ?? undefined, + listingAgentCompanyEmail: data.listingDetail?.listingAgentCompanyEmail ?? undefined, + listingAgentCompanyWebsite: data.listingDetail?.listingAgentCompanyWebsite ?? undefined, + listingAgentCompanyAddress: data.listingDetail?.listingAgentCompanyAddress ?? undefined, + }, +}); + +/** + * Builds the listing-content portion explicitly. Member builders below only + * spread this positive allowlist, so restricted fields cannot be serialized + * accidentally through a hidden field or an object cast. + */ +export const toPropertyListingContentInput = (values: PropertyFormValues): PropertyListingContentInput => ({ + listedForSale: values.listedForSale ?? false, + listedForRent: values.listedForRent ?? false, + listedForLease: values.listedForLease ?? false, + listedInDirectory: values.listedInDirectory ?? false, + tags: splitCommaList(values.tags), + location: { + address: { + streetNumber: trimmedOrNull(values.location?.address?.streetNumber), + streetName: trimmedOrNull(values.location?.address?.streetName), + municipality: trimmedOrNull(values.location?.address?.municipality), + countrySubdivision: trimmedOrNull(values.location?.address?.countrySubdivision), + postalCode: trimmedOrNull(values.location?.address?.postalCode), + country: trimmedOrNull(values.location?.address?.country), + }, + }, + listingDetail: { + price: values.listingDetail?.price ?? null, + rentHigh: values.listingDetail?.rentHigh ?? null, + rentLow: values.listingDetail?.rentLow ?? null, + lease: values.listingDetail?.lease ?? null, + maxGuests: values.listingDetail?.maxGuests ?? null, + bedrooms: values.listingDetail?.bedrooms ?? null, + bathrooms: values.listingDetail?.bathrooms ?? null, + squareFeet: values.listingDetail?.squareFeet ?? null, + yearBuilt: values.listingDetail?.yearBuilt ?? null, + lotSize: values.listingDetail?.lotSize ?? null, + description: trimmedOrNull(values.listingDetail?.description), + amenities: splitCommaList(values.listingDetail?.amenities), + bedroomDetails: (values.listingDetail?.bedroomDetails ?? []).map((row) => ({ + roomName: trimmedOrNull(row.roomName), + bedDescriptions: splitCommaList(row.bedDescriptions), + })), + additionalAmenities: (values.listingDetail?.additionalAmenities ?? []).map((row) => ({ + category: trimmedOrNull(row.category), + amenities: splitCommaList(row.amenities), + })), + images: splitCommaList(values.listingDetail?.images), + video: trimmedOrNull(values.listingDetail?.video), + floorPlan: trimmedOrNull(values.listingDetail?.floorPlan), + floorPlanImages: splitCommaList(values.listingDetail?.floorPlanImages), + listingAgent: trimmedOrNull(values.listingDetail?.listingAgent), + listingAgentPhone: trimmedOrNull(values.listingDetail?.listingAgentPhone), + listingAgentEmail: trimmedOrNull(values.listingDetail?.listingAgentEmail), + listingAgentWebsite: trimmedOrNull(values.listingDetail?.listingAgentWebsite), + listingAgentCompany: trimmedOrNull(values.listingDetail?.listingAgentCompany), + listingAgentCompanyPhone: trimmedOrNull(values.listingDetail?.listingAgentCompanyPhone), + listingAgentCompanyEmail: trimmedOrNull(values.listingDetail?.listingAgentCompanyEmail), + listingAgentCompanyWebsite: trimmedOrNull(values.listingDetail?.listingAgentCompanyWebsite), + listingAgentCompanyAddress: trimmedOrNull(values.listingDetail?.listingAgentCompanyAddress), + }, +}); + +/** Builds manager form fields, including manager-only type and owner controls. */ +export const toManagerPropertyInputFields = (values: PropertyFormValues): ManagerPropertyInputFields => ({ + propertyType: trimmedOrNull(values.propertyType), + ownerId: values.ownerId ?? null, + ...toPropertyListingContentInput(values), +}); + +/** Builds the strict member-create mutation payload. */ +export const toMemberPropertyCreateInput = (values: PropertyFormValues): MemberPropertyCreateInput => ({ + propertyName: (values.propertyName ?? '').trim(), + ...toPropertyListingContentInput(values), +}); + +/** Builds the strict member-update mutation payload. */ +export const toMemberPropertyUpdateInput = (id: string, values: PropertyFormValues): MemberPropertyUpdateInput => ({ + id, + ...toPropertyListingContentInput(values), +}); diff --git a/packages/ocom/ui-community-shared/src/components/property/property-read-only-detail.tsx b/packages/ocom/ui-community-shared/src/components/property/property-read-only-detail.tsx new file mode 100644 index 000000000..829cd0ef3 --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/property-read-only-detail.tsx @@ -0,0 +1,121 @@ +import { Descriptions, Space, Typography } from 'antd'; +import dayjs from 'dayjs'; +import type React from 'react'; +import { formatDisplayAddress } from './format-display-address.ts'; +import { joinCommaList } from './property-input-mappers.ts'; +import type { PropertyRecord } from './property-types.ts'; + +const { Text } = Typography; + +const display = (value: string | number | boolean | null | undefined): string => { + if (value === null || value === undefined || value === '') { + return 'N/A'; + } + return String(value); +}; + +const money = (value: number | null | undefined): string => (value === null || value === undefined ? 'N/A' : `$${value.toLocaleString('en-US')}`); + +/** Shared timestamp display preserving the manager detail's existing format. */ +export const formatPropertyDate = (value: string | Date | null | undefined): string => (value ? dayjs(value).format('MM/DD/YYYY') : 'N/A'); + +/** + * Read-only Property projection. It intentionally never renders `owner`, + * even when the route queried `owner.id` only to select an edit policy. + */ +export const PropertyReadOnlyDetail: React.FC<{ data: PropertyRecord }> = ({ data }) => { + const listing = data.listingDetail; + + return ( + + + {display(data.propertyType)} + {data.listedForSale ? 'Yes' : 'No'} + {data.listedForRent ? 'Yes' : 'No'} + {data.listedForLease ? 'Yes' : 'No'} + {data.listedInDirectory ? 'Yes' : 'No'} + {joinCommaList(data.tags) || 'N/A'} + + + + {formatDisplayAddress(data.location?.address)} + {display(data.location?.address?.country)} + + + + {money(listing?.price)} + + {listing?.rentLow === null || listing?.rentLow === undefined || listing?.rentHigh === null || listing?.rentHigh === undefined ? 'N/A' : `${money(listing.rentLow)} - ${money(listing.rentHigh)}`} + + {display(listing?.lease)} + {display(listing?.maxGuests)} + {display(listing?.bedrooms)} + {display(listing?.bathrooms)} + {display(listing?.squareFeet)} + {display(listing?.yearBuilt)} + {display(listing?.lotSize)} + {display(listing?.description)} + {joinCommaList(listing?.amenities) || 'N/A'} + + {listing?.bedroomDetails?.length ? listing.bedroomDetails.map((detail) => `${detail.roomName ?? 'Unnamed'}: ${joinCommaList(detail.bedDescriptions) || 'N/A'}`).join('; ') : 'N/A'} + + + {listing?.additionalAmenities?.length ? listing.additionalAmenities.map((amenity) => `${amenity.category ?? 'Uncategorized'}: ${joinCommaList(amenity.amenities) || 'N/A'}`).join('; ') : 'N/A'} + + {joinCommaList(listing?.images) || 'N/A'} + {display(listing?.video)} + {display(listing?.floorPlan)} + {joinCommaList(listing?.floorPlanImages) || 'N/A'} + + + + {display(listing?.listingAgent)} + {display(listing?.listingAgentPhone)} + {display(listing?.listingAgentEmail)} + {display(listing?.listingAgentWebsite)} + {display(listing?.listingAgentCompany)} + {display(listing?.listingAgentCompanyPhone)} + {display(listing?.listingAgentCompanyEmail)} + {display(listing?.listingAgentCompanyWebsite)} + {display(listing?.listingAgentCompanyAddress)} + + + ); +}; + +/** Metadata shown for both editable and read-only detail views. */ +export const PropertyMetadata: React.FC<{ data: PropertyRecord }> = ({ data }) => ( + + {data.id} + {formatPropertyDate(data.createdAt)} + {formatPropertyDate(data.updatedAt)} + +); + +/** A visible textual marker useful when a property has no editable form. */ +export const ReadOnlyPropertyNotice: React.FC = () => This property is view only.; diff --git a/packages/ocom/ui-community-shared/src/components/property/property-types.ts b/packages/ocom/ui-community-shared/src/components/property/property-types.ts new file mode 100644 index 000000000..93f85866a --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/property-types.ts @@ -0,0 +1,224 @@ +/** + * Route-neutral Property data contracts shared by the manager and member + * Community Portal routes. These intentionally mirror GraphQL shapes without + * depending on generated client types. + */ + +export interface PropertyAddress { + streetNumber?: string | null | undefined; + streetName?: string | null | undefined; + municipality?: string | null | undefined; + countrySubdivision?: string | null | undefined; + postalCode?: string | null | undefined; + country?: string | null | undefined; +} + +export interface PropertyLocation { + address?: PropertyAddress | null | undefined; +} + +export interface PropertyBedroomDetail { + roomName?: string | null | undefined; + bedDescriptions?: readonly (string | null | undefined)[] | null | undefined; +} + +export interface PropertyAdditionalAmenity { + category?: string | null | undefined; + amenities?: readonly (string | null | undefined)[] | null | undefined; +} + +export interface PropertyListingDetail { + price?: number | null | undefined; + rentHigh?: number | null | undefined; + rentLow?: number | null | undefined; + lease?: number | null | undefined; + maxGuests?: number | null | undefined; + bedrooms?: number | null | undefined; + bathrooms?: number | null | undefined; + squareFeet?: number | null | undefined; + yearBuilt?: number | null | undefined; + lotSize?: number | null | undefined; + description?: string | null | undefined; + amenities?: readonly (string | null | undefined)[] | null | undefined; + bedroomDetails?: readonly PropertyBedroomDetail[] | null | undefined; + additionalAmenities?: readonly PropertyAdditionalAmenity[] | null | undefined; + images?: readonly (string | null | undefined)[] | null | undefined; + video?: string | null | undefined; + floorPlan?: string | null | undefined; + floorPlanImages?: readonly (string | null | undefined)[] | null | undefined; + listingAgent?: string | null | undefined; + listingAgentPhone?: string | null | undefined; + listingAgentEmail?: string | null | undefined; + listingAgentWebsite?: string | null | undefined; + listingAgentCompany?: string | null | undefined; + listingAgentCompanyPhone?: string | null | undefined; + listingAgentCompanyEmail?: string | null | undefined; + listingAgentCompanyWebsite?: string | null | undefined; + listingAgentCompanyAddress?: string | null | undefined; +} + +export interface PropertyOwner { + id: string; + memberName?: string | null | undefined; +} + +export interface PropertyRecord { + id: string; + propertyName: string; + propertyType?: string | null | undefined; + listedForSale?: boolean | null | undefined; + listedForRent?: boolean | null | undefined; + listedForLease?: boolean | null | undefined; + listedInDirectory?: boolean | null | undefined; + tags?: readonly (string | null | undefined)[] | null | undefined; + owner?: PropertyOwner | null | undefined; + location?: PropertyLocation | null | undefined; + listingDetail?: PropertyListingDetail | null | undefined; + createdAt?: string | Date | null | undefined; + updatedAt?: string | Date | null | undefined; +} + +/** Values of one bedroom-detail row; list values are edited as comma-separated text. */ +export interface PropertyFormBedroomDetailValues { + roomName?: string | null | undefined; + bedDescriptions?: string | null | undefined; +} + +/** Values of one additional-amenity row; list values are edited as comma-separated text. */ +export interface PropertyFormAdditionalAmenityValues { + category?: string | null | undefined; + amenities?: string | null | undefined; +} + +/** + * Form-internal Property value shape. Keeping this independent from GraphQL + * lets route adapters decide which fields their authorization policy permits. + */ +export interface PropertyFormValues { + propertyName?: string | undefined; + propertyType?: string | null | undefined; + ownerId?: string | null | undefined; + listedForSale?: boolean | undefined; + listedForRent?: boolean | undefined; + listedForLease?: boolean | undefined; + listedInDirectory?: boolean | undefined; + tags?: string | null | undefined; + location?: { + address?: PropertyAddress; + }; + listingDetail?: { + price?: number | null | undefined; + rentHigh?: number | null | undefined; + rentLow?: number | null | undefined; + lease?: number | null | undefined; + maxGuests?: number | null | undefined; + bedrooms?: number | null | undefined; + bathrooms?: number | null | undefined; + squareFeet?: number | null | undefined; + yearBuilt?: number | null | undefined; + lotSize?: number | null | undefined; + description?: string | null | undefined; + amenities?: string | null | undefined; + bedroomDetails?: PropertyFormBedroomDetailValues[] | undefined; + additionalAmenities?: PropertyFormAdditionalAmenityValues[] | undefined; + images?: string | null | undefined; + video?: string | null | undefined; + floorPlan?: string | null | undefined; + floorPlanImages?: string | null | undefined; + listingAgent?: string | null | undefined; + listingAgentPhone?: string | null | undefined; + listingAgentEmail?: string | null | undefined; + listingAgentWebsite?: string | null | undefined; + listingAgentCompany?: string | null | undefined; + listingAgentCompanyPhone?: string | null | undefined; + listingAgentCompanyEmail?: string | null | undefined; + listingAgentCompanyWebsite?: string | null | undefined; + listingAgentCompanyAddress?: string | null | undefined; + }; +} + +/** Member option offered by a manager's Owner select. */ +export interface PropertyFormMemberOption { + id: string; + memberName?: string | null | undefined; +} + +export interface PropertyAddressInput { + streetNumber: string | null; + streetName: string | null; + municipality: string | null; + countrySubdivision: string | null; + postalCode: string | null; + country: string | null; +} + +export interface PropertyLocationInput { + address: PropertyAddressInput; +} + +export interface PropertyBedroomDetailInput { + roomName: string | null; + bedDescriptions: string[]; +} + +interface PropertyAdditionalAmenityInput { + category: string | null; + amenities: string[]; +} + +export interface PropertyListingDetailInput { + price: number | null; + rentHigh: number | null; + rentLow: number | null; + lease: number | null; + maxGuests: number | null; + bedrooms: number | null; + bathrooms: number | null; + squareFeet: number | null; + yearBuilt: number | null; + lotSize: number | null; + description: string | null; + amenities: string[]; + bedroomDetails: PropertyBedroomDetailInput[]; + additionalAmenities: PropertyAdditionalAmenityInput[]; + images: string[]; + video: string | null; + floorPlan: string | null; + floorPlanImages: string[]; + listingAgent: string | null; + listingAgentPhone: string | null; + listingAgentEmail: string | null; + listingAgentWebsite: string | null; + listingAgentCompany: string | null; + listingAgentCompanyPhone: string | null; + listingAgentCompanyEmail: string | null; + listingAgentCompanyWebsite: string | null; + listingAgentCompanyAddress: string | null; +} + +/** Listing fields a member is allowed to create or update. */ +export interface PropertyListingContentInput { + listedForSale: boolean; + listedForRent: boolean; + listedForLease: boolean; + listedInDirectory: boolean; + tags: string[]; + location: PropertyLocationInput; + listingDetail: PropertyListingDetailInput; +} + +/** Full manager-only form fields in addition to listing content. */ +export interface ManagerPropertyInputFields extends PropertyListingContentInput { + propertyType: string | null; + ownerId: string | null; +} + +/** Positive member-create allowlist. No owner or property type can appear here. */ +export interface MemberPropertyCreateInput extends PropertyListingContentInput { + propertyName: string; +} + +/** Positive member-update allowlist. No name, owner, or property type can appear here. */ +export interface MemberPropertyUpdateInput extends PropertyListingContentInput { + id: string; +} diff --git a/packages/ocom/ui-community-shared/src/components/property/sub-page-layout.tsx b/packages/ocom/ui-community-shared/src/components/property/sub-page-layout.tsx new file mode 100644 index 000000000..6ef63e93f --- /dev/null +++ b/packages/ocom/ui-community-shared/src/components/property/sub-page-layout.tsx @@ -0,0 +1,50 @@ +import { Layout, theme } from 'antd'; +import type React from 'react'; + +const { Header, Content, Footer } = Layout; + +interface SubPageLayoutProps { + header: React.JSX.Element; + fixedHeader?: boolean; + children?: React.ReactNode; +} + +export const SubPageLayout: React.FC = (props) => { + const { + token: { colorText, colorBgContainer }, + } = theme.useToken(); + const overFlow = props.fixedHeader ? 'scroll' : 'unset'; + return ( + <> +
+ {props.header} +
+
+ +
{props.children}
+
+
+ Owner Community +
+
+ + ); +}; diff --git a/packages/ocom/ui-community-shared/src/index.tsx b/packages/ocom/ui-community-shared/src/index.tsx index 2863ba63d..ed9dba99c 100644 --- a/packages/ocom/ui-community-shared/src/index.tsx +++ b/packages/ocom/ui-community-shared/src/index.tsx @@ -1,2 +1,41 @@ export { MemberProfileContainer, type MemberProfileContainerProps } from './components/member-profile.container.tsx'; export { MenuComponent, type MenuComponentProps, type PageLayoutProps } from './components/menu-component.tsx'; +export { COUNTRY_SELECT_OPTIONS, stateSelectOptionsForCountry } from './components/property/address-options.ts'; +export { COUNTRIES } from './components/property/countries.ts'; +export { formatDisplayAddress } from './components/property/format-display-address.ts'; +export { PropertyDetails, type PropertyDetailsProps } from './components/property/properties-detail.tsx'; +export { PropertyList, type PropertyListProps } from './components/property/properties-list.tsx'; +export { PROPERTY_EDIT_POLICIES, type PropertyEditPolicy } from './components/property/property-edit-policy.ts'; +export { PropertyForm, type PropertyFormProps } from './components/property/property-form.tsx'; +export { commaListRule, emailRules, halfStepRangeRule, integerRangeRule, isSafeHttpUrl, maxLengthRule, safeHttpUrlListRule, safeHttpUrlRule } from './components/property/property-form.validation.ts'; +export { + joinCommaList, + propertyRecordToFormValues, + toManagerPropertyInputFields, + toMemberPropertyCreateInput, + toMemberPropertyUpdateInput, + toPropertyListingContentInput, +} from './components/property/property-input-mappers.ts'; +export { formatPropertyDate, PropertyMetadata, PropertyReadOnlyDetail, ReadOnlyPropertyNotice } from './components/property/property-read-only-detail.tsx'; +export type { + ManagerPropertyInputFields, + MemberPropertyCreateInput, + MemberPropertyUpdateInput, + PropertyAdditionalAmenity, + PropertyAddress, + PropertyAddressInput, + PropertyBedroomDetail, + PropertyBedroomDetailInput, + PropertyFormAdditionalAmenityValues, + PropertyFormBedroomDetailValues, + PropertyFormMemberOption, + PropertyFormValues, + PropertyListingContentInput, + PropertyListingDetail, + PropertyListingDetailInput, + PropertyLocation, + PropertyLocationInput, + PropertyOwner, + PropertyRecord, +} from './components/property/property-types.ts'; +export { SubPageLayout } from './components/property/sub-page-layout.tsx'; diff --git a/packages/ocom/ui-shared/src/components/organisms/dropdown-menu/member-admin-access.ts b/packages/ocom/ui-shared/src/components/organisms/dropdown-menu/member-admin-access.ts index 197b33e26..f23ae3c29 100644 --- a/packages/ocom/ui-shared/src/components/organisms/dropdown-menu/member-admin-access.ts +++ b/packages/ocom/ui-shared/src/components/organisms/dropdown-menu/member-admin-access.ts @@ -1,6 +1,6 @@ interface MemberAccountLike { - statusCode?: string | null; - user?: { id?: string | null } | null; + statusCode?: string | null | undefined; + user?: { id?: string | null | undefined } | null | undefined; } interface AdminPortalMemberLike { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e024a17f1..cc04b8109 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -539,6 +539,9 @@ importers: '@ocom/ui-community-route-admin': specifier: workspace:* version: link:../../packages/ocom/ui-community-route-admin + '@ocom/ui-community-route-member': + specifier: workspace:* + version: link:../../packages/ocom/ui-community-route-member '@ocom/ui-community-route-root': specifier: workspace:* version: link:../../packages/ocom/ui-community-route-root @@ -1449,6 +1452,9 @@ importers: '@ocom/ui-community-route-admin': specifier: workspace:* version: link:../../ocom/ui-community-route-admin + '@ocom/ui-community-route-member': + specifier: workspace:* + version: link:../../ocom/ui-community-route-member '@ocom/ui-community-route-root': specifier: workspace:* version: link:../../ocom/ui-community-route-root @@ -2349,6 +2355,100 @@ importers: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/ocom/ui-community-route-member: + dependencies: + '@ant-design/icons': + specifier: 'catalog:' + version: 6.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@ant-design/pro-layout': + specifier: ^7.22.7 + version: 7.22.7(antd@6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@apollo/client': + specifier: ^3.13.9 + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@cellix/ui-core': + specifier: workspace:* + version: link:../../cellix/ui-core + '@dr.pogodin/react-helmet': + specifier: ^3.0.2 + version: 3.0.4(react@19.2.8) + '@graphql-typed-document-node/core': + specifier: ^3.2.0 + version: 3.2.0(graphql@16.12.0) + '@ocom/ui-community-shared': + specifier: workspace:* + version: link:../ui-community-shared + '@ocom/ui-shared': + specifier: workspace:* + version: link:../ui-shared + antd: + specifier: 'catalog:' + version: 6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: 'catalog:' + version: 19.2.8 + react-dom: + specifier: 'catalog:' + version: 19.2.8(react@19.2.8) + react-router-dom: + specifier: 'catalog:' + version: 7.18.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + devDependencies: + '@cellix/archunit-tests': + specifier: workspace:* + version: link:../../cellix/archunit-tests + '@cellix/config-typescript': + specifier: workspace:* + version: link:../../cellix/config-typescript + '@cellix/config-vitest': + specifier: workspace:* + version: link:../../cellix/config-vitest + '@chromatic-com/storybook': + specifier: ^5.2.1 + version: 5.2.1(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) + '@ocom-verification/archunit-tests': + specifier: workspace:* + version: link:../../ocom-verification/archunit-tests + '@storybook/addon-a11y': + specifier: 'catalog:' + version: 10.4.2(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) + '@storybook/addon-docs': + specifier: 'catalog:' + version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(esbuild@0.28.1)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(esbuild@0.28.1)) + '@storybook/addon-vitest': + specifier: 'catalog:' + version: 10.4.2(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10))(@vitest/runner@4.1.10)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vitest@4.1.10) + '@storybook/react': + specifier: ^10.4.2 + version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@6.0.3) + '@storybook/react-vite': + specifier: 'catalog:' + version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@6.0.3)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(esbuild@0.28.1)) + '@types/react': + specifier: ^19.1.11 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.1.6 + version: 19.2.3(@types/react@19.2.7) + graphql: + specifier: 'catalog:' + version: 16.12.0 + jsdom: + specifier: 'catalog:' + version: 26.1.0 + storybook: + specifier: 'catalog:' + version: 10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite: + specifier: 8.0.16 + version: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/ocom/ui-community-route-root: dependencies: '@ocom/ui-community-shared': @@ -2421,6 +2521,9 @@ importers: packages/ocom/ui-community-shared: dependencies: + '@ant-design/icons': + specifier: 'catalog:' + version: 6.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@apollo/client': specifier: ^3.13.9 version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2449,9 +2552,6 @@ importers: specifier: 'catalog:' version: 7.18.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: - '@ant-design/icons': - specifier: 'catalog:' - version: 6.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@cellix/config-typescript': specifier: workspace:* version: link:../../cellix/config-typescript