diff --git a/apps/ui-sharethrift/package.json b/apps/ui-sharethrift/package.json index 295eaee38..d4275b2d2 100644 --- a/apps/ui-sharethrift/package.json +++ b/apps/ui-sharethrift/package.json @@ -20,7 +20,9 @@ "@ant-design/v5-patch-for-react-19": "^1.0.3", "@apollo/client": "^4.0.7", "@sthrift/ui-components": "workspace:*", + "@twilio/conversations": "^2.6.3", "antd": "^5.27.1", + "clean": "^4.0.2", "crypto-hash": "^3.1.0", "dayjs": "^1.11.18", "graphql": "^16.11.0", @@ -43,6 +45,7 @@ "@storybook/react": "^9.1.17", "@storybook/react-vite": "^9.1.17", "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", "@types/lodash": "^4.17.20", "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", diff --git a/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-card.tsx b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-card.tsx index 1403fbc85..5e7f04bde 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-card.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-card.tsx @@ -23,14 +23,18 @@ const AllListingsCard: React.FC = ({ if (status === 'Active' || status === 'Reserved') { buttons.push( - , + + , ); } diff --git a/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.container.graphql b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.container.graphql index b9e9c0ca4..f8aeb86f9 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.container.graphql +++ b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.container.graphql @@ -1,63 +1,71 @@ fragment HomeAllListingsTableContainerListingFields on ListingAll { - id - title - state - images - createdAt - sharingPeriodStart - sharingPeriodEnd + id + title + state + images + createdAt + sharingPeriodStart + sharingPeriodEnd } fragment HomeAllListingsTableContainerItemListingFields on ItemListing { - id - title - state - images - createdAt - sharingPeriodStart - sharingPeriodEnd + id + title + state + images + createdAt + sharingPeriodStart + sharingPeriodEnd } query HomeAllListingsTableContainerMyListingsAll( - $page: Int! - $pageSize: Int! - $searchText: String - $statusFilters: [String!] - $sorter: SorterInput + $page: Int! + $pageSize: Int! + $searchText: String + $statusFilters: [String!] + $sorter: SorterInput ) { - myListingsAll( - page: $page - pageSize: $pageSize - searchText: $searchText - statusFilters: $statusFilters - sorter: $sorter - ) { - items { - ...HomeAllListingsTableContainerListingFields - } - total - page - pageSize - } + myListingsAll( + page: $page + pageSize: $pageSize + searchText: $searchText + statusFilters: $statusFilters + sorter: $sorter + ) { + items { + ...HomeAllListingsTableContainerListingFields + } + total + page + pageSize + } +} + +mutation HomeAllListingsTableContainerPauseItemListing($id: ObjectID!) { + pauseItemListing(id: $id) { + id + state + updatedAt + } } mutation HomeAllListingsTableContainerCancelItemListing($id: ObjectID!) { - cancelItemListing(id: $id) { - status { - success - errorMessage - } - listing { - ...HomeAllListingsTableContainerItemListingFields - } - } + cancelItemListing(id: $id) { + status { + success + errorMessage + } + listing { + ...HomeAllListingsTableContainerItemListingFields + } + } } mutation HomeAllListingsTableContainerDeleteListing($id: ObjectID!) { - deleteItemListing(id: $id) { - status { - success - errorMessage - } - } -} \ No newline at end of file + deleteItemListing(id: $id) { + status { + success + errorMessage + } + } +} diff --git a/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.container.test.tsx b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.container.test.tsx new file mode 100644 index 000000000..1fbc8b3da --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.container.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/client/testing/react'; +import { AllListingsTableContainer } from './all-listings-table.container'; +import { HomeAllListingsTableContainerMyListingsAllDocument } from '../../../../../generated.tsx'; + +// Mock Ant Design message +vi.mock('antd', async () => { + const actual = await vi.importActual('antd'); + return { + ...actual, + message: { + success: vi.fn(), + error: vi.fn(), + }, + }; +}); + +describe('AllListingsTableContainer', () => { + const mockListingsData = { + myListingsAll: { + items: [ + { + id: 'listing-1', + title: 'Test Listing', + state: 'Published', + images: ['image1.jpg'], + createdAt: '2025-01-01T00:00:00Z', + sharingPeriodStart: '2025-02-01T00:00:00Z', + sharingPeriodEnd: '2025-02-28T00:00:00Z', + }, + ], + total: 1, + page: 1, + pageSize: 6, + }, + }; + + const mocks = [ + { + request: { + query: HomeAllListingsTableContainerMyListingsAllDocument, + variables: { + page: 1, + pageSize: 6, + searchText: '', + statusFilters: [], + sorter: undefined, + }, + }, + result: { + data: mockListingsData, + }, + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render and display listings', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText('Test Listing')).toBeTruthy(); + }); + }); + + it('should map domain state to UI status correctly', async () => { + const mocksWithPaused = [ + { + request: { + query: HomeAllListingsTableContainerMyListingsAllDocument, + variables: { + page: 1, + pageSize: 6, + searchText: '', + statusFilters: [], + sorter: undefined, + }, + }, + result: { + data: { + myListingsAll: { + items: [ + { + id: 'listing-1', + title: 'Published Listing', + state: 'Published', + images: [], + createdAt: '2025-01-01T00:00:00Z', + sharingPeriodStart: '2025-02-01T00:00:00Z', + sharingPeriodEnd: '2025-02-28T00:00:00Z', + }, + { + id: 'listing-2', + title: 'Paused Listing', + state: 'Paused', + images: [], + createdAt: '2025-01-01T00:00:00Z', + sharingPeriodStart: '2025-02-01T00:00:00Z', + sharingPeriodEnd: '2025-02-28T00:00:00Z', + }, + ], + total: 2, + page: 1, + pageSize: 6, + }, + }, + }, + }, + ]; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText('Published Listing')).toBeTruthy(); + expect(screen.getByText('Paused Listing')).toBeTruthy(); + }); + }); +}); diff --git a/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.container.tsx b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.container.tsx index 546d00a0c..386d7ecdf 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.container.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.container.tsx @@ -6,6 +6,7 @@ import { HomeAllListingsTableContainerCancelItemListingDocument, HomeAllListingsTableContainerDeleteListingDocument, HomeAllListingsTableContainerMyListingsAllDocument, + HomeAllListingsTableContainerPauseItemListingDocument, } from '../../../../../generated.tsx'; import { AllListingsTable } from './all-listings-table.tsx'; @@ -61,6 +62,19 @@ export const AllListingsTableContainer: React.FC< }, ); + const [pauseListing] = useMutation( + HomeAllListingsTableContainerPauseItemListingDocument, + { + onCompleted: () => { + message.success('Listing paused successfully'); + refetch(); + }, + onError: (error) => { + message.error(`Failed to pause listing: ${error.message}`); + }, + }, + ); + const [deleteListing] = useMutation( HomeAllListingsTableContainerDeleteListingDocument, { @@ -81,6 +95,7 @@ export const AllListingsTableContainer: React.FC< ); const listings = data?.myListingsAll?.items ?? []; + console.log('Listings data:', data); const total = data?.myListingsAll?.total ?? 0; const handleSearch = (value: string) => { @@ -112,6 +127,8 @@ export const AllListingsTableContainer: React.FC< const handleAction = async (action: string, listingId: string) => { if (action === 'cancel') { await cancelListing({ variables: { id: listingId } }); + } else if (action === 'pause') { + await pauseListing({ variables: { id: listingId } }); } else if (action === 'delete') { await deleteListing({ variables: { id: listingId } }); } else { diff --git a/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.tsx b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.tsx index 107b36d52..6f31c2300 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/my-listings/components/all-listings-table.tsx @@ -63,14 +63,18 @@ export const AllListingsTable: React.FC = ({ // Conditional actions based on status if (status === 'Active' || status === 'Reserved') { buttons.push( - + + , ); } diff --git a/apps/ui-sharethrift/src/components/layouts/home/my-listings/stories/all-listings-table.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/my-listings/stories/all-listings-table.stories.tsx new file mode 100644 index 000000000..fc0c25d4b --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/my-listings/stories/all-listings-table.stories.tsx @@ -0,0 +1,97 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { AllListingsTable } from '../components/all-listings-table.tsx'; + +const MOCK_LISTINGS = [ + { + id: '1', + title: 'Cordless Drill', + image: '/assets/item-images/projector.png', + publishedAt: '2025-12-23', + reservationPeriod: '2020-11-08 - 2020-12-23', + status: 'Paused', + pendingRequestsCount: 0, + }, + { + id: '2', + title: 'Electric Guitar', + image: '/assets/item-images/projector.png', + publishedAt: '2025-08-30', + reservationPeriod: '2025-09-01 - 2025-09-30', + status: 'Active', + pendingRequestsCount: 3, + }, + { + id: '3', + title: 'City Bike', + image: '/assets/item-images/bike.png', + publishedAt: '2025-01-15', + reservationPeriod: '2025-02-01 - 2025-02-28', + status: 'Reserved', + pendingRequestsCount: 1, + }, +]; + +const meta: Meta = { + title: 'My Listings/All Listings Table', + component: AllListingsTable, + args: { + data: MOCK_LISTINGS, + searchText: '', + statusFilters: [], + sorter: { field: null, order: null }, + currentPage: 1, + pageSize: 6, + total: MOCK_LISTINGS.length, + onSearch: (value: string) => console.log('Search:', value), + onStatusFilter: (values: string[]) => console.log('Status filter:', values), + onTableChange: (pagination, filters, sorter, extra) => + console.log('Table change:', { pagination, filters, sorter, extra }), + onPageChange: (page: number) => console.log('Page change:', page), + onAction: (action: string, listingId: string) => + console.log('Action:', action, 'Listing:', listingId), + onViewAllRequests: (listingId: string) => + console.log('View all requests:', listingId), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithPauseAction: Story = { + args: { + ...meta.args, + onAction: (action: string, listingId: string) => { + if (action === 'pause') { + console.log('Pause action triggered for listing:', listingId); + alert(`Pausing listing ${listingId}. In real app, this would show a confirmation modal.`); + } else { + console.log('Action:', action, 'Listing:', listingId); + } + }, + }, +}; + +export const ActiveListingsWithPause: Story = { + args: { + ...meta.args, + data: MOCK_LISTINGS.filter((listing) => listing.status === 'Active' || listing.status === 'Reserved'), + onAction: (action: string, listingId: string) => { + console.log('Action:', action, 'Listing:', listingId); + if (action === 'pause') { + alert(`Pause confirmation would appear for listing ${listingId}`); + } + }, + }, +}; + +export const PausedListings: Story = { + args: { + ...meta.args, + data: MOCK_LISTINGS.filter((listing) => listing.status === 'Paused'), + onAction: (action: string, listingId: string) => { + console.log('Action:', action, 'Listing:', listingId); + }, + }, +}; diff --git a/apps/ui-sharethrift/tsconfig.json b/apps/ui-sharethrift/tsconfig.json index f23790092..45bc46d60 100644 --- a/apps/ui-sharethrift/tsconfig.json +++ b/apps/ui-sharethrift/tsconfig.json @@ -9,8 +9,16 @@ "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", - "allowImportingTsExtensions": true + "allowImportingTsExtensions": true, + "noPropertyAccessFromIndexSignature": false }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] } diff --git a/apps/ui-sharethrift/tsconfig.test.json b/apps/ui-sharethrift/tsconfig.test.json new file mode 100644 index 000000000..42848543a --- /dev/null +++ b/apps/ui-sharethrift/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals", "node"] + }, + "include": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ], + "exclude": ["node_modules", "dist"] +} diff --git a/package.json b/package.json index 70c39c710..290b2965b 100644 --- a/package.json +++ b/package.json @@ -1,100 +1,99 @@ { - "name": "sharethrift", - "version": "1.0.0", - "description": "", - "type": "module", - "author": "Simnova", - "license": "MIT", - "packageManager": "pnpm@10.18.2", - "main": "apps/api/dist/src/index.js", - "scripts": { - "analyze": "pnpm -r exec -- pnpm dlx @e18e/cli analyze", - "build": "turbo run build", - "test": "turbo run test", - "lint": "turbo run lint", - "setup:certs": "node scripts/setup-local-certs.js", - "proxy:start": "node build-pipeline/scripts/local-https-proxy.js", - "dev": "pnpm run build && pnpm run setup:certs && turbo run //#proxy:start azurite gen:watch start --parallel", - "start": "turbo run build && concurrently pnpm:start:* --kill-others-on-fail --workspace=@sthrift/api", - "format": "turbo run format", - "gen": "graphql-codegen --config codegen.yml", - "gen:watch": "graphql-codegen --config codegen.yml --watch", - "tsbuild": "tsc --build", - "tswatch": "tsc --build --watch", - "clean": "pnpm install && turbo run clean && rimraf dist node_modules **/coverage .turbo", - "start:api": "pnpm --filter=@sthrift/api run start", - "start:ui-sharethrift": "pnpm --filter=@sthrift/ui-sharethrift run dev", - "start:docs": "pnpm --filter=@sthrift/docs run start", - "start-emulator:mongo-memory-server": "pnpm --filter=@cellix/mock-mongodb-memory-server run start", - "start-emulator:auth-server": "pnpm --filter=@cellix/mock-oauth2-server run start", - "start-emulator:payment-server": "pnpm --filter=@cellix/mock-payment-server run start", - "start-emulator:messaging-server": "pnpm --filter=@sthrift/mock-messaging-server run start", - "test:all": "turbo run test:all", - "test:coverage": "turbo run test:coverage:ui && turbo run test:coverage:node", - "test:coverage:node": "turbo run test:coverage:node", - "test:coverage:ui": "turbo run test:coverage:ui", - "test:coverage:merge": "pnpm run test:coverage && pnpm run merge-lcov-reports", - "merge-lcov-reports": "node build-pipeline/scripts/merge-coverage.js", - "test:integration": "turbo run test:integration", - "test:serenity": "turbo run test:serenity", - "test:unit": "turbo run test:unit", - "test:watch": "turbo run test:watch --concurrency 15", - "sonar": "sonar-scanner", - "sonar:pr": "export PR_NUMBER=$(node build-pipeline/scripts/get-pr-number.cjs) && sonar-scanner -Dsonar.pullrequest.key=$PR_NUMBER -Dsonar.pullrequest.branch=$(git branch --show-current) -Dsonar.pullrequest.base=main", - "sonar:pr-windows": "for /f %i in ('node build-pipeline/scripts/get-pr-number.cjs') do set PR_NUMBER=%i && sonar-scanner -Dsonar.pullrequest.key=%PR_NUMBER% -Dsonar.pullrequest.branch=%BRANCH_NAME% -Dsonar.pullrequest.base=main", - "verify": "pnpm run test:coverage:merge && pnpm run knip && pnpm run snyk && pnpm run sonar:pr && pnpm run check-sonar", - "knip": "knip", - "snyk": "pnpm run snyk:test && pnpm run snyk:code", - "snyk:report": "pnpm run snyk:monitor && pnpm run snyk:code:report", - "snyk:test": "snyk test --all-projects --org=simnova --remote-repo-url=https://github.com/simnova/sharethrift --policy-path=.snyk --exclude=dist,build,.turbo,coverage", - "snyk:monitor": "snyk monitor --all-projects --org=simnova --target-reference=main --remote-repo-url=https://github.com/simnova/sharethrift --project-name-prefix=\"sharethrift/\" --policy-path=.snyk --exclude=dist,build,.turbo,coverage", - "snyk:code": "snyk code test --org=simnova --remote-repo-url=https://github.com/simnova/sharethrift --severity-threshold=medium", - "snyk:code:report": "snyk code test --org=simnova --remote-repo-url=https://github.com/simnova/sharethrift --target-reference=main --project-name=sharethrift-code --report", - "snyk:iac": "snyk iac test iac/**/*.bicep apps/**/iac/**/*.bicep --org=simnova --remote-repo-url=https://github.com/simnova/sharethrift", - "snyk:iac:report": "snyk iac test iac/**/*.bicep apps/**/iac/**/*.bicep --org=simnova --remote-repo-url=https://github.com/simnova/sharethrift --target-reference=main --target-name=sharethrift-iac --report" - }, - "pnpm": { - "auditConfig": { - "ignoreGhsas": [ - "GHSA-6rw7-vpxm-498p" - ] - }, - "overrides": { - "@pnpm/npm-conf": ">=3.0.2", - "@types/node": "^24.10.7", - "lodash": "^4.17.23" - } - }, - "catalog": { - "@azure/functions": "4.8.0" - }, - "devDependencies": { - - "@amiceli/vitest-cucumber": "^6.1.0", - "@biomejs/biome": "2.0.0", - "@graphql-codegen/cli": "^5.0.7", - "@graphql-codegen/introspection": "^4.0.3", - "@graphql-codegen/typed-document-node": "^5.1.2", - "@graphql-codegen/typescript": "^4.1.6", - "@graphql-codegen/typescript-operations": "^4.6.1", - "@graphql-codegen/typescript-resolvers": "^4.5.1", - "@parcel/watcher": "^2.5.1", - "@playwright/test": "^1.55.1", - "@sonar/scan": "^4.3.0", - "@types/node": "^24.7.2", - "@vitest/browser-playwright": "catalog:", - "@vitest/coverage-v8": "catalog:", - "azurite": "^3.35.0", - "concurrently": "^9.1.2", - "cpx2": "^3.0.2", - "knip": "^5.61.1", - "rimraf": "^6.0.1", - "rollup": "3.29.5", - "snyk": "^1.1301.0", - "tsx": "^4.20.3", - "turbo": "^2.5.8", - "typescript": "^5.8.3", - "vite": "catalog:", - "vitest": "catalog:" - } -} \ No newline at end of file + "name": "sharethrift", + "version": "1.0.0", + "description": "", + "type": "module", + "author": "Simnova", + "license": "MIT", + "packageManager": "pnpm@10.18.2", + "main": "apps/api/dist/src/index.js", + "scripts": { + "analyze": "pnpm -r exec -- pnpm dlx @e18e/cli analyze", + "build": "turbo run build", + "test": "turbo run test", + "lint": "turbo run lint", + "setup:certs": "node scripts/setup-local-certs.js", + "proxy:start": "node build-pipeline/scripts/local-https-proxy.js", + "dev": "pnpm run build && pnpm run setup:certs && turbo run //#proxy:start azurite gen:watch start --parallel", + "start": "turbo run build && concurrently pnpm:start:* --kill-others-on-fail --workspace=@sthrift/api", + "format": "turbo run format", + "gen": "graphql-codegen --config codegen.yml", + "gen:watch": "graphql-codegen --config codegen.yml --watch", + "tsbuild": "tsc --build", + "tswatch": "tsc --build --watch", + "clean": "pnpm install && turbo run clean && rimraf dist node_modules **/coverage .turbo", + "start:api": "pnpm --filter=@sthrift/api run start", + "start:ui-sharethrift": "pnpm --filter=@sthrift/ui-sharethrift run dev", + "start:docs": "pnpm --filter=@sthrift/docs run start", + "start-emulator:mongo-memory-server": "pnpm --filter=@cellix/mock-mongodb-memory-server run start", + "start-emulator:auth-server": "pnpm --filter=@cellix/mock-oauth2-server run start", + "start-emulator:payment-server": "pnpm --filter=@cellix/mock-payment-server run start", + "start-emulator:messaging-server": "pnpm --filter=@sthrift/mock-messaging-server run start", + "test:all": "turbo run test:all", + "test:coverage": "turbo run test:coverage:ui && turbo run test:coverage:node", + "test:coverage:node": "turbo run test:coverage:node", + "test:coverage:ui": "turbo run test:coverage:ui", + "test:coverage:merge": "pnpm run test:coverage && pnpm run merge-lcov-reports", + "merge-lcov-reports": "node build-pipeline/scripts/merge-coverage.js", + "test:integration": "turbo run test:integration", + "test:serenity": "turbo run test:serenity", + "test:unit": "turbo run test:unit", + "test:watch": "turbo run test:watch --concurrency 15", + "sonar": "sonar-scanner", + "sonar:pr": "export PR_NUMBER=$(node build-pipeline/scripts/get-pr-number.cjs) && sonar-scanner -Dsonar.pullrequest.key=$PR_NUMBER -Dsonar.pullrequest.branch=$(git branch --show-current) -Dsonar.pullrequest.base=main", + "sonar:pr-windows": "for /f %i in ('node build-pipeline/scripts/get-pr-number.cjs') do set PR_NUMBER=%i && sonar-scanner -Dsonar.pullrequest.key=%PR_NUMBER% -Dsonar.pullrequest.branch=%BRANCH_NAME% -Dsonar.pullrequest.base=main", + "verify": "pnpm run test:coverage:merge && pnpm run knip && pnpm run snyk && pnpm run sonar:pr && pnpm run check-sonar", + "knip": "knip", + "snyk": "pnpm run snyk:test && pnpm run snyk:code", + "snyk:report": "pnpm run snyk:monitor && pnpm run snyk:code:report", + "snyk:test": "snyk test --all-projects --org=simnova --remote-repo-url=https://github.com/simnova/sharethrift --policy-path=.snyk --exclude=dist,build,.turbo,coverage", + "snyk:monitor": "snyk monitor --all-projects --org=simnova --target-reference=main --remote-repo-url=https://github.com/simnova/sharethrift --project-name-prefix=\"sharethrift/\" --policy-path=.snyk --exclude=dist,build,.turbo,coverage", + "snyk:code": "snyk code test --org=simnova --remote-repo-url=https://github.com/simnova/sharethrift --severity-threshold=medium", + "snyk:code:report": "snyk code test --org=simnova --remote-repo-url=https://github.com/simnova/sharethrift --target-reference=main --project-name=sharethrift-code --report", + "snyk:iac": "snyk iac test iac/**/*.bicep apps/**/iac/**/*.bicep --org=simnova --remote-repo-url=https://github.com/simnova/sharethrift", + "snyk:iac:report": "snyk iac test iac/**/*.bicep apps/**/iac/**/*.bicep --org=simnova --remote-repo-url=https://github.com/simnova/sharethrift --target-reference=main --target-name=sharethrift-iac --report" + }, + "pnpm": { + "auditConfig": { + "ignoreGhsas": [ + "GHSA-6rw7-vpxm-498p" + ] + }, + "overrides": { + "@pnpm/npm-conf": ">=3.0.2", + "@types/node": "^24.10.7", + "lodash": "^4.17.23" + } + }, + "catalog": { + "@azure/functions": "4.8.0" + }, + "devDependencies": { + "@amiceli/vitest-cucumber": "^6.1.0", + "@biomejs/biome": "2.0.0", + "@graphql-codegen/cli": "^5.0.7", + "@graphql-codegen/introspection": "^4.0.3", + "@graphql-codegen/typed-document-node": "^5.1.2", + "@graphql-codegen/typescript": "^4.1.6", + "@graphql-codegen/typescript-operations": "^4.6.1", + "@graphql-codegen/typescript-resolvers": "^4.5.1", + "@parcel/watcher": "^2.5.1", + "@playwright/test": "^1.55.1", + "@sonar/scan": "^4.3.0", + "@types/node": "^24.7.2", + "@vitest/browser-playwright": "catalog:", + "@vitest/coverage-v8": "catalog:", + "azurite": "^3.35.0", + "concurrently": "^9.1.2", + "cpx2": "^3.0.2", + "knip": "^5.61.1", + "rimraf": "^6.0.1", + "rollup": "3.29.5", + "snyk": "^1.1301.0", + "tsx": "^4.20.3", + "turbo": "^2.5.8", + "typescript": "^5.8.3", + "vite": "catalog:", + "vitest": "catalog:" + } +} 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..31f9d7e43 100644 --- a/packages/sthrift/application-services/src/contexts/listing/item/index.ts +++ b/packages/sthrift/application-services/src/contexts/listing/item/index.ts @@ -8,6 +8,7 @@ import { } from './query-by-sharer.ts'; import { type ItemListingQueryAllCommand, queryAll } from './query-all.ts'; import { type ItemListingCancelCommand, cancel } from './cancel.ts'; +import { type ItemListingPauseCommand, pause } from './pause.ts'; import { type ItemListingDeleteCommand, deleteListings } from './delete.ts'; import { type ItemListingUpdateCommand, update } from './update.ts'; import { type ItemListingUnblockCommand, unblock } from './unblock.ts'; @@ -22,7 +23,9 @@ export interface ItemListingApplicationService { ) => Promise; queryBySharer: ( command: ItemListingQueryBySharerCommand, - ) => Promise; + ) => Promise< + Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[] + >; queryAll: ( command: ItemListingQueryAllCommand, ) => Promise< @@ -31,6 +34,9 @@ export interface ItemListingApplicationService { cancel: ( command: ItemListingCancelCommand, ) => Promise; + pause: ( + command: ItemListingPauseCommand, + ) => Promise; update: ( command: ItemListingUpdateCommand, ) => Promise; @@ -62,8 +68,9 @@ export const ItemListing = ( queryBySharer: queryBySharer(dataSources), queryAll: queryAll(dataSources), cancel: cancel(dataSources), + pause: pause(dataSources), update: update(dataSources), - deleteListings: deleteListings(dataSources), + deleteListings: deleteListings(dataSources), unblock: unblock(dataSources), queryPaged: queryPaged(dataSources), }; diff --git a/packages/sthrift/application-services/src/contexts/listing/item/pause.test.ts b/packages/sthrift/application-services/src/contexts/listing/item/pause.test.ts new file mode 100644 index 000000000..a697caeff --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/listing/item/pause.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Domain } from '@sthrift/domain'; +import type { DataSources } from '@sthrift/persistence'; +import { pause, type ItemListingPauseCommand } from './pause.ts'; + +describe('pause', () => { + let mockDataSources: DataSources; + let mockListing: Domain.Contexts.Listing.ItemListing.ItemListingEntityReference; + let mockRepo: { + getById: ReturnType; + save: ReturnType; + }; + let mockUow: { + withScopedTransaction: ReturnType; + }; + + beforeEach(() => { + mockListing = { + id: 'listing-1', + title: 'Test Listing', + description: 'Test description', + category: 'Electronics', + location: 'Delhi', + sharingPeriodStart: new Date('2025-10-06'), + sharingPeriodEnd: new Date('2025-11-06'), + state: 'Published', + sharer: { + id: 'user-1', + } as Domain.Contexts.User.PersonalUser.PersonalUserEntityReference, + images: [], + reports: 0, + sharingHistory: [], + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0.0', + listingType: 'item-listing', + }; + + mockRepo = { + getById: vi.fn().mockResolvedValue(mockListing), + save: vi.fn().mockResolvedValue({ + ...mockListing, + state: 'Paused', + }), + }; + + mockUow = { + withScopedTransaction: vi.fn().mockImplementation(async (callback) => { + return await callback(mockRepo); + }), + }; + + mockDataSources = { + domainDataSource: { + Listing: { + ItemListing: { + ItemListingUnitOfWork: mockUow, + }, + }, + }, + } as unknown as DataSources; + }); + + it('should pause a listing successfully', async () => { + const command: ItemListingPauseCommand = { id: 'listing-1' }; + const pauseFn = pause(mockDataSources); + + // Mock the pause method on the listing + const listingWithPause = { + ...mockListing, + pause: vi.fn(), + }; + mockRepo.getById = vi.fn().mockResolvedValue(listingWithPause); + + const result = await pauseFn(command); + + expect(mockUow.withScopedTransaction).toHaveBeenCalled(); + expect(mockRepo.getById).toHaveBeenCalledWith('listing-1'); + expect(listingWithPause.pause).toHaveBeenCalled(); + expect(mockRepo.save).toHaveBeenCalledWith(listingWithPause); + expect(result.state).toBe('Paused'); + }); + + it('should throw error when listing is not found', async () => { + const command: ItemListingPauseCommand = { id: 'nonexistent-id' }; + const pauseFn = pause(mockDataSources); + + mockRepo.getById = vi.fn().mockResolvedValue(null); + + await expect(pauseFn(command)).rejects.toThrow('Listing not found'); + expect(mockUow.withScopedTransaction).toHaveBeenCalled(); + expect(mockRepo.getById).toHaveBeenCalledWith('nonexistent-id'); + expect(mockRepo.save).not.toHaveBeenCalled(); + }); + + it('should throw error when save fails', async () => { + const command: ItemListingPauseCommand = { id: 'listing-1' }; + const pauseFn = pause(mockDataSources); + + const listingWithPause = { + ...mockListing, + pause: vi.fn(), + }; + mockRepo.getById = vi.fn().mockResolvedValue(listingWithPause); + mockRepo.save = vi.fn().mockResolvedValue(undefined); + + await expect(pauseFn(command)).rejects.toThrow('ItemListing not paused'); + expect(mockUow.withScopedTransaction).toHaveBeenCalled(); + expect(mockRepo.getById).toHaveBeenCalledWith('listing-1'); + expect(listingWithPause.pause).toHaveBeenCalled(); + expect(mockRepo.save).toHaveBeenCalled(); + }); + + it('should throw error when repository throws', async () => { + const command: ItemListingPauseCommand = { id: 'listing-1' }; + const pauseFn = pause(mockDataSources); + + mockUow.withScopedTransaction = vi.fn().mockRejectedValue( + new Error('Database error'), + ); + + await expect(pauseFn(command)).rejects.toThrow('Database error'); + }); +}); + diff --git a/packages/sthrift/application-services/src/contexts/listing/item/pause.ts b/packages/sthrift/application-services/src/contexts/listing/item/pause.ts new file mode 100644 index 000000000..a932a96e4 --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/listing/item/pause.ts @@ -0,0 +1,37 @@ +import type { Domain } from '@sthrift/domain'; +import type { DataSources } from '@sthrift/persistence'; + +export interface ItemListingPauseCommand { + id: string; + userEmail: string; +} + +export const pause = (dataSources: DataSources) => { + return async ( + command: ItemListingPauseCommand, + ): 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'); + } + + // Ownership check: only the sharer who owns the listing can pause it + if (listing.sharer?.account?.email !== command.userEmail) { + throw new Error('Only the listing owner can pause this listing'); + } + + listing.pause(); + itemListingToReturn = await repo.save(listing); + }, + ); + if (!itemListingToReturn) { + throw new Error('ItemListing not paused'); + } + return itemListingToReturn; + }; +}; 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..11c9fa8a6 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[]; + excludeStates?: string[]; } export const queryAll = (dataSources: DataSources) => { @@ -11,8 +12,15 @@ export const queryAll = (dataSources: DataSources) => { ): Promise< Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[] > => { + const options: Parameters< + typeof dataSources.readonlyDataSource.Listing.ItemListing.ItemListingReadRepo.getAll + >[0] = { + ...(command.fields && { fields: command.fields }), + ...(command.excludeStates && { excludeStates: command.excludeStates }), + }; + return await dataSources.readonlyDataSource.Listing.ItemListing.ItemListingReadRepo.getAll( - { fields: command.fields }, + options, ); }; }; 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..973ea6b1b 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 @@ -86,6 +86,29 @@ So that I can view, filter, and create listings through the GraphQL API Given Listing.ItemListing.create throws an error When the createItemListing mutation is executed Then it should propagate the error message + + Scenario: Pausing an item listing successfully + Given a user with a verifiedJwt in their context + And a valid listing ID for an active listing + When the pauseItemListing mutation is executed + Then it should call Listing.ItemListing.pause with the listing ID + And it should return the paused listing with state "Paused" + + Scenario: Pausing an item listing without authentication + Given a user without a verifiedJwt in their context + When the pauseItemListing mutation is executed + Then it should throw an "Authentication required" error + + Scenario: Pausing a non-existent item listing + Given a user with a verifiedJwt in their context + And a listing ID that does not match any record + When the pauseItemListing mutation is executed + Then it should propagate the error from the application service + + Scenario: Error while pausing an item listing + Given Listing.ItemListing.pause throws an error + When the pauseItemListing mutation is executed + Then it should propagate the error message Scenario: Mapping item listing fields for myListingsAll Given a valid result from queryPaged 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..ef2bfde6c 100644 --- a/packages/sthrift/graphql/src/schema/types/listing/item-listing.graphql +++ b/packages/sthrift/graphql/src/schema/types/listing/item-listing.graphql @@ -70,13 +70,14 @@ input CreateItemListingInput { } type ItemListingMutationResult implements MutationResult { - status: MutationStatus! - listing: ItemListing + status: MutationStatus! + listing: ItemListing } extend type Mutation { createItemListing(input: CreateItemListingInput!): ItemListing! cancelItemListing(id: ObjectID!): ItemListingMutationResult! + pauseItemListing(id: ObjectID!): ItemListing! deleteItemListing(id: ObjectID!): ItemListingMutationResult! unblockListing(id: ObjectID!): Boolean! } 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..cecedd1e2 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 @@ -123,6 +123,13 @@ function makeMockGraphContext( queryPaged: vi.fn(), create: vi.fn(), update: vi.fn(), + cancel: vi.fn(), + pause: vi.fn(), +<<<<<<< HEAD +======= + deleteListings: vi.fn(), + unblock: vi.fn(), +>>>>>>> bd488a89b1b24e6caab31f1bc89d06fba8ee8be2 }, }, User: { @@ -163,7 +170,7 @@ test.for(feature, ({ Scenario }) => { Then('it should call Listing.ItemListing.queryAll', () => { expect( context.applicationServices.Listing.ItemListing.queryAll, - ).toHaveBeenCalledWith({}); + ).toHaveBeenCalledWith({ excludeStates: ['Paused'] }); }); And('it should return a list of item listings', () => { expect(result).toBeDefined(); @@ -195,7 +202,7 @@ test.for(feature, ({ Scenario }) => { Then('it should call Listing.ItemListing.queryAll', () => { expect( context.applicationServices.Listing.ItemListing.queryAll, - ).toHaveBeenCalledWith({}); + ).toHaveBeenCalledWith({ excludeStates: ['Paused'] }); }); And('it should return all available listings', () => { expect(result).toBeDefined(); @@ -811,6 +818,7 @@ test.for(feature, ({ Scenario }) => { ); Scenario( +<<<<<<< HEAD 'Querying myListingsAll with sorting by title ascending', ({ Given, And, When, Then }) => { Given('a verified user and valid pagination arguments', () => { @@ -863,11 +871,42 @@ test.for(feature, ({ Scenario }) => { expect(result).toBeDefined(); const resultData = result as { items: ItemListingEntity[] }; expect(resultData.items.length).toBe(2); +======= + 'Pausing an item listing successfully', + ({ Given, And, When, Then }) => { + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + And('a valid listing ID for an active listing', () => { + vi.mocked( + context.applicationServices.Listing.ItemListing.pause, + ).mockResolvedValue( + createMockListing({ id: 'listing-1', state: 'Paused' }), + ); + }); + When('the pauseItemListing mutation is executed', async () => { + const resolver = itemListingResolvers.Mutation + ?.pauseItemListing as TestResolver<{ id: string }>; + result = await resolver({}, { id: 'listing-1' }, context, {} as never); + }); + Then( + 'it should call Listing.ItemListing.pause with the listing ID', + () => { + expect( + context.applicationServices.Listing.ItemListing.pause, + ).toHaveBeenCalledWith({ id: 'listing-1', userEmail: 'test@example.com' }); + }, + ); + And('it should return the paused listing with state "Paused"', () => { + expect(result).toBeDefined(); + expect((result as { state: string }).state).toBe('Paused'); +>>>>>>> bd488a89b1b24e6caab31f1bc89d06fba8ee8be2 }); }, ); Scenario( +<<<<<<< HEAD 'Querying myListingsAll with sorting by createdAt descending', ({ Given, And, When, Then }) => { Given('a verified user and valid pagination arguments', () => { @@ -922,6 +961,157 @@ test.for(feature, ({ Scenario }) => { }, ); + Scenario('Pausing an item listing successfully', ({ Given, And, When, Then }) => { + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + And('a valid listing ID for an active listing', () => { + vi.mocked( + context.applicationServices.Listing.ItemListing.pause, + ).mockResolvedValue(createMockListing({ id: 'listing-1', state: 'Paused' })); + }); + When('the pauseItemListing mutation is executed', async () => { + const resolver = itemListingResolvers.Mutation?.pauseItemListing as TestResolver<{ id: string }>; + result = await resolver({}, { id: 'listing-1' }, context, {} as never); + }); + Then('it should call Listing.ItemListing.pause with the listing ID', () => { + expect( + context.applicationServices.Listing.ItemListing.pause, + ).toHaveBeenCalledWith({ id: 'listing-1' }); + }); + And('it should return the paused listing with state "Paused"', () => { + expect(result).toBeDefined(); + expect((result as { state: string }).state).toBe('Paused'); + }); + }); + + Scenario('Pausing an item listing without authentication', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ + applicationServices: { + ...makeMockGraphContext().applicationServices, + verifiedUser: null, + }, + }); + }); + When('the pauseItemListing mutation is executed', async () => { + try { + const resolver = itemListingResolvers.Mutation?.pauseItemListing as TestResolver<{ id: string }>; + await resolver({}, { id: 'listing-1' }, context, {} as never); + } catch (e) { + error = e as Error; + } + }); + Then('it should throw an "Authentication required" error', () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('Authentication required'); + }); + }); + + Scenario('Pausing a non-existent item listing', ({ Given, And, When, Then }) => { + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + And('a listing ID that does not match any record', () => { + vi.mocked( + context.applicationServices.Listing.ItemListing.pause, + ).mockRejectedValue(new Error('Listing not found')); + }); + When('the pauseItemListing mutation is executed', async () => { + try { + const resolver = itemListingResolvers.Mutation?.pauseItemListing as TestResolver<{ id: string }>; + await resolver({}, { id: 'nonexistent-id' }, context, {} as never); + } catch (e) { + error = e as Error; + } + }); + Then('it should propagate the error from the application service', () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('Listing not found'); + }); + }); +======= + 'Pausing an item listing without authentication', + ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ + applicationServices: { + ...makeMockGraphContext().applicationServices, + verifiedUser: null, + }, + }); + }); + When('the pauseItemListing mutation is executed', async () => { + try { + const resolver = itemListingResolvers.Mutation + ?.pauseItemListing as TestResolver<{ id: string }>; + await resolver({}, { id: 'listing-1' }, context, {} as never); + } catch (e) { + error = e as Error; + } + }); + Then('it should throw an "Authentication required" error', () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('Authentication required'); + }); + }, + ); + + Scenario( + 'Pausing a non-existent item listing', + ({ Given, And, When, Then }) => { + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + And('a listing ID that does not match any record', () => { + vi.mocked( + context.applicationServices.Listing.ItemListing.pause, + ).mockRejectedValue(new Error('Listing not found')); + }); + When('the pauseItemListing mutation is executed', async () => { + try { + const resolver = itemListingResolvers.Mutation + ?.pauseItemListing as TestResolver<{ id: string }>; + await resolver({}, { id: 'nonexistent-id' }, context, {} as never); + } catch (e) { + error = e as Error; + } + }); + Then('it should propagate the error from the application service', () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('Listing not found'); + }); + }, + ); +>>>>>>> bd488a89b1b24e6caab31f1bc89d06fba8ee8be2 + + Scenario('Error while pausing an item listing', ({ Given, When, Then }) => { + Given('Listing.ItemListing.pause throws an error', () => { + context = makeMockGraphContext(); + vi.mocked( + context.applicationServices.Listing.ItemListing.pause, + ).mockRejectedValue(new Error('Pause failed')); + }); + When('the pauseItemListing mutation is executed', async () => { + try { +<<<<<<< HEAD + const resolver = itemListingResolvers.Mutation?.pauseItemListing as TestResolver<{ id: string }>; +======= + const resolver = itemListingResolvers.Mutation + ?.pauseItemListing as TestResolver<{ id: string }>; +>>>>>>> bd488a89b1b24e6caab31f1bc89d06fba8ee8be2 + await resolver({}, { id: 'listing-1' }, context, {} as never); + } catch (e) { + error = e as Error; + } + }); + Then('it should propagate the error message', () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('Pause failed'); + }); + }); +<<<<<<< HEAD + Scenario( 'Querying myListingsAll with invalid sorter order defaults to ascend', ({ Given, And, When, Then }) => { 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..f89cc55a0 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 @@ -39,7 +39,11 @@ const itemListingResolvers: Resolvers = { return await context.applicationServices.Listing.ItemListing.queryPaged(command); }, itemListings: async (_parent, _args, context) => { - return await context.applicationServices.Listing.ItemListing.queryAll({}); + // Filter out paused listings from search results for reservers at the database level + // Paused listings should not be visible to reservers + return await context.applicationServices.Listing.ItemListing.queryAll({ + excludeStates: ['Paused'], + }); }, itemListing: async (_parent, args, context) => { @@ -110,7 +114,7 @@ const itemListingResolvers: Resolvers = { cancelItemListing: async ( _parent: unknown, args: { id: string }, - context, + context: GraphContext, ) => ({ status: { success: true }, listing: await context.applicationServices.Listing.ItemListing.cancel({ @@ -123,12 +127,35 @@ const itemListingResolvers: Resolvers = { args: { id: string }, context: GraphContext, ) => { + const listing = + await context.applicationServices.Listing.ItemListing.queryById({ + id: args.id, + }); await context.applicationServices.Listing.ItemListing.deleteListings({ id: args.id, userEmail: context.applicationServices.verifiedUser?.verifiedJwt?.email ?? '', }); - return { status: { success: true } }; + return { + status: { success: true }, + listing, + }; + }, + pauseItemListing: async ( + _parent: unknown, + args: { id: string }, + context: GraphContext, + ) => { + const userEmail = + context.applicationServices.verifiedUser?.verifiedJwt?.email; + if (!userEmail) { + throw new Error('Authentication required'); + } + + return await context.applicationServices.Listing.ItemListing.pause({ + id: args.id, + userEmail, + }); }, }, }; 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..dfcdefb81 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 @@ -10,7 +10,7 @@ import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; export interface ItemListingReadRepository { getAll: ( - options?: FindOptions, + options?: FindOptions & { excludeStates?: string[] }, ) => Promise< Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[] >; @@ -58,14 +58,20 @@ class ItemListingReadRepositoryImpl } async getAll( - options?: FindOptions, + options?: FindOptions & { excludeStates?: string[] }, ): Promise { - const result = await this.mongoDataSource.find( - {}, - { - ...options, - }, - ); + // Build filter query + const filter: Record = {}; + + // Add state exclusion filter if provided + if (options?.excludeStates && options.excludeStates.length > 0) { + // biome-ignore lint/complexity/useLiteralKeys: MongoDB query uses index signature + filter['state'] = { $nin: options.excludeStates }; + } + + const result = await this.mongoDataSource.find(filter, { + ...options, + }); if (!result || result.length === 0) return []; return result.map((doc) => this.converter.toDomain(doc, this.passport)); } diff --git a/packages/sthrift/ui-components/scripts/copy-assets.mjs b/packages/sthrift/ui-components/scripts/copy-assets.mjs new file mode 100644 index 000000000..a869a3574 --- /dev/null +++ b/packages/sthrift/ui-components/scripts/copy-assets.mjs @@ -0,0 +1,20 @@ +import { mkdir, cp } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const srcDir = resolve(__dirname, '..', 'src'); +const destDir = resolve(__dirname, '..', 'dist', 'src'); + +await mkdir(destDir, { recursive: true }); + +// Copy all files recursively; the build only outputs JS/DTs into dist/src, +// so copying the entire src tree preserves CSS and assets alongside. +try { + await cp(srcDir, destDir, { recursive: true }); +} catch (error) { + console.error('Failed to copy assets:', error); + process.exit(1); +}