diff --git a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.graphql b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.graphql index ae954b342..6f973885e 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.graphql +++ b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.graphql @@ -1,12 +1,12 @@ # Inline fragment (previously in admin-listings-table.fragment.graphql) fragment AdminListingFields on ListingAll { - id - title - state - images - createdAt - sharingPeriodStart - sharingPeriodEnd + id + title + state + images + createdAt + sharingPeriodStart + sharingPeriodEnd } query AdminListingsTableContainerAdminListings( @@ -42,5 +42,19 @@ mutation AdminListingsTableContainerDeleteListing($id: ObjectID!) { } mutation AdminListingsTableContainerUnblockListing($id: ObjectID!) { - unblockListing(id: $id) + unblockListing(id: $id) { + status { + success + errorMessage + } + listing { + id + title + state + images + createdAt + sharingPeriodStart + sharingPeriodEnd + } + } } diff --git a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.stories.tsx index 888bc83c9..e450a4561 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.stories.tsx @@ -8,7 +8,7 @@ import { import { AdminListingsTableContainerAdminListingsDocument, AdminListingsTableContainerDeleteListingDocument, - AdminListingsTableContainerUnblockListingDocument, + AdminListingsTableContainerUnblockListingDocument } from '../../../../../../../generated.tsx'; const meta: Meta = { @@ -56,10 +56,11 @@ const meta: Meta = { }, result: { data: { - unblockItemListing: { - __typename: 'MutationStatus', + unblockListing: { + __typename: 'BlockListingResult', + id: 'listing-1', + state: 'Published', success: true, - errorMessage: null, }, }, }, diff --git a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.stories.tsx index 6da419fed..77586a8e5 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.stories.tsx @@ -5,7 +5,8 @@ import { withMockApolloClient, withMockRouter } from '../../../../../../../test- import { AdminListingsTableContainerAdminListingsDocument, AdminListingsTableContainerDeleteListingDocument, - AdminListingsTableContainerUnblockListingDocument, + BlockListingContainerUnblockListingDocument, + AdminListingsTableContainerUnblockListingDocument } from '../../../../../../../generated.tsx'; const meta = { @@ -49,14 +50,15 @@ const meta = { }, { request: { - query: AdminListingsTableContainerUnblockListingDocument, + query: BlockListingContainerUnblockListingDocument, }, result: { data: { - unblockItemListing: { - __typename: 'MutationStatus', + unblockListing: { + __typename: 'BlockListingResult', + id: 'listing-123', + state: 'Published', success: true, - errorMessage: null, }, }, }, diff --git a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.tsx b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.tsx index 40431241a..8781ba8a1 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.tsx @@ -16,7 +16,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { AdminListingsTableContainerAdminListingsDocument, AdminListingsTableContainerDeleteListingDocument, - AdminListingsTableContainerUnblockListingDocument, + BlockListingContainerUnblockListingDocument, } from '../../../../../../../generated.tsx'; export function AdminViewListing(): ReactElement { @@ -37,7 +37,7 @@ export function AdminViewListing(): ReactElement { ); const [unblockListingMutation] = useMutation( - AdminListingsTableContainerUnblockListingDocument, + BlockListingContainerUnblockListingDocument, ); const [deleteListingMutation] = useMutation( diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.stories.tsx new file mode 100644 index 000000000..4a4c72990 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BlockListingModal } from './block-listing-modal'; + +const meta: Meta = { + title: 'Layouts/Home/View Listing/Block Listing Modal', + component: BlockListingModal, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + visible: { control: 'boolean' }, + loading: { control: 'boolean' }, + onConfirm: { action: 'confirmed' }, + onCancel: { action: 'cancelled' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + visible: true, + listingTitle: 'City Bike', + loading: false, + }, +}; + +export const Loading: Story = { + args: { + visible: true, + listingTitle: 'City Bike', + loading: true, + }, +}; + +export const Closed: Story = { + args: { + visible: false, + listingTitle: 'City Bike', + loading: false, + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.tsx new file mode 100644 index 000000000..8fc09fc18 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.tsx @@ -0,0 +1,55 @@ +import { Button, Modal } from 'antd'; + +interface BlockListingModalProps { + visible: boolean; + listingTitle: string; + onConfirm: () => void; + onCancel: () => void; + loading?: boolean; +} + +export const BlockListingModal: React.FC = ({ + visible, + listingTitle, + onConfirm, + onCancel, + loading = false, +}) => { + const handleOk = () => { + onConfirm(); + }; + + const handleCancel = () => { + onCancel(); + }; + + return ( + + Cancel + , + , + ]} + > +

+ You are about to block the listing: {listingTitle} +

+

+ Are you sure you want to block this listing? This will make it + unavailable to all users except administrators. +

+
+ ); +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.graphql b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.graphql new file mode 100644 index 000000000..1a8f872d8 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.graphql @@ -0,0 +1,25 @@ +mutation BlockListingContainerBlockListing($id: ObjectID!) { + blockListing(id: $id) { + status { + success + errorMessage + } + listing { + id + state + } + } +} + +mutation BlockListingContainerUnblockListing($id: ObjectID!) { + unblockListing(id: $id) { + status { + success + errorMessage + } + listing { + id + state + } + } +} diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.stories.tsx new file mode 100644 index 000000000..071e4bf3c --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.stories.tsx @@ -0,0 +1,144 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { + BlockListingContainerBlockListingDocument, + BlockListingContainerUnblockListingDocument, + ViewListingDocument, +} from '../../../../../generated.tsx'; +import { withMockApolloClient } from '../../../../../test-utils/storybook-decorators.tsx'; +import { BlockListingButton } from './block-listing.container.tsx'; + +const meta: Meta = { + title: 'Containers/BlockListingButton', + component: BlockListingButton, + parameters: { + layout: 'centered', + apolloClient: { + mocks: [ + { + request: { + query: BlockListingContainerBlockListingDocument, + variables: { id: 'listing-1' }, + }, + result: { + data: { + blockListing: { + __typename: 'ItemListing', + id: 'listing-1', + isBlocked: true, + }, + }, + }, + }, + { + request: { + query: BlockListingContainerUnblockListingDocument, + variables: { id: 'listing-1' }, + }, + result: { + data: { + unblockListing: { + __typename: 'ItemListing', + id: 'listing-1', + isBlocked: false, + }, + }, + }, + }, + { + request: { + query: ViewListingDocument, + variables: { id: 'listing-1' }, + }, + result: { + data: { + itemListing: { + __typename: 'ItemListing', + id: 'listing-1', + title: 'City Bike', + description: 'A great city bike', + category: 'Sports & Outdoors', + location: 'Toronto, ON', + state: 'Published', + images: [], + sharingPeriodStart: '2025-01-01', + sharingPeriodEnd: '2025-12-31', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + isBlocked: false, + sharer: { + __typename: 'PersonalUser', + id: 'user-1', + profile: { + firstName: 'John', + lastName: 'Doe', + }, + }, + }, + }, + }, + }, + ], + }, + }, + decorators: [withMockApolloClient], + tags: ['autodocs'], + argTypes: { + listingId: { control: 'text' }, + listingTitle: { control: 'text' }, + isBlocked: { control: 'boolean' }, + sharerName: { control: 'text' }, + renderModals: { control: 'boolean' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const BlockButton: Story = { + args: { + listingId: 'listing-1', + listingTitle: 'City Bike', + isBlocked: false, + sharerName: 'user-1', + renderModals: true, + }, +}; + +export const UnblockButton: Story = { + args: { + listingId: 'listing-1', + listingTitle: 'City Bike', + isBlocked: true, + sharerName: 'user-1', + renderModals: true, + }, +}; + +export const BlockButtonWithoutModals: Story = { + args: { + listingId: 'listing-1', + listingTitle: 'City Bike', + isBlocked: false, + sharerName: 'user-1', + renderModals: false, + }, +}; + +export const UnblockButtonWithoutModals: Story = { + args: { + listingId: 'listing-1', + listingTitle: 'City Bike', + isBlocked: true, + sharerName: 'user-1', + renderModals: false, + }, +}; + +export const WithDefaultSharer: Story = { + args: { + listingId: 'listing-1', + listingTitle: 'Professional Camera', + isBlocked: false, + renderModals: true, + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.tsx new file mode 100644 index 000000000..795c3729f --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.tsx @@ -0,0 +1,119 @@ +import { useMutation } from '@apollo/client/react'; +import { message } from 'antd'; +import { + BlockListingContainerBlockListingDocument, + BlockListingContainerUnblockListingDocument, + ViewListingDocument, +} from '../../../../../generated.tsx'; +import { BlockListingModal } from './block-listing-modal.tsx'; +import { UnblockListingModal } from './unblock-listing-modal.tsx'; +import { useState } from 'react'; +import { Button } from 'antd'; + +interface BlockListingButtonProps { + listingId: string; + listingTitle: string; + isBlocked: boolean; + sharerName?: string; + renderModals?: boolean; +} + +export const BlockListingButton: React.FC = ({ + listingId, + listingTitle, + isBlocked, + sharerName = 'Unknown', + renderModals = true, +}) => { + const [blockModalVisible, setBlockModalVisible] = useState(false); + const [unblockModalVisible, setUnblockModalVisible] = useState(false); + + const [blockListing, { loading: blockLoading }] = useMutation( + BlockListingContainerBlockListingDocument, + { + onCompleted: () => { + message.success('Listing blocked successfully'); + }, + onError: (error) => { + message.error(`Failed to block listing: ${error.message}`); + }, + refetchQueries: [ + { + query: ViewListingDocument, + variables: { id: listingId }, + }, + ], + }, + ); + + const [unblockListing, { loading: unblockLoading }] = useMutation( + BlockListingContainerUnblockListingDocument, + { + onCompleted: () => { + message.success('Listing unblocked successfully'); + }, + onError: (error) => { + message.error(`Failed to unblock listing: ${error.message}`); + }, + refetchQueries: [ + { + query: ViewListingDocument, + variables: { id: listingId }, + }, + ], + }, + ); + + const handleBlockConfirm = async () => { + await blockListing({ variables: { id: listingId } }); + setBlockModalVisible(false); + }; + + const handleUnblockConfirm = async () => { + await unblockListing({ variables: { id: listingId } }); + setUnblockModalVisible(false); + }; +console.log('isBlocked:', isBlocked); + return ( + <> + {isBlocked ? ( + + ) : ( + + )} + {renderModals && ( + <> + setBlockModalVisible(false)} + loading={blockLoading} + /> + setUnblockModalVisible(false)} + loading={unblockLoading} + /> + + )} + + ); +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.container.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.container.tsx index d27e33e07..888f42acc 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.container.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.container.tsx @@ -10,11 +10,15 @@ interface SharerInformationContainerProps { className?: string; showIconOnly?: boolean; currentUserId?: string | null; + isAdmin?: boolean; + isBlocked?: boolean; + sharerName?: string; + listingTitle?: string; } export const SharerInformationContainer: React.FC< SharerInformationContainerProps -> = ({ sharerId, listingId, isOwner, sharedTimeAgo, className, currentUserId }) => { +> = ({ sharerId, listingId, isOwner, sharedTimeAgo, className, currentUserId, isAdmin, isBlocked, sharerName, listingTitle }) => { const { data, loading, error } = useQuery( SharerInformationContainerDocument, { @@ -41,6 +45,7 @@ export const SharerInformationContainer: React.FC< sharedTimeAgo={sharedTimeAgo} className={className} currentUserId={currentUserId} + isAdmin={isAdmin} /> ); } @@ -66,6 +71,10 @@ export const SharerInformationContainer: React.FC< sharedTimeAgo={sharedTimeAgo} className={className} currentUserId={currentUserId} + isAdmin={isAdmin} + isBlocked={isBlocked} + sharerName={sharerName} + listingTitle={listingTitle} /> ); }; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.stories.tsx index 3772aa7e2..63862ac9e 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.stories.tsx @@ -170,6 +170,28 @@ export const MessageButtonWithError: Story = { }, }; +export const AdminView: Story = { + args: { + isAdmin: true, + isBlocked: false, + sharerName: 'John Doe', + }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toBeTruthy(); + }, +}; + +export const AdminViewWithBlockedListing: Story = { + args: { + isAdmin: true, + isBlocked: true, + sharerName: 'John Doe', + }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toBeTruthy(); + }, +}; + export const MobileView: Story = { parameters: { viewport: { diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.tsx index bd3835be9..934ff1229 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.tsx @@ -8,6 +8,7 @@ import type { CreateConversationMutation, CreateConversationMutationVariables, } from '../../../../../../generated.tsx'; +import { BlockListingButton } from '../block-listing.container.tsx'; type Sharer = { id: string; @@ -22,6 +23,10 @@ interface SharerInformationProps { sharedTimeAgo?: string; className?: string; currentUserId?: string | null; + isAdmin?: boolean; + isBlocked?: boolean; + sharerName?: string; + listingTitle?: string; } export const SharerInformation: React.FC = ({ @@ -31,7 +36,13 @@ export const SharerInformation: React.FC = ({ sharedTimeAgo = '2 days ago', className = '', currentUserId, + isAdmin, + isBlocked = false, + sharerName, + listingTitle = '' + }) => { + console.log('Rendering SharerInformation with isBlocked:', isBlocked, isBlocked); const [isMobile, setIsMobile] = useState(false); const navigate = useNavigate(); @@ -153,6 +164,17 @@ export const SharerInformation: React.FC = ({ )} + + {isAdmin && ( + + )} + ); }; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.stories.tsx new file mode 100644 index 000000000..a16e43f97 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { UnblockListingModal } from './unblock-listing-modal'; + +const meta: Meta = { + title: 'Layouts/Home/View Listing/Unblock Listing Modal', + component: UnblockListingModal, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + visible: { control: 'boolean' }, + loading: { control: 'boolean' }, + onConfirm: { action: 'confirmed' }, + onCancel: { action: 'cancelled' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + visible: true, + listingTitle: 'City Bike', + listingSharer: 'Patrick G.', + loading: false, + }, +}; + +export const WithBlockInfo: Story = { + args: { + visible: true, + listingTitle: 'City Bike', + listingSharer: 'Patrick G.', + blockReason: 'Profanity', + blockDescription: + 'Your listing has been blocked due to profanity in the description. In order to have your listing unblocked, please update your listing to comply with our guidelines and submit an appeal.', + loading: false, + }, +}; + +export const Loading: Story = { + args: { + visible: true, + listingTitle: 'City Bike', + listingSharer: 'Patrick G.', + loading: true, + }, +}; + +export const Closed: Story = { + args: { + visible: false, + listingTitle: 'City Bike', + listingSharer: 'Patrick G.', + loading: false, + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.tsx new file mode 100644 index 000000000..8539df49e --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.tsx @@ -0,0 +1,40 @@ +import { Modal } from 'antd'; + +interface UnblockListingModalProps { + visible: boolean; + listingTitle: string; + listingSharer: string; + blockReason?: string; + blockDescription?: string; + onConfirm: () => void; + onCancel: () => void; + loading?: boolean; +} + +export const UnblockListingModal: React.FC = ({ + visible, + listingTitle, + listingSharer, + onConfirm, + onCancel, + loading = false, +}) => { + return ( + +
+

+ Are you sure you want to unblock {listingTitle}{' '} + posted by {listingSharer}? +

+
+
+ ); +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.graphql b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.graphql index 7d6c9e054..d2d2bc3bd 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.graphql +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.graphql @@ -1,62 +1,76 @@ # View Listing Query (backend-aligned) fragment ViewListingContainerListingFields on ItemListing { - id - title - description - category - listingType - location - sharingPeriodStart - sharingPeriodEnd - state - images - createdAt - updatedAt - reports - sharingHistory - schemaVersion - sharer { - ... on PersonalUser { - id - } - ... on AdminUser { - id - } - } + id + title + description + category + listingType + location + sharingPeriodStart + sharingPeriodEnd + state + images + createdAt + updatedAt + reports + sharingHistory + schemaVersion + sharer { + ... on PersonalUser { + id + account { + profile { + firstName + lastName + } + } + } + ... on AdminUser { + id + account { + profile { + firstName + lastName + } + } + } + } } query ViewListing($id: ObjectID!) { - itemListing(id: $id) { - ...ViewListingContainerListingFields - } + itemListing(id: $id) { + ...ViewListingContainerListingFields + } } query ViewListingCurrentUser { - currentUser { - ... on PersonalUser { - id - userType - } - ... on AdminUser { - id - userType - } - } + currentUser { + ... on PersonalUser { + id + userType + userIsAdmin + } + ... on AdminUser { + id + userType + userIsAdmin + } + } } fragment ViewListingContainerReservationFields on ReservationRequest { - id - state - reservationPeriodStart - reservationPeriodEnd + id + state + reservationPeriodStart + reservationPeriodEnd } query ViewListingActiveReservationRequestForListing( - $listingId: ObjectID! - $reserverId: ObjectID! + $listingId: ObjectID! + $reserverId: ObjectID! ) { - myActiveReservationForListing(listingId: $listingId, userId: $reserverId) { - ...ViewListingContainerReservationFields - } + myActiveReservationForListing(listingId: $listingId, userId: $reserverId) { + ...ViewListingContainerReservationFields + } } diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.stories.tsx index 49001e521..34bb68cdf 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.stories.tsx @@ -1,15 +1,17 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { + BlockListingContainerBlockListingDocument, + BlockListingContainerUnblockListingDocument, + ViewListingActiveReservationRequestForListingDocument, + ViewListingCurrentUserDocument, + ViewListingDocument, +} from '../../../../../generated.tsx'; import { expect, within, waitFor } from 'storybook/test'; -import { ViewListingContainer } from './view-listing.container.tsx'; import { withMockApolloClient, withMockRouter, } from '../../../../../test-utils/storybook-decorators.tsx'; -import { - ViewListingDocument, - ViewListingCurrentUserDocument, - ViewListingActiveReservationRequestForListingDocument, -} from '../../../../../generated.tsx'; +import { ViewListingContainer } from './view-listing.container.tsx'; const mockListing = { __typename: 'ItemListing', @@ -37,6 +39,7 @@ const mockListing = { const mockCurrentUser = { __typename: 'PersonalUser', id: 'user-2', + userIsAdmin: false, }; const meta: Meta = { @@ -78,6 +81,36 @@ const meta: Meta = { }, }, }, + { + request: { + query: BlockListingContainerBlockListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + blockListing: { + id: '1', + state: 'Blocked', + success: true, + }, + }, + }, + }, + { + request: { + query: BlockListingContainerUnblockListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + unblockListing: { + id: '1', + state: 'Published', + success: true, + }, + }, + }, + }, ], }, }, @@ -158,3 +191,173 @@ export const Loading: Story = { expect(loadingSpinner ?? canvasElement).toBeTruthy(); }, }; + +export const AdminUser: Story = { + args: { + isAuthenticated: true, + }, + parameters: { + apolloClient: { + mocks: [ + { + request: { + query: ViewListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + itemListing: mockListing, + }, + }, + }, + { + request: { + query: ViewListingCurrentUserDocument, + }, + result: { + data: { + currentUser: { + ...mockCurrentUser, + userIsAdmin: true, + }, + }, + }, + }, + { + request: { + query: ViewListingActiveReservationRequestForListingDocument, + variables: { listingId: '1', reserverId: 'user-2' }, + }, + result: { + data: { + myActiveReservationForListing: null, + }, + }, + }, + { + request: { + query: BlockListingContainerBlockListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + blockListing: { + id: '1', + state: 'Blocked', + }, + }, + }, + }, + ], + }, + }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toBeTruthy(); + }, +}; + +export const BlockedListing: Story = { + args: { + isAuthenticated: true, + }, + parameters: { + apolloClient: { + mocks: [ + { + request: { + query: ViewListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + itemListing: { + ...mockListing, + state: 'Blocked', + }, + }, + }, + }, + { + request: { + query: ViewListingCurrentUserDocument, + }, + result: { + data: { + currentUser: mockCurrentUser, + }, + }, + }, + ], + }, + }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toBeTruthy(); + }, +}; + +export const BlockedListingAsAdmin: Story = { + args: { + isAuthenticated: true, + }, + parameters: { + apolloClient: { + mocks: [ + { + request: { + query: ViewListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + itemListing: { + ...mockListing, + state: 'Blocked', + }, + }, + }, + }, + { + request: { + query: ViewListingCurrentUserDocument, + }, + result: { + data: { + currentUser: { + ...mockCurrentUser, + userIsAdmin: true, + }, + }, + }, + }, + { + request: { + query: ViewListingActiveReservationRequestForListingDocument, + variables: { listingId: '1', reserverId: 'user-2' }, + }, + result: { + data: { + myActiveReservationForListing: null, + }, + }, + }, + { + request: { + query: BlockListingContainerUnblockListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + unblockListing: { + id: '1', + state: 'Published', + }, + }, + }, + }, + ], + }, + }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toBeTruthy(); + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.tsx index 13213e5ad..852f3b535 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.tsx @@ -41,29 +41,29 @@ export const ViewListingContainer: React.FC = ( fetchPolicy: 'cache-first', }); - const { - data: currentUserData, - loading: currentUserLoading, - } = useQuery(ViewListingCurrentUserDocument, { - skip: !props.isAuthenticated, // Skip if not authenticated - }); + const { data: currentUserData, loading: currentUserLoading } = useQuery( + ViewListingCurrentUserDocument, + { + skip: !props.isAuthenticated, // Skip if not authenticated + }, + ); const reserverId = currentUserData?.currentUser?.id ?? ''; const skip = !reserverId || !listingId; - const { - data: userReservationData, - loading: userReservationLoading, - } = useQuery(ViewListingActiveReservationRequestForListingDocument, { - variables: { listingId: listingId ?? '', reserverId }, - skip, - }); + const { data: userReservationData, loading: userReservationLoading } = + useQuery(ViewListingActiveReservationRequestForListingDocument, { + variables: { listingId: listingId ?? '', reserverId }, + skip, + }); const sharedTimeAgo = listingData?.itemListing?.createdAt ? computeTimeAgo(listingData.itemListing.createdAt) : undefined; const userIsSharer = false; + const isAdmin = currentUserData?.currentUser?.__typename === 'AdminUser'; + return ( = ( userReservationRequest={ userReservationData?.myActiveReservationForListing } + isAdmin={isAdmin} /> } /> diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.stories.tsx index 953709315..8a83024c9 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { ViewListing } from './view-listing.tsx'; import { type ItemListing, ViewListingImageGalleryGetImagesDocument, @@ -9,6 +8,8 @@ import { withMockApolloClient, withMockRouter, } from '../../../../../test-utils/storybook-decorators.tsx'; +import { ViewListing } from './view-listing.tsx'; + // Local mock listing data (removed dependency on DUMMY_LISTINGS) const baseListingId = 'mock-listing-id-1'; const MOCK_LISTING_BASE: ItemListing = { @@ -46,7 +47,7 @@ const MOCK_LISTING_BASE: ItemListing = { schemaVersion: '1.0', userType: 'personal-user', }, - listingType: 'item-listing', + listingType: 'item-listing', }; const mocks = [ @@ -107,20 +108,37 @@ type Story = StoryObj; const baseListing = MOCK_LISTING_BASE; +// Shared admin configuration +const adminBaseArgs = { + isAdmin: true, + isAuthenticated: true, + currentUserId: 'mock-admin-id', +}; + +// Blocked listing variant +const adminBlockedListing = { + ...baseListing, + state: 'Blocked' as string, +}; + export const Default: Story = { args: { listing: baseListing, userIsSharer: false, isAuthenticated: false, + currentUserId: '', userReservationRequest: null, sharedTimeAgo: '2 days ago', - }}; + isAdmin: false, + }, +}; export const AsReserver: Story = { args: { ...Default.args, userIsSharer: false, isAuthenticated: true, + currentUserId: 'mock-reserver-id', userReservationRequest: null, }, }; @@ -130,6 +148,39 @@ export const AsOwner: Story = { ...Default.args, userIsSharer: true, isAuthenticated: true, + currentUserId: 'mock-sharer-id', userReservationRequest: null, }, }; + +export const AsAdmin: Story = { + args: { + ...Default.args, + ...adminBaseArgs, + listing: baseListing, + }, +}; + +export const BlockedListingAsAdmin: Story = { + args: { + ...Default.args, + ...adminBaseArgs, + listing: adminBlockedListing, + }, +}; + +export const BlockListing: Story = { + args: { + ...Default.args, + ...adminBaseArgs, + listing: baseListing, + }, +}; + +export const UnblockListing: Story = { + args: { + ...Default.args, + ...adminBaseArgs, + listing: adminBlockedListing, + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.tsx index 225bd910b..0ab2ddfbf 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.tsx @@ -1,12 +1,12 @@ -import { Row, Col, Button } from 'antd'; import { LeftOutlined } from '@ant-design/icons'; -import { ListingImageGalleryContainer } from './listing-image-gallery/listing-image-gallery.container.tsx'; -import { SharerInformationContainer } from './sharer-information/sharer-information.container.tsx'; -import { ListingInformationContainer } from './listing-information/listing-information.container.tsx'; +import { Alert, Button, Col, Row } from 'antd'; import type { ItemListing, ViewListingActiveReservationRequestForListingQuery, } from '../../../../../generated.tsx'; +import { ListingImageGalleryContainer } from './listing-image-gallery/listing-image-gallery.container.tsx'; +import { ListingInformationContainer } from './listing-information/listing-information.container.tsx'; +import { SharerInformationContainer } from './sharer-information/sharer-information.container.tsx'; interface ViewListingProps { listing: ItemListing; @@ -17,6 +17,7 @@ interface ViewListingProps { | ViewListingActiveReservationRequestForListingQuery['myActiveReservationForListing'] | null; sharedTimeAgo?: string; + isAdmin: boolean; } export const ViewListing: React.FC = ({ @@ -26,9 +27,12 @@ export const ViewListing: React.FC = ({ currentUserId, userReservationRequest, sharedTimeAgo, + isAdmin, }) => { - // Mock sharer info (since ItemListing.sharer is just an ID) - const sharer = listing.sharer; + + const { sharer } = listing; + + const isBlocked = listing.state === 'Blocked'; const handleBack = () => { window.location.href = '/'; @@ -86,6 +90,8 @@ export const ViewListing: React.FC = ({ paddingBottom: 75, boxSizing: 'border-box', width: '100%', + opacity: isBlocked && !isAdmin ? 0.5 : 1, + pointerEvents: isBlocked && !isAdmin ? 'none' : 'auto', }} gutter={[0, 24]} className="view-listing-responsive" @@ -101,15 +107,29 @@ export const ViewListing: React.FC = ({ Back + {isBlocked && ( + + + + )} {/* Sharer Info at top, clickable to profile */} @@ -137,16 +157,16 @@ export const ViewListing: React.FC = ({ className="listing-gallery-responsive" /> - {/* Right: Info/Form */} - - - + {/* Right: Info/Form */} + + + diff --git a/package.json b/package.json index a730e69c7..ea65f8fa7 100644 --- a/package.json +++ b/package.json @@ -85,4 +85,4 @@ "vite": "catalog:", "vitest": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/cellix/mock-oauth2-server/package.json b/packages/cellix/mock-oauth2-server/package.json index 1f402e89d..ec3a6fd65 100644 --- a/packages/cellix/mock-oauth2-server/package.json +++ b/packages/cellix/mock-oauth2-server/package.json @@ -1,31 +1,31 @@ { - "name": "@cellix/mock-oauth2-server", - "version": "1.0.0", - "author": "", - "license": "MIT", - "description": "Local OAuth2/OIDC mock server for dev/testing", - "type": "module", - "main": "dist/src/index.js", - "types": "dist/src/index.d.ts", - "scripts": { - "build": "tsc --build", - "clean": "rimraf dist node_modules tsconfig.tsbuildinfo && tsc --build --clean", - "lint": "biome lint", - "format": "biome format --write", - "start": "node dist/src/index.js", - "dev": "tsx watch src/index.ts", - "prebuild": "biome lint" - }, - "dependencies": { - "dotenv": "^16.4.5", - "express": "^4.22.1", - "jose": "^5.9.6" - }, - "devDependencies": { - "@cellix/typescript-config": "workspace:*", - "@types/express": "^4.17.23", - "rimraf": "^6.0.1", - "tsx": "^4.20.3", - "typescript": "^5.8.3" - } -} \ No newline at end of file + "name": "@cellix/mock-oauth2-server", + "version": "1.0.0", + "author": "", + "license": "MIT", + "description": "Local OAuth2/OIDC mock server for dev/testing", + "type": "module", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "scripts": { + "build": "tsc --build", + "clean": "rimraf dist node_modules tsconfig.tsbuildinfo && tsc --build --clean", + "lint": "biome lint", + "format": "biome format --write", + "start": "node dist/src/index.js", + "dev": "tsx watch src/index.ts", + "prebuild": "biome lint" + }, + "dependencies": { + "dotenv": "^16.4.5", + "express": "^4.22.1", + "jose": "^5.9.6" + }, + "devDependencies": { + "@cellix/typescript-config": "workspace:*", + "@types/express": "^4.17.23", + "rimraf": "^6.0.1", + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/cellix/mock-payment-server/package.json b/packages/cellix/mock-payment-server/package.json index bc8c4efa1..444d36759 100644 --- a/packages/cellix/mock-payment-server/package.json +++ b/packages/cellix/mock-payment-server/package.json @@ -1,28 +1,28 @@ { - "name": "@cellix/mock-payment-server", - "version": "1.0.0", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "type": "module", - "license": "MIT", - "dependencies": { - "@cellix/payment-service": "workspace:*", - "express": "^4.18.2", - "jose": "^5.10.0", - "jsonwebtoken": "^9.0.3" - }, - "devDependencies": { - "@cellix/typescript-config": "workspace:*", - "@types/express": "^4.17.21", - "@types/jsonwebtoken": "^9.0.10", - "ts-node": "^10.9.2", - "ts-node-dev": "^2.0.0", - "tsc-watch": "^7.1.1", - "typescript": "^5.0.0" - }, - "scripts": { - "start": "node dist/src/index.js", - "build": "tsc --build && node dist/src/copy-assets.js", - "clean": "rimraf node_modules package-lock.json dist" - } + "name": "@cellix/mock-payment-server", + "version": "1.0.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "license": "MIT", + "dependencies": { + "express": "^4.22.0", + "jose": "^5.10.0", + "jsonwebtoken": "^9.0.3", + "@cellix/payment-service": "workspace:*" + }, + "devDependencies": { + "@cellix/typescript-config": "workspace:*", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.10", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "tsc-watch": "^7.1.1", + "typescript": "^5.0.0" + }, + "scripts": { + "start": "node dist/src/index.js", + "build": "tsc --build && node dist/src/copy-assets.js", + "clean": "rimraf node_modules package-lock.json dist" + } } diff --git a/packages/cellix/mongoose-seedwork/vitest.config.ts b/packages/cellix/mongoose-seedwork/vitest.config.ts index 83d1c15d6..06f35a998 100644 --- a/packages/cellix/mongoose-seedwork/vitest.config.ts +++ b/packages/cellix/mongoose-seedwork/vitest.config.ts @@ -6,7 +6,8 @@ export default mergeConfig( defineConfig({ // Add package-specific overrides here if needed test: { - include: ['src/**/*.test.ts', 'tests/integration/**/*.test.ts'], + include: ['src/**/*.test.ts'], + exclude: ['tests/integration/**/*.test.ts'], retry: 0, coverage: { exclude: ['**/index.ts', '**/base.ts'], diff --git a/packages/cellix/typescript-config/node.json b/packages/cellix/typescript-config/node.json index 5a14af24f..7a54bd5c6 100644 --- a/packages/cellix/typescript-config/node.json +++ b/packages/cellix/typescript-config/node.json @@ -1,6 +1,6 @@ { - "extends": "@cellix/typescript-config/tsconfig-base.json", - "compilerOptions": { - "types": ["node"] - } -} \ No newline at end of file + "extends": "./tsconfig-base.json", + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/sthrift/application-services/src/contexts/listing/item/block.test.ts b/packages/sthrift/application-services/src/contexts/listing/item/block.test.ts new file mode 100644 index 000000000..f2730d382 --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/listing/item/block.test.ts @@ -0,0 +1,76 @@ +import type { Domain } from '@sthrift/domain'; +import type { DataSources } from '@sthrift/persistence'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { block } from './block.ts'; + +describe('listing/item', () => { + describe('block', () => { + let mockRepo: { + getById: ReturnType; + save: ReturnType; + }; + + let mockListing: { + blocked?: boolean; + }; + + let mockDataSources: DataSources; + + beforeEach(() => { + mockListing = {}; + + mockRepo = { + getById: vi.fn().mockResolvedValue(mockListing), + save: vi + .fn() + .mockResolvedValue( + mockListing as Domain.Contexts.Listing.ItemListing.ItemListingEntityReference, + ), + }; + + mockDataSources = { + domainDataSource: { + Listing: { + ItemListing: { + ItemListingUnitOfWork: { + withScopedTransaction: vi + .fn() + .mockImplementation(async (callback) => { + return await callback(mockRepo); + }), + }, + }, + }, + }, + } as unknown as DataSources; + }); + + it('should block an item listing', async () => { + const command = { id: 'test-listing-id' }; + const blockFn = block(mockDataSources); + + const result = await blockFn(command); + + expect(mockRepo.getById).toHaveBeenCalledWith('test-listing-id'); + expect(mockListing.blocked).toBe(true); + expect(mockRepo.save).toHaveBeenCalledWith(mockListing); + expect(result).toBe(mockListing); + }); + + it('should throw an error if listing is not found', async () => { + mockRepo.getById.mockResolvedValue(null); + const command = { id: 'non-existent-id' }; + const blockFn = block(mockDataSources); + + await expect(blockFn(command)).rejects.toThrow('Listing not found'); + }); + + it('should throw an error if listing is not saved', async () => { + mockRepo.save.mockResolvedValue(undefined); + const command = { id: 'test-listing-id' }; + const blockFn = block(mockDataSources); + + await expect(blockFn(command)).rejects.toThrow('ItemListing not updated'); + }); + }); +}); diff --git a/packages/sthrift/application-services/src/contexts/listing/item/block.ts b/packages/sthrift/application-services/src/contexts/listing/item/block.ts new file mode 100644 index 000000000..d0df7af60 --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/listing/item/block.ts @@ -0,0 +1,31 @@ +import type { Domain } from '@sthrift/domain'; +import type { DataSources } from '@sthrift/persistence'; + +export interface ItemListingBlockCommand { + id: string; +} + +export const block = (dataSources: DataSources) => { + return async ( + command: ItemListingBlockCommand, + ): Promise => { + let itemListingToReturn: + | Domain.Contexts.Listing.ItemListing.ItemListingEntityReference + | undefined; + await dataSources.domainDataSource.Listing.ItemListing.ItemListingUnitOfWork.withScopedTransaction( + async (repo) => { + const listing = await repo.getById(command.id); + if (!listing) { + throw new Error('Listing not found'); + } + + listing.blocked = true; + itemListingToReturn = await repo.save(listing); + }, + ); + if (!itemListingToReturn) { + throw new Error('ItemListing not updated'); + } + return itemListingToReturn; + }; +}; diff --git a/packages/sthrift/application-services/src/contexts/listing/item/features/block.feature b/packages/sthrift/application-services/src/contexts/listing/item/features/block.feature new file mode 100644 index 000000000..81154600e --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/listing/item/features/block.feature @@ -0,0 +1,30 @@ +Feature: Block Item Listing + As an admin user + I want to block a specific item listing + So that I can prevent it from being visible or active in the system + + Background: + Given a data source with listing repositories + And a listing repository with ItemListingUnitOfWork transaction support + + Scenario: Successfully block an existing item listing + Given an item listing exists with ID "test-listing-id" + And the listing is not blocked + When I block the listing with ID "test-listing-id" + Then the listing should be retrieved by its ID + And the blocked property should be set to true + And the listing should be saved to the repository + And the updated listing should be returned + + Scenario: Attempt to block a non-existent listing + Given no listing exists with ID "non-existent-id" + When I attempt to block the listing with ID "non-existent-id" + Then an error should be thrown with message "Listing not found" + And the repository save method should not be called + + Scenario: Save operation fails to update the listing + Given an item listing exists with ID "test-listing-id" + And the save operation will not persist the listing + When I block the listing with ID "test-listing-id" + Then an error should be thrown with message "ItemListing not updated" + And the operation should fail with the update error diff --git a/packages/sthrift/application-services/src/contexts/listing/item/index.ts b/packages/sthrift/application-services/src/contexts/listing/item/index.ts index 96bf35318..be9b6ac92 100644 --- a/packages/sthrift/application-services/src/contexts/listing/item/index.ts +++ b/packages/sthrift/application-services/src/contexts/listing/item/index.ts @@ -11,6 +11,7 @@ import { type ItemListingCancelCommand, cancel } from './cancel.ts'; import { type ItemListingDeleteCommand, deleteListings } from './delete.ts'; import { type ItemListingUpdateCommand, update } from './update.ts'; import { type ItemListingUnblockCommand, unblock } from './unblock.ts'; +import { type ItemListingBlockCommand, block } from './block.ts'; import { queryPaged } from './query-paged.ts'; export interface ItemListingApplicationService { @@ -38,6 +39,9 @@ export interface ItemListingApplicationService { unblock: ( command: ItemListingUnblockCommand, ) => Promise; + block: ( + command: ItemListingBlockCommand, + ) => Promise; queryPaged: (command: { page: number; pageSize: number; @@ -63,8 +67,9 @@ export const ItemListing = ( queryAll: queryAll(dataSources), cancel: cancel(dataSources), update: update(dataSources), - deleteListings: deleteListings(dataSources), + deleteListings: deleteListings(dataSources), unblock: unblock(dataSources), + block: block(dataSources), queryPaged: queryPaged(dataSources), }; }; diff --git a/packages/sthrift/application-services/src/contexts/listing/item/query-all.ts b/packages/sthrift/application-services/src/contexts/listing/item/query-all.ts index a5daf9ddc..b07a23416 100644 --- a/packages/sthrift/application-services/src/contexts/listing/item/query-all.ts +++ b/packages/sthrift/application-services/src/contexts/listing/item/query-all.ts @@ -3,6 +3,7 @@ import type { DataSources } from '@sthrift/persistence'; export interface ItemListingQueryAllCommand { fields?: string[]; + isAdmin?: boolean; } export const queryAll = (dataSources: DataSources) => { @@ -12,7 +13,7 @@ export const queryAll = (dataSources: DataSources) => { Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[] > => { return await dataSources.readonlyDataSource.Listing.ItemListing.ItemListingReadRepo.getAll( - { fields: command.fields }, + { fields: command.fields, isAdmin: command.isAdmin ?? false }, ); }; }; diff --git a/packages/sthrift/application-services/src/contexts/listing/item/query-by-id.ts b/packages/sthrift/application-services/src/contexts/listing/item/query-by-id.ts index b1fb396d1..3615231ce 100644 --- a/packages/sthrift/application-services/src/contexts/listing/item/query-by-id.ts +++ b/packages/sthrift/application-services/src/contexts/listing/item/query-by-id.ts @@ -4,6 +4,7 @@ import type { DataSources } from '@sthrift/persistence'; export interface ItemListingQueryByIdCommand { id: string; fields?: string[]; + isAdmin?: boolean; } export const queryById = (dataSources: DataSources) => { @@ -12,7 +13,10 @@ export const queryById = (dataSources: DataSources) => { ): Promise => { return await dataSources.readonlyDataSource.Listing.ItemListing.ItemListingReadRepo.getById( command.id, - { fields: command.fields }, + { + fields: command.fields, + isAdmin: command.isAdmin ?? false, + }, ); }; }; diff --git a/packages/sthrift/application-services/src/contexts/listing/item/query-by-sharer.ts b/packages/sthrift/application-services/src/contexts/listing/item/query-by-sharer.ts index 70d5581c7..0352fe5cf 100644 --- a/packages/sthrift/application-services/src/contexts/listing/item/query-by-sharer.ts +++ b/packages/sthrift/application-services/src/contexts/listing/item/query-by-sharer.ts @@ -4,6 +4,7 @@ import type { DataSources } from '@sthrift/persistence'; export interface ItemListingQueryBySharerCommand { personalUser: string; fields?: string[]; + isAdmin?: boolean; } export const queryBySharer = (dataSources: DataSources) => { @@ -14,6 +15,7 @@ export const queryBySharer = (dataSources: DataSources) => { > => { return await dataSources.readonlyDataSource.Listing.ItemListing.ItemListingReadRepo.getBySharer( command.personalUser, + { isAdmin: command.isAdmin ?? false }, ); }; }; diff --git a/packages/sthrift/application-services/src/contexts/listing/item/query-paged.test.ts b/packages/sthrift/application-services/src/contexts/listing/item/query-paged.test.ts index 193904636..ddffa0fea 100644 --- a/packages/sthrift/application-services/src/contexts/listing/item/query-paged.test.ts +++ b/packages/sthrift/application-services/src/contexts/listing/item/query-paged.test.ts @@ -40,7 +40,6 @@ describe('ItemListing queryPaged', () => { expect(mockGetPaged).toHaveBeenCalledWith({ page: 1, pageSize: 20, - statusFilters: ['Blocked'], }); }); @@ -56,7 +55,6 @@ describe('ItemListing queryPaged', () => { page: 1, pageSize: 10, searchText: 'test search', - statusFilters: ['Blocked'], }); }); @@ -102,7 +100,6 @@ describe('ItemListing queryPaged', () => { page: 1, pageSize: 10, sorter: { field: 'createdAt', order: 'descend' }, - statusFilters: ['Blocked'], }); }); @@ -154,13 +151,14 @@ describe('ItemListing queryPaged', () => { await query({ page: 1, pageSize: 10, + isAdmin: true, }); - expect(mockGetPaged).toHaveBeenCalledWith( - expect.objectContaining({ - statusFilters: ['Blocked'], - }), - ); + expect(mockGetPaged).toHaveBeenCalledWith({ + page: 1, + pageSize: 10, + isAdmin: true, + }); }); it('should not include default statusFilters when sharerId is provided', async () => { diff --git a/packages/sthrift/application-services/src/contexts/listing/item/query-paged.ts b/packages/sthrift/application-services/src/contexts/listing/item/query-paged.ts index fed4e295f..7def29aad 100644 --- a/packages/sthrift/application-services/src/contexts/listing/item/query-paged.ts +++ b/packages/sthrift/application-services/src/contexts/listing/item/query-paged.ts @@ -8,6 +8,7 @@ interface ItemListingQueryPagedCommand { statusFilters?: string[]; sharerId?: string; sorter?: { field: string; order: 'ascend' | 'descend' }; + isAdmin?: boolean; } export const queryPaged = (dataSources: DataSources) => { @@ -27,6 +28,7 @@ export const queryPaged = (dataSources: DataSources) => { statusFilters?: string[]; sharerId?: string; sorter?: { field: string; order: 'ascend' | 'descend' }; + isAdmin?: boolean; } = { page: command.page, pageSize: command.pageSize @@ -36,13 +38,8 @@ export const queryPaged = (dataSources: DataSources) => { args.searchText = command.searchText; } - // Apply status filters with admin defaults - // If no sharerId (admin query) and no explicit filters, default to admin-relevant statuses if (command.statusFilters) { args.statusFilters = command.statusFilters; - } else if (!command.sharerId) { - // Admin query without explicit filters: default to showing items needing attention - args.statusFilters = ['Blocked']; } if (command.sharerId) { @@ -51,6 +48,9 @@ export const queryPaged = (dataSources: DataSources) => { if (command.sorter) { args.sorter = command.sorter; } + if (command.isAdmin) { + args.isAdmin = command.isAdmin; + } return await dataSources.readonlyDataSource.Listing.ItemListing.ItemListingReadRepo.getPaged(args); }; diff --git a/packages/sthrift/application-services/src/contexts/listing/item/unblock.test.ts b/packages/sthrift/application-services/src/contexts/listing/item/unblock.test.ts index 11a1102d1..15fc336c1 100644 --- a/packages/sthrift/application-services/src/contexts/listing/item/unblock.test.ts +++ b/packages/sthrift/application-services/src/contexts/listing/item/unblock.test.ts @@ -71,7 +71,7 @@ test.for(feature, ({ Background, Scenario }) => { }); Then('the listing should be marked as unblocked', () => { - expect(mockListing.setBlocked).toHaveBeenCalledWith(false); + expect(mockListing.blocked).toBe(false); }); And('the listing should be saved to the repository', () => { diff --git a/packages/sthrift/application-services/src/contexts/listing/item/unblock.ts b/packages/sthrift/application-services/src/contexts/listing/item/unblock.ts index b6efd8536..05dcc6d09 100644 --- a/packages/sthrift/application-services/src/contexts/listing/item/unblock.ts +++ b/packages/sthrift/application-services/src/contexts/listing/item/unblock.ts @@ -19,7 +19,7 @@ export const unblock = (dataSources: DataSources) => { throw new Error('Listing not found'); } - listing.setBlocked(false); + listing.blocked = false; itemListingToReturn = await repo.save(listing); }, ); diff --git a/packages/sthrift/data-sources-mongoose-models/src/models/listing/item-listing.model.ts b/packages/sthrift/data-sources-mongoose-models/src/models/listing/item-listing.model.ts index 22403ed0a..715ea8f4a 100644 --- a/packages/sthrift/data-sources-mongoose-models/src/models/listing/item-listing.model.ts +++ b/packages/sthrift/data-sources-mongoose-models/src/models/listing/item-listing.model.ts @@ -36,7 +36,7 @@ export const LISTING_STATE_ENUM = [ 'Cancelled', 'Draft', 'Expired', - 'Blocked', + 'Blocked' ] as const; export const ItemListingSchema = new Schema< diff --git a/packages/sthrift/domain/src/domain/contexts/listing/item/features/item-listing.feature b/packages/sthrift/domain/src/domain/contexts/listing/item/features/item-listing.feature index 23e3e803d..b1d7d39f2 100644 --- a/packages/sthrift/domain/src/domain/contexts/listing/item/features/item-listing.feature +++ b/packages/sthrift/domain/src/domain/contexts/listing/item/features/item-listing.feature @@ -138,11 +138,6 @@ Feature: ItemListing When I call setBlocked(true) Then the listing's state should be "Blocked" - Scenario: Unblocking a listing with permission - Given an ItemListing aggregate with permission to publish item listing that is currently blocked - When I call setBlocked(false) - Then the listing's state should be "Active" - Scenario: Blocking already blocked listing Given an ItemListing aggregate with permission to publish item listing that is already blocked When I call setBlocked(true) again @@ -173,6 +168,110 @@ Feature: ItemListing When I set the listingType to "premium-listing" Then the listingType should be updated to "premium-listing" + Scenario: Loading sharer asynchronously + Given an ItemListing aggregate + When I call loadSharer() + Then sharer should be loaded + + Scenario: Getting entity reference + Given an ItemListing aggregate + When I call getEntityReference() + Then it should return ItemListingEntityReference + + Scenario: Accessing displayLocation getter + Given an ItemListing aggregate with location "San Francisco" + When I access displayLocation + Then it should return the same location value + + Scenario: Setting blocked state using setter + Given an ItemListing aggregate with permission to publish item listing + When I set blocked = true using the setter + Then the listing's state should be "Blocked" + + Scenario: Setting blocked state to false using setter + Given an ItemListing aggregate with permission to publish item listing that is blocked + When I set blocked = false using the setter + Then the listing's state should be "Active" + + Scenario: Creating new instance with images + Given a new ItemListing aggregate factory method with images + When I access the images + Then images array should contain all provided images + + Scenario: Creating new instance without images + Given a new ItemListing aggregate factory method without images + When I access the images + Then images array should be empty + + Scenario: Getting reports count when not set + Given an ItemListing aggregate without reports + When I access reports property + Then it should return 0 + + Scenario: Getting reports count when set + Given an ItemListing aggregate with 5 reports + When I access reports property + Then it should return 5 + + Scenario: Getting sharingHistory when empty + Given an ItemListing aggregate without sharing history + When I access sharingHistory property + Then it should return an empty array + + Scenario: Getting sharingHistory when populated + Given an ItemListing aggregate with sharing history + When I access sharingHistory property + Then it should return the sharing history array + + Scenario: Accessing createdAt timestamp + Given an ItemListing aggregate with a specific createdAt timestamp + When I access createdAt property + Then it should return the correct timestamp + + Scenario: Accessing updatedAt timestamp + Given an ItemListing aggregate with a specific updatedAt timestamp + When I access updatedAt property + Then it should return the correct timestamp + + Scenario: Accessing schemaVersion + Given an ItemListing aggregate with schemaVersion "2.0.0" + When I access schemaVersion property + Then it should return "2.0.0" + + Scenario: Checking isActive when state is Active + Given an ItemListing aggregate in Active state + When I access isActive property + Then it should return true + + Scenario: Checking isActive when state is Paused + Given an ItemListing aggregate in Paused state + When I access isActive property + Then it should return false + + Scenario: Checking isActive when state is Blocked + Given an ItemListing aggregate in Blocked state + When I access isActive property + Then it should return false + + Scenario: Getting polymorphic sharer as PersonalUser + Given an ItemListing aggregate with PersonalUser sharer + When I access the sharer property + Then it should instantiate as PersonalUser + + Scenario: Getting title returns string + Given an ItemListing aggregate with title "Test Item" + When I access the title property + Then it should return "Test Item" + + Scenario: Setting title when isNew is true + Given a new ItemListing instance created via getNewInstance + When I set the title to "New Title" + Then the title should be updated without permission check + + Scenario: Setting state directly + Given an ItemListing aggregate + When I set state directly to "Drafted" + Then the state should be "Drafted" Scenario: Getting expiresAt from item listing Given an ItemListing aggregate with expiresAt set When I access the expiresAt property diff --git a/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.test.ts b/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.test.ts index c7c6f9b1b..f6a032387 100644 --- a/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.test.ts +++ b/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.test.ts @@ -20,6 +20,7 @@ function makePassport( canPublishItemListing = true, canUnpublishItemListing = true, canDeleteItemListing = true, + canViewBlockedItemListing = true, ): Passport { return vi.mocked({ listing: { @@ -30,6 +31,7 @@ function makePassport( canPublishItemListing: boolean; canUnpublishItemListing: boolean; canDeleteItemListing: boolean; + canViewBlockedItemListing: boolean; }) => boolean, ) => fn({ @@ -37,6 +39,7 @@ function makePassport( canPublishItemListing, canUnpublishItemListing, canDeleteItemListing, + canViewBlockedItemListing, }), })), }, @@ -751,25 +754,6 @@ Scenario( }); }); - Scenario( - 'Unblocking a listing with permission', - ({ Given, When, Then }) => { - Given( - 'an ItemListing aggregate with permission to publish item listing that is currently blocked', - () => { - passport = makePassport(true, true, true, true); - listing = new ItemListing(makeBaseProps({ state: 'Blocked' }), passport); - }, - ); - When('I call setBlocked(false)', () => { - listing.setBlocked(false); - }); - Then('the listing\'s state should be "Active"', () => { - expect(listing.state).toBe('Active'); - }); - }, - ); - Scenario( 'Blocking already blocked listing', ({ Given, When, Then }) => { @@ -884,6 +868,434 @@ Scenario( }, ); + Scenario( + 'Loading sharer asynchronously', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing(makeBaseProps(), passport); + }); + When('I call loadSharer()', async () => { + await listing.loadSharer(); + }); + Then('sharer should be loaded', async () => { + const sharer = await listing.loadSharer(); + expect(sharer).toBeDefined(); + }); + }, + ); + + Scenario( + 'Getting entity reference', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing(makeBaseProps(), passport); + }); + When('I call getEntityReference()', () => { + // Action in Then + }); + Then('it should return ItemListingEntityReference', () => { + const { id } = listing.getEntityReference(); + expect(id).toBeDefined(); + expect(id).toBe('listing-1'); + }); + }, + ); + + Scenario( + 'Accessing displayLocation getter', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate with location "San Francisco"', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing( + makeBaseProps({ location: 'San Francisco' }), + passport, + ); + }); + When('I access displayLocation', () => { + // Access in Then + }); + Then('it should return the same location value', () => { + expect(listing.displayLocation).toBe('San Francisco'); + }); + }, + ); + + Scenario( + 'Setting blocked state using setter', + ({ Given, When, Then }) => { + Given( + 'an ItemListing aggregate with permission to publish item listing', + () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing(makeBaseProps({ state: 'Published' }), passport); + }, + ); + When('I set blocked = true using the setter', () => { + listing.blocked = true; + }); + Then('the listing\'s state should be "Blocked"', () => { + expect(listing.state).toBe('Blocked'); + }); + }, + ); + + Scenario( + 'Setting blocked state to false using setter', + ({ Given, When, Then }) => { + Given( + 'an ItemListing aggregate with permission to publish item listing that is blocked', + () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing(makeBaseProps({ state: 'Blocked' }), passport); + }, + ); + When('I set blocked = false using the setter', () => { + listing.blocked = false; + }); + Then('the listing\'s state should be "Active"', () => { + expect(listing.state).toBe('Active'); + }); + }, + ); + + Scenario( + 'Creating new instance with images', + ({ Given, When, Then }) => { + Given('a new ItemListing aggregate factory method with images', () => { + passport = makePassport(true, true, true, true); + const props = makeBaseProps(); + listing = ItemListing.getNewInstance( + props, + passport, + props.sharer, + { + title: 'Item with Images', + description: 'Item with multiple images', + category: 'Electronics', + location: 'Delhi', + sharingPeriodStart: new Date('2025-10-06T00:00:00Z'), + sharingPeriodEnd: new Date('2025-11-06T00:00:00Z'), + images: ['image1.jpg', 'image2.jpg', 'image3.jpg'], + }, + ); + }); + When('I access the images', () => { + // Access in Then + }); + Then('images array should contain all provided images', () => { + expect(listing.images).toEqual([ + 'image1.jpg', + 'image2.jpg', + 'image3.jpg', + ]); + }); + }, + ); + + Scenario( + 'Creating new instance without images', + ({ Given, When, Then }) => { + Given('a new ItemListing aggregate factory method without images', () => { + passport = makePassport(true, true, true, true); + const props = makeBaseProps(); + listing = ItemListing.getNewInstance( + props, + passport, + props.sharer, + { + title: 'Item without Images', + description: 'Item without images', + category: 'Books', + location: 'Mumbai', + sharingPeriodStart: new Date('2025-10-06T00:00:00Z'), + sharingPeriodEnd: new Date('2025-11-06T00:00:00Z'), + }, + ); + }); + When('I access the images', () => { + // Access in Then + }); + Then('images array should be empty', () => { + expect(listing.images).toEqual([]); + }); + }, + ); + + Scenario( + 'Getting reports count when not set', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate without reports', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing( + makeBaseProps({ reports: undefined }), + passport, + ); + }); + When('I access reports property', () => { + // Access in Then + }); + Then('it should return 0', () => { + expect(listing.reports).toBe(0); + }); + }, + ); + + Scenario( + 'Getting reports count when set', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate with 5 reports', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing(makeBaseProps({ reports: 5 }), passport); + }); + When('I access reports property', () => { + // Access in Then + }); + Then('it should return 5', () => { + expect(listing.reports).toBe(5); + }); + }, + ); + + Scenario( + 'Getting sharingHistory when empty', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate without sharing history', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing( + makeBaseProps({ sharingHistory: undefined }), + passport, + ); + }); + When('I access sharingHistory property', () => { + // Access in Then + }); + Then('it should return an empty array', () => { + expect(listing.sharingHistory).toEqual([]); + }); + }, + ); + + Scenario( + 'Getting sharingHistory when populated', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate with sharing history', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing( + makeBaseProps({ + sharingHistory: ['history-1', 'history-2', 'history-3'], + }), + passport, + ); + }); + When('I access sharingHistory property', () => { + // Access in Then + }); + Then('it should return the sharing history array', () => { + expect(listing.sharingHistory).toEqual([ + 'history-1', + 'history-2', + 'history-3', + ]); + }); + }, + ); + + Scenario( + 'Accessing createdAt timestamp', + ({ Given, When, Then }) => { + const createdDate = new Date('2024-01-01T10:00:00Z'); + Given('an ItemListing aggregate with a specific createdAt timestamp', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing( + makeBaseProps({ createdAt: createdDate }), + passport, + ); + }); + When('I access createdAt property', () => { + // Access in Then + }); + Then('it should return the correct timestamp', () => { + expect(listing.createdAt).toEqual(createdDate); + }); + }, + ); + + Scenario( + 'Accessing updatedAt timestamp', + ({ Given, When, Then }) => { + const updatedDate = new Date('2024-12-01T15:30:00Z'); + Given('an ItemListing aggregate with a specific updatedAt timestamp', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing( + makeBaseProps({ updatedAt: updatedDate }), + passport, + ); + }); + When('I access updatedAt property', () => { + // Access in Then + }); + Then('it should return the correct timestamp', () => { + expect(listing.updatedAt).toEqual(updatedDate); + }); + }, + ); + + Scenario( + 'Accessing schemaVersion', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate with schemaVersion "2.0.0"', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing( + makeBaseProps({ schemaVersion: '2.0.0' }), + passport, + ); + }); + When('I access schemaVersion property', () => { + // Access in Then + }); + Then('it should return "2.0.0"', () => { + expect(listing.schemaVersion).toBe('2.0.0'); + }); + }, + ); + + Scenario( + 'Checking isActive when state is Active', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate in Active state', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing( + makeBaseProps({ state: 'Active' }), + passport, + ); + }); + When('I access isActive property', () => { + // Access in Then + }); + Then('it should return true', () => { + expect(listing.isActive).toBe(true); + }); + }, + ); + + Scenario( + 'Checking isActive when state is Paused', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate in Paused state', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing(makeBaseProps({ state: 'Paused' }), passport); + }); + When('I access isActive property', () => { + // Access in Then + }); + Then('it should return false', () => { + expect(listing.isActive).toBe(false); + }); + }, + ); + + Scenario( + 'Checking isActive when state is Blocked', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate in Blocked state', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing( + makeBaseProps({ state: 'Blocked' }), + passport, + ); + }); + When('I access isActive property', () => { + // Access in Then + }); + Then('it should return false', () => { + expect(listing.isActive).toBe(false); + }); + }, + ); + + Scenario( + 'Getting polymorphic sharer as PersonalUser', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate with PersonalUser sharer', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing(makeBaseProps(), passport); + }); + When('I access the sharer property', () => { + // Access in Then + }); + Then('it should instantiate as PersonalUser', () => { + const { sharer } = listing; + expect(sharer).toBeDefined(); + expect(sharer).toBeInstanceOf(PersonalUser); + }); + }, + ); + + Scenario( + 'Getting title returns string', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate with title "Test Item"', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing( + makeBaseProps({ title: 'Test Item' }), + passport, + ); + }); + When('I access the title property', () => { + // Access in Then + }); + Then('it should return "Test Item"', () => { + expect(listing.title).toBe('Test Item'); + }); + }, + ); + + Scenario( + 'Setting title when isNew is true', + ({ Given, When, Then }) => { + Given('a new ItemListing instance created via getNewInstance', () => { + passport = makePassport(true, true, true, true); + const props = makeBaseProps(); + listing = ItemListing.getNewInstance( + props, + passport, + props.sharer, + { + title: 'Initial Title', + description: 'Initial Description', + category: 'Electronics', + location: 'Delhi', + sharingPeriodStart: new Date('2025-10-06T00:00:00Z'), + sharingPeriodEnd: new Date('2025-11-06T00:00:00Z'), + }, + ); + }); + When('I set the title to "New Title"', () => { + listing.title = 'New Title'; + }); + Then('the title should be updated without permission check', () => { + expect(listing.title).toBe('New Title'); + }); + }, + ); + + Scenario( + 'Setting state directly', + ({ Given, When, Then }) => { + Given('an ItemListing aggregate', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing(makeBaseProps({ state: 'Published' }), passport); + }); + When('I set state directly to "Drafted"', () => { + listing.state = 'Drafted'; + }); + Then('the state should be "Drafted"', () => { + expect(listing.state).toBe('Drafted'); + }); + }, + ); Scenario('Getting expiresAt from item listing', ({ Given, When, Then }) => { Given('an ItemListing aggregate with expiresAt set', () => { const expirationDate = new Date('2025-12-31T23:59:59Z'); diff --git a/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.ts b/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.ts index 45fc9ff48..15f6cc276 100644 --- a/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.ts +++ b/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.ts @@ -193,7 +193,17 @@ export class ItemListing } get state(): string { - return this.props.state; + // Field-level authorization: check if user has permission to view blocked listings + const currentState = this.props.state.valueOf(); + const isBlocked = currentState === ValueObjects.ListingStateEnum.Blocked; + + if (isBlocked && !this.visa.determineIf((permissions) => permissions.canViewBlockedItemListing)) { + throw new DomainSeedwork.PermissionError( + 'You do not have permission to view this blocked listing', + ); + } + + return currentState; } set state(value: string) { @@ -336,6 +346,21 @@ export class ItemListing this.props.state = ValueObjects.ListingStateEnum.Blocked; } + /** + * Set whether this listing is blocked. + * Convenience setter that delegates to setBlocked(). + */ + set blocked(value: boolean) { + if ( + !this.visa.determineIf((permissions) => permissions.canPublishItemListing) + ) { + throw new DomainSeedwork.PermissionError( + 'You do not have permission to change the blocked state of this listing', + ); + } + this.setBlocked(value); + } + /** * Request deletion of this item listing (marks as deleted). */ diff --git a/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.value-objects.ts b/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.value-objects.ts index e62a24f91..a82d290a7 100644 --- a/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.value-objects.ts +++ b/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.value-objects.ts @@ -9,7 +9,7 @@ export const ListingStateEnum = { Cancelled: 'Cancelled', Draft: 'Draft', Expired: 'Expired', - Blocked: 'Blocked', + Blocked: 'Blocked' } as const; export class ListingState extends VOString({ diff --git a/packages/sthrift/domain/src/domain/contexts/listing/listing.domain-permissions.ts b/packages/sthrift/domain/src/domain/contexts/listing/listing.domain-permissions.ts index 903c7724b..e33f79f20 100644 --- a/packages/sthrift/domain/src/domain/contexts/listing/listing.domain-permissions.ts +++ b/packages/sthrift/domain/src/domain/contexts/listing/listing.domain-permissions.ts @@ -3,6 +3,7 @@ export interface ListingDomainPermissions { canUpdateItemListing: boolean; canDeleteItemListing: boolean; canViewItemListing: boolean; + canViewBlockedItemListing: boolean; canPublishItemListing: boolean; canUnpublishItemListing: boolean; } diff --git a/packages/sthrift/domain/src/domain/iam/user/admin-user/contexts/admin-user.listing.item-listing.visa.ts b/packages/sthrift/domain/src/domain/iam/user/admin-user/contexts/admin-user.listing.item-listing.visa.ts index bd8d4b93d..777982081 100644 --- a/packages/sthrift/domain/src/domain/iam/user/admin-user/contexts/admin-user.listing.item-listing.visa.ts +++ b/packages/sthrift/domain/src/domain/iam/user/admin-user/contexts/admin-user.listing.item-listing.visa.ts @@ -33,6 +33,8 @@ export class AdminUserListingItemListingVisa< rolePermissions?.userPermissions?.canDeleteContent ?? false, // Admins can view all listings canViewItemListing: true, + // Admins can view blocked listings + canViewBlockedItemListing: true, // Admins can publish listings if they have moderation permission canPublishItemListing: rolePermissions?.listingPermissions?.canModerateListings ?? false, diff --git a/packages/sthrift/domain/src/domain/iam/user/personal-user/contexts/personal-user.listing.item-listing.visa.ts b/packages/sthrift/domain/src/domain/iam/user/personal-user/contexts/personal-user.listing.item-listing.visa.ts index 08877ac6b..55a7a1f77 100644 --- a/packages/sthrift/domain/src/domain/iam/user/personal-user/contexts/personal-user.listing.item-listing.visa.ts +++ b/packages/sthrift/domain/src/domain/iam/user/personal-user/contexts/personal-user.listing.item-listing.visa.ts @@ -21,6 +21,7 @@ export class PersonalUserListingItemListingVisa< canUpdateItemListing: this.user.id === this.root.sharer.id, canDeleteItemListing: this.user.id === this.root.sharer.id, canViewItemListing: true, + canViewBlockedItemListing: false, canPublishItemListing: this.user.id === this.root.sharer.id, canUnpublishItemListing: this.user.id === this.root.sharer.id, }; diff --git a/packages/sthrift/domain/src/domain/iam/user/personal-user/contexts/personal-user.listing.visa.ts b/packages/sthrift/domain/src/domain/iam/user/personal-user/contexts/personal-user.listing.visa.ts index cd1579819..a48a8ff0b 100644 --- a/packages/sthrift/domain/src/domain/iam/user/personal-user/contexts/personal-user.listing.visa.ts +++ b/packages/sthrift/domain/src/domain/iam/user/personal-user/contexts/personal-user.listing.visa.ts @@ -27,6 +27,7 @@ export class PersonalUserListingVisa canViewItemListing: true, // All users can view listings canPublishItemListing: isOwner, canUnpublishItemListing: isOwner, + canViewBlockedItemListing: isOwner }; return func(permissions); diff --git a/packages/sthrift/event-handler/package.json b/packages/sthrift/event-handler/package.json index bf7a1b457..be9ee827f 100644 --- a/packages/sthrift/event-handler/package.json +++ b/packages/sthrift/event-handler/package.json @@ -1,31 +1,31 @@ { - "name": "@sthrift/event-handler", - "version": "1.0.0", - "private": true, - "type": "module", - "files": [ - "dist" - ], - "exports": { - ".": { - "types": "./dist/src/index.d.ts", - "default": "./dist/src/index.js" - } - }, - "scripts": { - "prebuild": "biome lint", - "build": "tsc --build", - "watch": "tsc --watch", - "lint": "biome lint", - "clean": "rimraf dist" - }, - "dependencies": { - "@sthrift/domain": "workspace:*" - }, - "devDependencies": { - "@cellix/typescript-config": "workspace:*", - "typescript": "^5.8.3", - "rimraf": "^6.0.1" - }, - "license": "MIT" + "name": "@sthrift/event-handler", + "version": "1.0.0", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "scripts": { + "prebuild": "biome lint", + "build": "tsc --build", + "watch": "tsc --watch", + "lint": "biome lint", + "clean": "rimraf dist" + }, + "dependencies": { + "@sthrift/domain": "workspace:*" + }, + "devDependencies": { + "@cellix/typescript-config": "workspace:*", + "typescript": "^5.8.3", + "rimraf": "^6.0.1" + }, + "license": "MIT" } diff --git a/packages/sthrift/event-handler/tsconfig.json b/packages/sthrift/event-handler/tsconfig.json index 7b5d4c2a0..e03fcaf84 100644 --- a/packages/sthrift/event-handler/tsconfig.json +++ b/packages/sthrift/event-handler/tsconfig.json @@ -7,4 +7,4 @@ "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"], "references": [{ "path": "../domain" }] -} +} \ No newline at end of file diff --git a/packages/sthrift/graphql/src/schema/types/listing/features/item-listing.resolvers.feature b/packages/sthrift/graphql/src/schema/types/listing/features/item-listing.resolvers.feature index 2d4c5ce6c..2f21c44d6 100644 --- a/packages/sthrift/graphql/src/schema/types/listing/features/item-listing.resolvers.feature +++ b/packages/sthrift/graphql/src/schema/types/listing/features/item-listing.resolvers.feature @@ -104,7 +104,13 @@ So that I can view, filter, and create listings through the GraphQL API Given a valid listing ID to unblock When the unblockListing mutation is executed Then it should call Listing.ItemListing.unblock with the ID - And it should return true + And it should return the ItemListingMutationResult with success + + Scenario: Blocking a listing successfully + Given a valid listing ID to block + When the blockListing mutation is executed + Then it should call Listing.ItemListing.block with the ID + And it should return the ItemListingMutationResult with success Scenario: Canceling an item listing successfully Given a valid listing ID to cancel diff --git a/packages/sthrift/graphql/src/schema/types/listing/item-listing.graphql b/packages/sthrift/graphql/src/schema/types/listing/item-listing.graphql index 3d7ce0a81..71bb54a06 100644 --- a/packages/sthrift/graphql/src/schema/types/listing/item-listing.graphql +++ b/packages/sthrift/graphql/src/schema/types/listing/item-listing.graphql @@ -1,72 +1,72 @@ type ItemListing implements MongoBase { - sharer: User - title: String! - description: String! - category: String! - location: String! - sharingPeriodStart: DateTime! - sharingPeriodEnd: DateTime! - state: String - sharingHistory: [String!] - reports: Int - images: [String!] # Array of image URLs - listingType: String! - id: ObjectID! - schemaVersion: String - createdAt: DateTime - updatedAt: DateTime - version: Int + sharer: User + title: String! + description: String! + category: String! + location: String! + sharingPeriodStart: DateTime! + sharingPeriodEnd: DateTime! + state: String + sharingHistory: [String!] + reports: Int + images: [String!] # Array of image URLs + listingType: String! + id: ObjectID! + schemaVersion: String + createdAt: DateTime + updatedAt: DateTime + version: Int } type ListingAllPage { - items: [ListingAll!]! - total: Int! - page: Int! - pageSize: Int! + items: [ListingAll!]! + total: Int! + page: Int! + pageSize: Int! } type ListingRequestPage { - items: [ListingRequest!]! - total: Int! - page: Int! - pageSize: Int! + items: [ListingRequest!]! + total: Int! + page: Int! + pageSize: Int! } # Listing types for dashboard type ListingAll { - id: ObjectID! - title: String! - images: [String!] - createdAt: DateTime - sharingPeriodStart: DateTime - sharingPeriodEnd: DateTime - state: String + id: ObjectID! + title: String! + images: [String!] + createdAt: DateTime + sharingPeriodStart: DateTime + sharingPeriodEnd: DateTime + state: String } type ListingRequest { - id: ObjectID! - title: String! - image: String - requestedBy: String! - requestedOn: String! - reservationPeriod: String! - status: String! + id: ObjectID! + title: String! + image: String + requestedBy: String! + requestedOn: String! + reservationPeriod: String! + status: String! } input SorterInput { - field: String! - order: String! + field: String! + order: String! } input CreateItemListingInput { - title: String! - description: String! - category: String! - location: String! - sharingPeriodStart: DateTime! - sharingPeriodEnd: DateTime! - images: [String!] - isDraft: Boolean + title: String! + description: String! + category: String! + location: String! + sharingPeriodStart: DateTime! + sharingPeriodEnd: DateTime! + images: [String!] + isDraft: Boolean } type ItemListingMutationResult implements MutationResult { @@ -75,27 +75,16 @@ type ItemListingMutationResult implements MutationResult { } extend type Mutation { - createItemListing(input: CreateItemListingInput!): ItemListing! - cancelItemListing(id: ObjectID!): ItemListingMutationResult! - deleteItemListing(id: ObjectID!): ItemListingMutationResult! - unblockListing(id: ObjectID!): Boolean! + createItemListing(input: CreateItemListingInput!): ItemListing! + cancelItemListing(id: ObjectID!): ItemListingMutationResult! + deleteItemListing(id: ObjectID!): ItemListingMutationResult! + unblockListing(id: ObjectID!): ItemListingMutationResult! + blockListing(id: ObjectID!): ItemListingMutationResult! } extend type Query { - itemListings: [ItemListing!]! - itemListing(id: ObjectID!): ItemListing - myListingsAll( - page: Int! - pageSize: Int! - searchText: String - statusFilters: [String!] - sorter: SorterInput - ): ListingAllPage! - adminListings( - page: Int! - pageSize: Int! - searchText: String - statusFilters: [String!] - sorter: SorterInput - ): ListingAllPage! + itemListings: [ItemListing!]! + itemListing(id: ObjectID!): ItemListing + myListingsAll(page: Int!, pageSize: Int!, searchText: String, statusFilters: [String!], sorter: SorterInput): ListingAllPage! + adminListings(page: Int!, pageSize: Int!, searchText: String, statusFilters: [String!], sorter: SorterInput): ListingAllPage! } diff --git a/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.test.ts b/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.test.ts index c0d81e0d8..ebb07dc21 100644 --- a/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.test.ts +++ b/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.test.ts @@ -113,7 +113,8 @@ function createMockUser( function makeMockGraphContext( overrides: Partial = {}, ): GraphContext { - return { + // biome-ignore lint/suspicious/noExplicitAny: Test utility requires flexible typing + const baseContext: any = { applicationServices: { Listing: { ItemListing: { @@ -123,12 +124,17 @@ function makeMockGraphContext( queryPaged: vi.fn(), create: vi.fn(), update: vi.fn(), + block: vi.fn(), + unblock: vi.fn(), }, }, User: { PersonalUser: { queryByEmail: vi.fn().mockResolvedValue(createMockUser()), }, + AdminUser: { + queryByEmail: vi.fn().mockRejectedValue(new Error('Not found')), + }, }, verifiedUser: { verifiedJwt: { @@ -137,8 +143,44 @@ function makeMockGraphContext( }, }, }, - ...overrides, - } as unknown as GraphContext; + }; + + // Deep merge applicationServices from overrides + if (overrides.applicationServices) { + // biome-ignore lint/suspicious/noExplicitAny: Test utility requires flexible typing + const appServicesOverride = overrides.applicationServices as any; + + if (appServicesOverride.Listing?.ItemListing) { + Object.assign( + baseContext.applicationServices.Listing.ItemListing, + appServicesOverride.Listing.ItemListing, + ); + } + + if (appServicesOverride.User?.PersonalUser) { + Object.assign( + baseContext.applicationServices.User.PersonalUser, + appServicesOverride.User.PersonalUser, + ); + } + + if (appServicesOverride.User?.AdminUser) { + Object.assign( + baseContext.applicationServices.User.AdminUser, + appServicesOverride.User.AdminUser, + ); + } + + // Handle verifiedUser - check if property exists, even if null + if ('verifiedUser' in appServicesOverride) { + baseContext.applicationServices.verifiedUser = + appServicesOverride.verifiedUser; + } + } + + // Merge other properties (not applicationServices) + const { applicationServices: _appServices, ...otherOverrides } = overrides; + return { ...baseContext, ...otherOverrides } as unknown as GraphContext; } test.for(feature, ({ Scenario }) => { @@ -245,7 +287,7 @@ test.for(feature, ({ Scenario }) => { () => { expect( context.applicationServices.Listing.ItemListing.queryById, - ).toHaveBeenCalledWith({ id: 'listing-1' }); + ).toHaveBeenCalledWith({ id: 'listing-1', isAdmin: false }); }, ); And('it should return the corresponding listing', () => { @@ -1155,128 +1197,247 @@ test.for(feature, ({ Scenario }) => { pageSize: 20, }); }); - When('the adminListings query is executed with only page and pageSize', async () => { - const resolver = itemListingResolvers.Query?.adminListings as TestResolver<{ - page: number; - pageSize: number; + When( + 'the adminListings query is executed with only page and pageSize', + async () => { + const resolver = itemListingResolvers.Query + ?.adminListings as TestResolver<{ + page: number; + pageSize: number; + }>; + result = await resolver( + {}, + { page: 1, pageSize: 20 }, + context, + {} as never, + ); + }, + ); + Then( + 'it should call Listing.ItemListing.queryPaged with minimal parameters', + () => { + expect( + context.applicationServices.Listing.ItemListing.queryPaged, + ).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + pageSize: 20, + }), + ); + }, + ); + And('it should return all listings', () => { + expect(result).toBeDefined(); + expect(result).toHaveProperty('items'); + }); + }, + ); + + Scenario( + 'Unblocking a listing successfully', + ({ Given, When, Then, And }) => { + Given('a valid listing ID to unblock', () => { + result = undefined; + context = makeMockGraphContext({ + applicationServices: { + Listing: { + ItemListing: { + queryAll: vi.fn(), + queryById: vi.fn(), + queryBySharer: vi.fn(), + queryPaged: vi.fn(), + create: vi.fn(), + update: vi.fn(), + block: vi.fn(), + unblock: vi.fn().mockResolvedValue(createMockListing({ state: 'Published' })), + }, + }, + User: { + PersonalUser: { + queryByEmail: vi.fn().mockResolvedValue(createMockUser()), + }, + AdminUser: { + queryByEmail: vi.fn().mockResolvedValue({ + userType: 'admin-user', + role: { roleName: 'Admin' }, + } as never), + }, + }, + verifiedUser: { + verifiedJwt: { email: 'admin@example.com' }, + }, + }, + } as unknown as GraphContext); + }); + When('the unblockListing mutation is executed', async () => { + const resolver = itemListingResolvers.Mutation + ?.unblockListing as TestResolver<{ + id: string; }>; - result = await resolver( - {}, - { page: 1, pageSize: 20 }, - context, - {} as never, - ); + result = await resolver({}, { id: 'listing-1' }, context, {} as never); }); - Then('it should call Listing.ItemListing.queryPaged with minimal parameters', () => { + Then('it should call Listing.ItemListing.unblock with the ID', () => { expect( - context.applicationServices.Listing.ItemListing.queryPaged, - ).toHaveBeenCalledWith( - expect.objectContaining({ - page: 1, - pageSize: 20, - }), - ); + context.applicationServices.Listing.ItemListing.unblock, + ).toHaveBeenCalledWith({ + id: 'listing-1', + }); }); - And('it should return all listings', () => { - expect(result).toBeDefined(); - expect(result).toHaveProperty('items'); + And('it should return the ItemListingMutationResult with success', () => { + expect(result).toEqual({ + status: { success: true }, + listing: expect.objectContaining({ + id: 'listing-1', + state: 'Published', + }), + }); }); }, ); - Scenario('Unblocking a listing successfully', ({ Given, When, Then, And }) => { - Given('a valid listing ID to unblock', () => { + Scenario('Blocking a listing successfully', ({ Given, When, Then, And }) => { + Given('a valid listing ID to block', () => { + result = undefined; context = makeMockGraphContext({ applicationServices: { - ...makeMockGraphContext().applicationServices, Listing: { ItemListing: { - ...makeMockGraphContext().applicationServices.Listing.ItemListing, - unblock: vi.fn().mockResolvedValue(undefined), + queryAll: vi.fn(), + queryById: vi.fn(), + queryBySharer: vi.fn(), + queryPaged: vi.fn(), + create: vi.fn(), + update: vi.fn(), + block: vi.fn().mockResolvedValue(createMockListing({ state: 'Blocked' })), + unblock: vi.fn(), + }, + }, + User: { + PersonalUser: { + queryByEmail: vi.fn().mockResolvedValue(createMockUser()), }, + AdminUser: { + queryByEmail: vi.fn().mockResolvedValue({ + userType: 'admin-user', + role: { roleName: 'Admin' }, + } as never), + }, + }, + verifiedUser: { + verifiedJwt: { email: 'admin@example.com' }, }, }, - }); + } as unknown as GraphContext); }); - When('the unblockListing mutation is executed', async () => { - const resolver = itemListingResolvers.Mutation?.unblockListing as TestResolver<{ + When('the blockListing mutation is executed', async () => { + const resolver = itemListingResolvers.Mutation + ?.blockListing as TestResolver<{ id: string; }>; result = await resolver({}, { id: 'listing-1' }, context, {} as never); }); - Then('it should call Listing.ItemListing.unblock with the ID', () => { - expect(context.applicationServices.Listing.ItemListing.unblock).toHaveBeenCalledWith({ + Then('it should call Listing.ItemListing.block with the ID', () => { + expect( + context.applicationServices.Listing.ItemListing.block, + ).toHaveBeenCalledWith({ id: 'listing-1', }); }); - And('it should return true', () => { - expect(result).toBe(true); + And('it should return the ItemListingMutationResult with success', () => { + expect(result).toEqual({ + status: { success: true }, + listing: expect.objectContaining({ + id: 'listing-1', + state: 'Blocked', + }), + }); }); }); - Scenario('Canceling an item listing successfully', ({ Given, When, Then, And }) => { - Given('a valid listing ID to cancel', () => { - context = makeMockGraphContext({ - applicationServices: { - ...makeMockGraphContext().applicationServices, - Listing: { - ItemListing: { - ...makeMockGraphContext().applicationServices.Listing.ItemListing, - cancel: vi.fn().mockResolvedValue(createMockListing()), + Scenario( + 'Canceling an item listing successfully', + ({ Given, When, Then, And }) => { + Given('a valid listing ID to cancel', () => { + context = makeMockGraphContext({ + applicationServices: { + ...makeMockGraphContext().applicationServices, + Listing: { + ItemListing: { + ...makeMockGraphContext().applicationServices.Listing + .ItemListing, + cancel: vi.fn().mockResolvedValue(createMockListing()), + }, }, }, - }, + }); }); - }); - When('the cancelItemListing mutation is executed', async () => { - const resolver = itemListingResolvers.Mutation?.cancelItemListing as TestResolver<{ - id: string; - }>; - result = await resolver({}, { id: 'listing-1' }, context, {} as never); - }); - Then('it should call Listing.ItemListing.cancel with the ID', () => { - expect(context.applicationServices.Listing.ItemListing.cancel).toHaveBeenCalledWith({ - id: 'listing-1', + When('the cancelItemListing mutation is executed', async () => { + const resolver = itemListingResolvers.Mutation + ?.cancelItemListing as TestResolver<{ + id: string; + }>; + result = await resolver({}, { id: 'listing-1' }, context, {} as never); }); - }); - And('it should return success status and the canceled listing', () => { - expect(result).toBeDefined(); - expect(result).toHaveProperty('status'); - expect((result as { status: { success: boolean } }).status.success).toBe(true); - expect(result).toHaveProperty('listing'); - }); - }); + Then('it should call Listing.ItemListing.cancel with the ID', () => { + expect( + context.applicationServices.Listing.ItemListing.cancel, + ).toHaveBeenCalledWith({ + id: 'listing-1', + }); + }); + And('it should return success status and the canceled listing', () => { + expect(result).toBeDefined(); + expect(result).toHaveProperty('status'); + expect( + (result as { status: { success: boolean } }).status.success, + ).toBe(true); + expect(result).toHaveProperty('listing'); + }); + }, + ); - Scenario('Deleting an item listing successfully', ({ Given, When, Then, And }) => { - Given('a valid listing ID and authenticated user email', () => { - context = makeMockGraphContext({ - applicationServices: { - ...makeMockGraphContext().applicationServices, - Listing: { - ItemListing: { - ...makeMockGraphContext().applicationServices.Listing.ItemListing, - deleteListings: vi.fn().mockResolvedValue(undefined), + Scenario( + 'Deleting an item listing successfully', + ({ Given, When, Then, And }) => { + Given('a valid listing ID and authenticated user email', () => { + context = makeMockGraphContext({ + applicationServices: { + ...makeMockGraphContext().applicationServices, + Listing: { + ItemListing: { + ...makeMockGraphContext().applicationServices.Listing + .ItemListing, + deleteListings: vi.fn().mockResolvedValue(undefined), + }, }, }, - }, + }); }); - }); - When('the deleteItemListing mutation is executed', async () => { - const resolver = itemListingResolvers.Mutation?.deleteItemListing as TestResolver<{ - id: string; - }>; - result = await resolver({}, { id: 'listing-1' }, context, {} as never); - }); - Then('it should call Listing.ItemListing.deleteListings with ID and email', () => { - expect(context.applicationServices.Listing.ItemListing.deleteListings).toHaveBeenCalledWith({ - id: 'listing-1', - userEmail: 'test@example.com', + When('the deleteItemListing mutation is executed', async () => { + const resolver = itemListingResolvers.Mutation + ?.deleteItemListing as TestResolver<{ + id: string; + }>; + result = await resolver({}, { id: 'listing-1' }, context, {} as never); }); - }); - And('it should return success status', () => { - expect(result).toBeDefined(); - expect(result).toHaveProperty('status'); - expect((result as { status: { success: boolean } }).status.success).toBe(true); - }); - }); + Then( + 'it should call Listing.ItemListing.deleteListings with ID and email', + () => { + expect( + context.applicationServices.Listing.ItemListing.deleteListings, + ).toHaveBeenCalledWith({ + id: 'listing-1', + userEmail: 'test@example.com', + }); + }, + ); + And('it should return success status', () => { + expect(result).toBeDefined(); + expect(result).toHaveProperty('status'); + expect( + (result as { status: { success: boolean } }).status.success, + ).toBe(true); + }); + }, + ); }); diff --git a/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.ts b/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.ts index 54c33b33a..7da338f0d 100644 --- a/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.ts +++ b/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.ts @@ -1,6 +1,6 @@ import type { GraphContext } from '../../../init/context.ts'; import type { Resolvers } from '../../builder/generated.js'; -import { PopulateUserFromField } from '../../resolver-helper.ts'; +import { PopulateUserFromField, currentViewerIsAdmin } from '../../resolver-helper.ts'; const itemListingResolvers: Resolvers = { ItemListing: { @@ -10,6 +10,7 @@ const itemListingResolvers: Resolvers = { myListingsAll: async (_parent: unknown, args, context) => { const currentUser = context.applicationServices.verifiedUser; const email = currentUser?.verifiedJwt?.email; + const isAdmin = await currentViewerIsAdmin(context); let sharerId: string | undefined; if (email) { sharerId = @@ -43,8 +44,10 @@ const itemListingResolvers: Resolvers = { }, itemListing: async (_parent, args, context) => { + const isAdmin = await currentViewerIsAdmin(context); return await context.applicationServices.Listing.ItemListing.queryById({ id: args.id, + isAdmin, }); }, adminListings: async (_parent, args, context) => { @@ -101,28 +104,45 @@ const itemListingResolvers: Resolvers = { }, unblockListing: async (_parent, args, context) => { - // Admin-note: role-based authorization should be implemented here (security) - await context.applicationServices.Listing.ItemListing.unblock({ + // Permission checks are enforced at the domain level via the visa pattern + const listing = await context.applicationServices.Listing.ItemListing.unblock({ + id: args.id, + }); + return { + status: { success: true }, + listing, + }; + }, + blockListing: async (_parent, args, context) => { + // Permission checks are enforced at the domain level via the visa pattern + const listing = await context.applicationServices.Listing.ItemListing.block({ id: args.id, }); - return true; + return { + status: { success: true }, + listing, + }; }, cancelItemListing: async ( _parent: unknown, args: { id: string }, context, - ) => ({ - status: { success: true }, - listing: await context.applicationServices.Listing.ItemListing.cancel({ - id: args.id, - }), - }), + ) => { + // Permission checks are enforced at the domain level via the visa pattern + return { + status: { success: true }, + listing: await context.applicationServices.Listing.ItemListing.cancel({ + id: args.id, + }), + }; + }, deleteItemListing: async ( _parent: unknown, args: { id: string }, context: GraphContext, ) => { + // Permission checks are enforced at the domain level via the visa pattern await context.applicationServices.Listing.ItemListing.deleteListings({ id: args.id, userEmail: diff --git a/packages/sthrift/messaging-service-twilio/package.json b/packages/sthrift/messaging-service-twilio/package.json index 0aa1accd3..d740e4760 100644 --- a/packages/sthrift/messaging-service-twilio/package.json +++ b/packages/sthrift/messaging-service-twilio/package.json @@ -29,4 +29,4 @@ "vitest": "catalog:" }, "license": "MIT" -} \ No newline at end of file +} diff --git a/packages/sthrift/mock-messaging-server/package.json b/packages/sthrift/mock-messaging-server/package.json index 17671ed50..e96d96278 100644 --- a/packages/sthrift/mock-messaging-server/package.json +++ b/packages/sthrift/mock-messaging-server/package.json @@ -1,36 +1,35 @@ { - "name": "@sthrift/mock-messaging-server", - "version": "1.0.0", - "private": true, - "type": "module", - "main": "dist/src/index.js", - "types": "dist/src/index.d.ts", - - "license": "MIT", - "scripts": { - "prebuild": "biome lint", - "build": "tsc --build", - "clean": "rimraf dist", - "start": "node -r dotenv/config dist/src/index.js", - "dev": "tsc-watch --onSuccess \"node -r dotenv/config dist/src/index.js\"", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "dotenv": "^16.6.1", - "express": "^4.18.2", - "mongodb": "catalog:" - }, - "devDependencies": { - "@cellix/typescript-config": "workspace:*", - "@cellix/vitest-config": "workspace:*", - "@types/express": "^4.17.21", - "@types/node": "^22.0.0", - "@types/supertest": "^6.0.2", - "rimraf": "^6.0.1", - "supertest": "^7.0.0", - "tsc-watch": "^7.1.1", - "typescript": "^5.8.3", - "vitest": "catalog:" - } + "name": "@sthrift/mock-messaging-server", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "license": "MIT", + "scripts": { + "prebuild": "biome lint", + "build": "tsc --build", + "clean": "rimraf dist", + "start": "node -r dotenv/config dist/src/index.js", + "dev": "tsc-watch --onSuccess \"node -r dotenv/config dist/src/index.js\"", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "dotenv": "^16.6.1", + "express": "^4.22.0", + "mongodb": "catalog:" + }, + "devDependencies": { + "@cellix/typescript-config": "workspace:*", + "@cellix/vitest-config": "workspace:*", + "@types/express": "^4.17.21", + "@types/node": "^22.0.0", + "@types/supertest": "^6.0.2", + "rimraf": "^6.0.1", + "supertest": "^7.0.0", + "tsc-watch": "^7.1.1", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + } } diff --git a/packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.ts b/packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.ts index 0633d697e..37f6c9551 100644 --- a/packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.ts +++ b/packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.ts @@ -8,9 +8,12 @@ import type { FindOneOptions, FindOptions } from '../../mongo-data-source.ts'; import { ItemListingConverter } from '../../../domain/listing/item/item-listing.domain-adapter.ts'; import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; +export type ItemListingFindOneOptions = FindOneOptions & { isAdmin?: boolean }; +export type ItemListingFindOptions = FindOptions & { isAdmin?: boolean }; + export interface ItemListingReadRepository { getAll: ( - options?: FindOptions, + options?: ItemListingFindOptions, ) => Promise< Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[] >; @@ -22,6 +25,7 @@ export interface ItemListingReadRepository { statusFilters?: string[]; sharerId?: string; sorter?: { field: string; order: 'ascend' | 'descend' }; + isAdmin?: boolean; }) => Promise<{ items: Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[]; total: number; @@ -31,12 +35,12 @@ export interface ItemListingReadRepository { getById: ( id: string, - options?: FindOneOptions, + options?: ItemListingFindOneOptions, ) => Promise; getBySharer: ( sharerId: string, - options?: FindOptions, + options?: ItemListingFindOptions, ) => Promise< Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[] >; @@ -58,13 +62,22 @@ class ItemListingReadRepositoryImpl } async getAll( - options?: FindOptions, + options?: ItemListingFindOptions, ): Promise { + const isAdmin = options?.isAdmin ?? false; + const { isAdmin: _, ...mongoOptions } = options ?? {}; + + const query: Record = {}; + + // Filter out blocked listings unless user is admin + if (!isAdmin) { + // biome-ignore lint/complexity/useLiteralKeys: MongoDB query uses index signature + query['state'] = { $ne: 'Blocked' }; + } + const result = await this.mongoDataSource.find( - {}, - { - ...options, - }, + query, + mongoOptions, ); if (!result || result.length === 0) return []; return result.map((doc) => this.converter.toDomain(doc, this.passport)); @@ -77,12 +90,15 @@ class ItemListingReadRepositoryImpl statusFilters?: string[]; sharerId?: string; sorter?: { field: string; order: 'ascend' | 'descend' }; + isAdmin?: boolean; }): Promise<{ items: Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[]; total: number; page: number; pageSize: number; }> { + const isAdmin = args.isAdmin ?? false; + // Build MongoDB query const query: Record = {}; @@ -117,6 +133,10 @@ class ItemListingReadRepositoryImpl if (args.statusFilters && args.statusFilters.length > 0) { // biome-ignore lint/complexity/useLiteralKeys: MongoDB query uses index signature query['state'] = { $in: args.statusFilters }; + } else if (!isAdmin) { + // If no explicit status filters and user is not admin, exclude blocked listings + // biome-ignore lint/complexity/useLiteralKeys: MongoDB query uses index signature + query['state'] = { $ne: 'Blocked' }; } // Build sort criteria @@ -161,27 +181,42 @@ class ItemListingReadRepositoryImpl async getById( id: string, - options?: FindOneOptions, + options?: ItemListingFindOneOptions, ): Promise { - const result = await this.mongoDataSource.findById(id, { - ...options, - }); + const isAdmin = options?.isAdmin ?? false; + const { isAdmin: _, ...mongoOptions } = options ?? {}; + + const result = await this.mongoDataSource.findById(id, mongoOptions); if (!result) return null; + + // Filter out blocked listings unless user is admin + if (!isAdmin && result.state === 'Blocked') { + return null; + } + return this.converter.toDomain(result, this.passport); } async getBySharer( sharerId: string, - options?: FindOptions, + options?: ItemListingFindOptions, ): Promise { if (!sharerId || sharerId.trim() === '') return []; try { - const result = await this.mongoDataSource.find( - { sharer: new MongooseSeedwork.ObjectId(sharerId) }, - { - ...options, - }, - ); + const isAdmin = options?.isAdmin ?? false; + const { isAdmin: _, ...mongoOptions } = options ?? {}; + + const query: Record = { + sharer: new MongooseSeedwork.ObjectId(sharerId), + }; + + // Filter out blocked listings unless user is admin + if (!isAdmin) { + // biome-ignore lint/complexity/useLiteralKeys: MongoDB query uses index signature + query['state'] = { $ne: 'Blocked' }; + } + + const result = await this.mongoDataSource.find(query, mongoOptions); if (!result || result.length === 0) return []; return result.map((doc) => this.converter.toDomain(doc, this.passport)); } catch (error) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e62e97f8..5a12ad725 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,8 +511,8 @@ importers: specifier: workspace:* version: link:../payment-service express: - specifier: ^4.18.2 - version: 4.21.2 + specifier: ^4.22.0 + version: 4.22.1 jose: specifier: ^5.10.0 version: 5.10.0 @@ -952,8 +952,8 @@ importers: specifier: ^16.6.1 version: 16.6.1 express: - specifier: ^4.18.2 - version: 4.21.2 + specifier: ^4.22.0 + version: 4.22.1 mongodb: specifier: 'catalog:' version: 6.18.0 @@ -986,8 +986,8 @@ importers: specifier: ^5.8.3 version: 5.8.3 vitest: - specifier: 'catalog:' - version: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@22.19.3)(@vitest/browser-playwright@4.0.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) packages/sthrift/mock-mongodb-memory-server: dependencies: @@ -8206,23 +8206,13 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - jsonwebtoken@9.0.2: - resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} - engines: {node: '>=12', npm: '>=6'} - jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} - jwa@1.4.2: - resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} - jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - jws@4.0.0: resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} @@ -9919,8 +9909,8 @@ packages: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} querystringify@2.2.0: @@ -17319,6 +17309,26 @@ snapshots: - utf-8-validate - vite + '@vitest/browser@3.2.4(playwright@1.56.1)(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/utils': 3.2.4 + magic-string: 0.30.21 + sirv: 3.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + ws: 8.18.3 + optionalDependencies: + playwright: 1.56.1 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@3.2.4(playwright@1.56.1)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 @@ -17448,6 +17458,14 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 @@ -18023,7 +18041,7 @@ snapshots: express: 4.21.2 fs-extra: 11.3.2 glob-to-regexp: 0.4.1 - jsonwebtoken: 9.0.2 + jsonwebtoken: 9.0.3 lokijs: 1.5.12 morgan: 1.10.1 multistream: 2.1.1 @@ -18151,7 +18169,7 @@ snapshots: http-errors: 2.0.0 iconv-lite: 0.7.0 on-finished: 2.4.1 - qs: 6.14.0 + qs: 6.14.1 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -19590,7 +19608,7 @@ snapshots: parseurl: 1.3.3 path-to-regexp: 0.1.12 proxy-addr: 2.0.7 - qs: 6.14.0 + qs: 6.14.1 range-parser: 1.2.1 safe-buffer: 5.2.1 send: 0.19.0 @@ -20905,19 +20923,6 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsonwebtoken@9.0.2: - dependencies: - jws: 3.2.2 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.7.3 - jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -20931,23 +20936,12 @@ snapshots: ms: 2.1.3 semver: 7.7.3 - jwa@1.4.2: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 - jws@3.2.2: - dependencies: - jwa: 1.4.2 - safe-buffer: 5.2.1 - jws@4.0.0: dependencies: jwa: 2.0.1 @@ -22945,7 +22939,7 @@ snapshots: dependencies: side-channel: 1.1.0 - qs@6.14.0: + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -24366,7 +24360,7 @@ snapshots: formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.14.0 + qs: 6.14.1 transitivePeerDependencies: - supports-color @@ -24709,7 +24703,7 @@ snapshots: dayjs: 1.11.18 https-proxy-agent: 5.0.1 jsonwebtoken: 9.0.3 - qs: 6.14.0 + qs: 6.14.1 scmp: 2.1.0 xmlbuilder: 13.0.2 transitivePeerDependencies: @@ -24994,6 +24988,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: cac: 6.7.14 @@ -25049,6 +25064,50 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@8.1.1) + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.19.3 + '@vitest/browser': 3.2.4(playwright@1.56.1)(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vitest@3.2.4) + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 31c5a352b..6679206b1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,7 @@ catalog: '@vitest/browser-playwright': ^4.0.15 storybook: 9.1.17 vite: ^7.3.0 + react-router-dom: 7.12.0 overrides: node-forge@<1.3.2: '>=1.3.2'