From 0c47ac3e28b151d553585137e8b67c35d39703b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:43:51 +0000 Subject: [PATCH 01/76] Initial plan From da4abab0ea6ac95104c123fc969a36920b5df56e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:55:55 +0000 Subject: [PATCH 02/76] Add backend block listing mutation and tests Co-authored-by: rohit-r-kumar <175348946+rohit-r-kumar@users.noreply.github.com> --- .../src/contexts/listing/item/block.test.ts | 80 +++++++++++++++++++ .../src/contexts/listing/item/block.ts | 31 +++++++ .../src/contexts/listing/item/index.ts | 7 +- .../features/item-listing.resolvers.feature | 6 ++ .../schema/types/listing/item-listing.graphql | 1 + .../listing/item-listing.resolvers.test.ts | 30 +++++++ .../types/listing/item-listing.resolvers.ts | 7 ++ 7 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 packages/sthrift/application-services/src/contexts/listing/item/block.test.ts create mode 100644 packages/sthrift/application-services/src/contexts/listing/item/block.ts 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..94aadb32a --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/listing/item/block.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { DataSources } from '@sthrift/persistence'; +import type { Domain } from '@sthrift/domain'; +import { block } from './block.ts'; + +describe('listing/item', () => { + describe('block', () => { + let mockRepo: { + getById: ReturnType; + save: ReturnType; + }; + + let mockListing: { + setBlocked: ReturnType; + }; + + let mockDataSources: DataSources; + + beforeEach(() => { + mockListing = { + setBlocked: vi.fn(), + }; + + 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.setBlocked).toHaveBeenCalledWith(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..9f64746a3 --- /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.setBlocked(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/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/graphql/src/schema/types/listing/features/item-listing.resolvers.feature b/packages/sthrift/graphql/src/schema/types/listing/features/item-listing.resolvers.feature index c13a3af7f..ce39bf92b 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 @@ -113,6 +113,12 @@ So that I can view, filter, and create listings through the GraphQL API Then it should call Listing.ItemListing.unblock with the ID And it should return true + 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 true + Scenario: Canceling an item listing successfully Given a valid listing ID to cancel When the cancelItemListing mutation is executed 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..b7d81f7ad 100644 --- a/packages/sthrift/graphql/src/schema/types/listing/item-listing.graphql +++ b/packages/sthrift/graphql/src/schema/types/listing/item-listing.graphql @@ -79,6 +79,7 @@ extend type Mutation { cancelItemListing(id: ObjectID!): ItemListingMutationResult! deleteItemListing(id: ObjectID!): ItemListingMutationResult! unblockListing(id: ObjectID!): Boolean! + blockListing(id: ObjectID!): Boolean! } extend type Query { 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 d5e3854ee..871bd2e86 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 @@ -941,6 +941,36 @@ test.for(feature, ({ Scenario }) => { }); }); + Scenario('Blocking a listing successfully', ({ Given, When, Then, And }) => { + Given('a valid listing ID to block', () => { + context = makeMockGraphContext({ + applicationServices: { + ...makeMockGraphContext().applicationServices, + Listing: { + ItemListing: { + ...makeMockGraphContext().applicationServices.Listing.ItemListing, + block: vi.fn().mockResolvedValue(undefined), + }, + }, + }, + }); + }); + 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.block with the ID', () => { + expect(context.applicationServices.Listing.ItemListing.block).toHaveBeenCalledWith({ + id: 'listing-1', + }); + }); + And('it should return true', () => { + expect(result).toBe(true); + }); + }); + Scenario('Canceling an item listing successfully', ({ Given, When, Then, And }) => { Given('a valid listing ID to cancel', () => { context = makeMockGraphContext({ 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 aed2ef2e4..69d587866 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 @@ -122,6 +122,13 @@ const itemListingResolvers: Resolvers = { }); return true; }, + blockListing: async (_parent, args, context) => { + // Admin-note: role-based authorization should be implemented here (security) + await context.applicationServices.Listing.ItemListing.block({ + id: args.id, + }); + return true; + }, cancelItemListing: async ( _parent: unknown, args: { id: string }, From b2d6bfdf5d82d86a9a5a7627f21225f2e956ada1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:59:59 +0000 Subject: [PATCH 03/76] Add frontend block/unblock listing UI with modals and mutations Co-authored-by: rohit-r-kumar <175348946+rohit-r-kumar@users.noreply.github.com> --- .../view-listing/block-listing-modal.tsx | 94 +++++++++++++++++++ .../view-listing/unblock-listing-modal.tsx | 66 +++++++++++++ .../view-listing-admin.container.graphql | 7 ++ .../view-listing/view-listing.container.tsx | 61 +++++++++++- .../components/view-listing/view-listing.tsx | 80 +++++++++++++++- 5 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.tsx create mode 100644 apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.tsx create mode 100644 apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing-admin.container.graphql 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..2f218faf1 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.tsx @@ -0,0 +1,94 @@ +import { Modal, Form, Input, Button } from 'antd'; +import { useState } from 'react'; + +const { TextArea } = Input; + +export interface BlockListingModalProps { + visible: boolean; + listingTitle: string; + onConfirm: (reason: string, description: string) => void; + onCancel: () => void; + loading?: boolean; +} + +export const BlockListingModal: React.FC = ({ + visible, + listingTitle, + onConfirm, + onCancel, + loading = false, +}) => { + const [form] = Form.useForm(); + const [charCount, setCharCount] = useState(0); + const maxChars = 100; + + const handleOk = async () => { + try { + const values = await form.validateFields(); + onConfirm(values.reason, values.description); + form.resetFields(); + setCharCount(0); + } catch (error) { + // Validation failed + } + }; + + const handleCancel = () => { + form.resetFields(); + setCharCount(0); + onCancel(); + }; + + const handleDescriptionChange = ( + e: React.ChangeEvent, + ) => { + setCharCount(e.target.value.length); + }; + + return ( + + Cancel + , + , + ]} + > +
+ + + + +