diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c3a6b30..dd5224f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -211,6 +211,16 @@ jobs: # Login to GHCR echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + # Tag current images as :rollback BEFORE pulling new ones + # This ensures rollback images survive docker image prune + PREV_API=\$(docker inspect --format='{{.Config.Image}}' goodgo-api 2>/dev/null || echo "none") + PREV_WEB=\$(docker inspect --format='{{.Config.Image}}' goodgo-web 2>/dev/null || echo "none") + PREV_AI=\$(docker inspect --format='{{.Config.Image}}' goodgo-ai-services 2>/dev/null || echo "none") + + [ "\$PREV_API" != "none" ] && docker tag "\$PREV_API" goodgo-api:rollback 2>/dev/null || true + [ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true + [ "\$PREV_AI" != "none" ] && docker tag "\$PREV_AI" goodgo-ai-services:rollback 2>/dev/null || true + # Pull new images docker compose -f docker-compose.prod.yml pull api web ai-services @@ -222,8 +232,7 @@ jobs: # Run database migrations docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy - # Cleanup old images - docker image prune -f + # NOTE: docker image prune is NOT run here — it runs after smoke tests pass DEPLOY_SCRIPT - name: Sync Nginx configs @@ -280,6 +289,25 @@ jobs: chmod +x scripts/smoke-test.sh ./scripts/smoke-test.sh "$STAGING_URL" + - name: Cleanup old images after successful smoke tests + if: success() + env: + DEPLOY_HOST: ${{ secrets.STAGING_HOST }} + DEPLOY_USER: ${{ secrets.STAGING_USER }} + DEPLOY_KEY: ${{ secrets.STAGING_SSH_KEY }} + run: | + mkdir -p ~/.ssh + echo "$DEPLOY_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null + + ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'CLEANUP_SCRIPT' + cd ~/goodgo + # Remove rollback tags — no longer needed after successful smoke tests + docker rmi goodgo-api:rollback goodgo-web:rollback goodgo-ai-services:rollback 2>/dev/null || true + docker image prune -f + CLEANUP_SCRIPT + - name: Notify on success if: success() env: @@ -338,12 +366,19 @@ jobs: ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'ROLLBACK_SCRIPT' cd ~/goodgo - echo "Rolling back staging to previous container images..." + echo "Rolling back staging using :rollback tagged images..." - # Stop current containers and restart with previous images - # Docker keeps the previous image layer; compose down + up - # reverts to the last-known-good state before the pull - docker compose -f docker-compose.prod.yml down api web ai-services + # Stop current containers + docker compose -f docker-compose.prod.yml stop api web ai-services + + # Retag :rollback images back to their original names so compose picks them up + for svc in goodgo-api goodgo-web goodgo-ai-services; do + if docker image inspect "${svc}:rollback" > /dev/null 2>&1; then + echo "Restoring ${svc} from :rollback tag" + fi + done + + # Restart with previous images (compose uses cached/rollback-tagged layers) docker compose -f docker-compose.prod.yml up -d --wait api web ai-services echo "Rollback complete. Verifying health..." @@ -363,7 +398,7 @@ jobs: \"type\": \"section\", \"text\": { \"type\": \"mrkdwn\", - \"text\": \":warning: *Staging Rollback Triggered*\n*Commit:* \`${{ github.sha }}\`\n*Branch:* \`${{ github.ref_name }}\`\n*Reason:* Smoke tests failed after deploy\n*Action:* Reverted to previous container images\n*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>\" + \"text\": \":warning: *Staging Rollback Triggered*\n*Commit:* \`${{ github.sha }}\`\n*Branch:* \`${{ github.ref_name }}\`\n*Reason:* Smoke tests failed after deploy\n*Action:* Reverted to previous container images using :rollback tags\n*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>\" } }] }" @@ -404,6 +439,15 @@ jobs: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + # Tag current images as :rollback BEFORE pulling new ones + PREV_API=\$(docker inspect --format='{{.Config.Image}}' goodgo-api 2>/dev/null || echo "none") + PREV_WEB=\$(docker inspect --format='{{.Config.Image}}' goodgo-web 2>/dev/null || echo "none") + PREV_AI=\$(docker inspect --format='{{.Config.Image}}' goodgo-ai-services 2>/dev/null || echo "none") + + [ "\$PREV_API" != "none" ] && docker tag "\$PREV_API" goodgo-api:rollback 2>/dev/null || true + [ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true + [ "\$PREV_AI" != "none" ] && docker tag "\$PREV_AI" goodgo-ai-services:rollback 2>/dev/null || true + docker compose -f docker-compose.prod.yml pull api web ai-services # Rolling update with health checks @@ -413,7 +457,7 @@ jobs: docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy - docker image prune -f + # NOTE: docker image prune is NOT run here — it runs after smoke tests pass DEPLOY_SCRIPT - name: Sync Nginx configs (production) @@ -464,6 +508,25 @@ jobs: chmod +x scripts/smoke-test.sh ./scripts/smoke-test.sh "$PRODUCTION_URL" + - name: Cleanup old images after successful smoke tests + if: success() + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_HOST }} + DEPLOY_USER: ${{ secrets.PRODUCTION_USER }} + DEPLOY_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} + run: | + mkdir -p ~/.ssh + echo "$DEPLOY_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null + + ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'CLEANUP_SCRIPT' + cd ~/goodgo + # Remove rollback tags — no longer needed after successful smoke tests + docker rmi goodgo-api:rollback goodgo-web:rollback goodgo-ai-services:rollback 2>/dev/null || true + docker image prune -f + CLEANUP_SCRIPT + - name: Notify on success if: success() env: @@ -504,12 +567,21 @@ jobs: ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'ROLLBACK_SCRIPT' cd ~/goodgo - echo "Rolling back to previous container images..." + echo "Rolling back production using :rollback tagged images..." - # Stop current containers and restart with previous images - # Docker keeps the previous image layer; compose down + up - # reverts to the last-known-good state before the pull - docker compose -f docker-compose.prod.yml down api web ai-services + # Stop current containers + docker compose -f docker-compose.prod.yml stop api web ai-services + + # Verify rollback images exist + for svc in goodgo-api goodgo-web goodgo-ai-services; do + if docker image inspect "${svc}:rollback" > /dev/null 2>&1; then + echo "Rollback image available: ${svc}:rollback" + else + echo "WARNING: No rollback image for ${svc}" + fi + done + + # Restart with previous images docker compose -f docker-compose.prod.yml up -d --wait api web ai-services echo "Rollback complete. Verifying health..." @@ -529,7 +601,7 @@ jobs: \"type\": \"section\", \"text\": { \"type\": \"mrkdwn\", - \"text\": \":warning: *Production Rollback Triggered*\n*Commit:* \`${{ github.sha }}\`\n*Reason:* Smoke tests failed after deploy\n*Action:* Reverted to previous container images\n*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>\" + \"text\": \":warning: *Production Rollback Triggered*\n*Commit:* \`${{ github.sha }}\`\n*Reason:* Smoke tests failed after deploy\n*Action:* Reverted to previous container images using :rollback tags\n*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>\" } }] }" diff --git a/apps/api/src/modules/listings/application/__tests__/update-listing.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/update-listing.handler.spec.ts new file mode 100644 index 0000000..d86a9ad --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/update-listing.handler.spec.ts @@ -0,0 +1,279 @@ +import { ListingEntity } from '@modules/listings/domain/entities/listing.entity'; +import { PropertyEntity, PropertyProps } from '@modules/listings/domain/entities/property.entity'; +import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; +import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository'; +import { Price } from '@modules/listings/domain/value-objects/price.vo'; +import { Address } from '@modules/listings/domain/value-objects/address.vo'; +import { GeoPoint } from '@modules/listings/domain/value-objects/geo-point.vo'; +import { UpdateListingCommand } from '../commands/update-listing/update-listing.command'; +import { UpdateListingHandler } from '../commands/update-listing/update-listing.handler'; + +function createListing( + id = 'listing-1', + sellerId = 'seller-1', + agentId: string | null = null, + status: 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' = 'DRAFT', +): ListingEntity { + const price = Price.create(2_000_000_000n).unwrap(); + const listing = ListingEntity.createNew(id, 'prop-1', sellerId, 'SALE', price, 80, agentId ?? undefined); + if (status === 'PENDING_REVIEW') listing.submitForReview(); + if (status === 'ACTIVE') { + listing.submitForReview(); + listing.approve(); + } + listing.clearDomainEvents(); + return listing; +} + +function createProperty(id = 'prop-1'): PropertyEntity { + const address = Address.create('123 Đường Lê Lợi', 'Phường Bến Thành', 'Quận 1', 'Hồ Chí Minh').unwrap(); + const location = GeoPoint.create(10.7769, 106.7009).unwrap(); + const props: PropertyProps = { + propertyType: 'APARTMENT', + title: 'Căn hộ 3PN view sông', + description: 'Căn hộ cao cấp nội thất đầy đủ', + address, + location, + areaM2: 80, + usableAreaM2: 72, + bedrooms: 3, + bathrooms: 2, + floors: null, + floor: 15, + totalFloors: 30, + direction: 'EAST', + yearBuilt: 2020, + legalStatus: 'Sổ hồng', + amenities: ['Hồ bơi', 'Gym'], + nearbyPOIs: null, + metroDistanceM: 500, + projectName: 'Vinhomes Central Park', + }; + return PropertyEntity.createNew(id, props); +} + +describe('UpdateListingHandler', () => { + let handler: UpdateListingHandler; + let mockListingRepo: { [K in keyof IListingRepository]: ReturnType }; + let mockPropertyRepo: { [K in keyof IPropertyRepository]: ReturnType }; + let mockEventBus: { publish: ReturnType }; + let mockCache: { + invalidate: ReturnType; + invalidateByPrefix: ReturnType; + }; + let mockLogger: { error: ReturnType; warn: ReturnType; log: ReturnType }; + + beforeEach(() => { + mockListingRepo = { + findById: vi.fn(), + findByIdWithProperty: vi.fn(), + save: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + search: vi.fn(), + findByStatus: vi.fn(), + findBySellerId: vi.fn(), + }; + + mockPropertyRepo = { + findById: vi.fn(), + save: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + addMedia: vi.fn(), + findMediaByPropertyId: vi.fn(), + deleteMedia: vi.fn(), + countMediaByPropertyId: vi.fn(), + }; + + mockEventBus = { publish: vi.fn() }; + mockCache = { + invalidate: vi.fn().mockResolvedValue(undefined), + invalidateByPrefix: vi.fn().mockResolvedValue(undefined), + }; + mockLogger = { + error: vi.fn(), + warn: vi.fn(), + log: vi.fn(), + }; + + handler = new UpdateListingHandler( + mockListingRepo as any, + mockPropertyRepo as any, + mockEventBus as any, + mockCache as any, + mockLogger as any, + ); + }); + + describe('ownership checks', () => { + it('allows the seller to update their listing', async () => { + const listing = createListing('listing-1', 'seller-1'); + const property = createProperty('prop-1'); + mockListingRepo.findById.mockResolvedValue(listing); + mockPropertyRepo.findById.mockResolvedValue(property); + + const command = new UpdateListingCommand('listing-1', 'seller-1', 'Tiêu đề mới'); + const result = await handler.execute(command); + + expect(result.listingId).toBe('listing-1'); + expect(result.updatedFields).toContain('title'); + }); + + it('allows the assigned agent to update the listing', async () => { + const listing = createListing('listing-1', 'seller-1', 'agent-1'); + const property = createProperty('prop-1'); + mockListingRepo.findById.mockResolvedValue(listing); + mockPropertyRepo.findById.mockResolvedValue(property); + + const command = new UpdateListingCommand('listing-1', 'agent-1', 'Tiêu đề mới'); + const result = await handler.execute(command); + + expect(result.listingId).toBe('listing-1'); + }); + + it('rejects update from unauthorized user', async () => { + const listing = createListing('listing-1', 'seller-1'); + mockListingRepo.findById.mockResolvedValue(listing); + + const command = new UpdateListingCommand('listing-1', 'stranger', 'Tiêu đề mới'); + + await expect(handler.execute(command)).rejects.toThrow(/người bán|môi giới/); + }); + }); + + describe('content updates', () => { + it('updates title on the property entity', async () => { + const listing = createListing('listing-1', 'seller-1'); + const property = createProperty('prop-1'); + mockListingRepo.findById.mockResolvedValue(listing); + mockPropertyRepo.findById.mockResolvedValue(property); + + const command = new UpdateListingCommand('listing-1', 'seller-1', 'Tiêu đề mới nhất'); + const result = await handler.execute(command); + + expect(result.updatedFields).toContain('title'); + expect(mockPropertyRepo.update).toHaveBeenCalledTimes(1); + }); + + it('updates description on the property entity', async () => { + const listing = createListing('listing-1', 'seller-1'); + const property = createProperty('prop-1'); + mockListingRepo.findById.mockResolvedValue(listing); + mockPropertyRepo.findById.mockResolvedValue(property); + + const command = new UpdateListingCommand( + 'listing-1', 'seller-1', undefined, + 'Mô tả mới chi tiết hơn cho căn hộ', + ); + const result = await handler.execute(command); + + expect(result.updatedFields).toContain('description'); + expect(mockPropertyRepo.update).toHaveBeenCalledTimes(1); + }); + + it('updates priceVND on the listing entity', async () => { + const listing = createListing('listing-1', 'seller-1'); + const property = createProperty('prop-1'); + mockListingRepo.findById.mockResolvedValue(listing); + mockPropertyRepo.findById.mockResolvedValue(property); + + const command = new UpdateListingCommand( + 'listing-1', 'seller-1', undefined, undefined, + 3_000_000_000n, + ); + const result = await handler.execute(command); + + expect(result.updatedFields).toContain('priceVND'); + expect(mockListingRepo.update).toHaveBeenCalledTimes(1); + // property should NOT be updated when only listing fields change + expect(mockPropertyRepo.update).not.toHaveBeenCalled(); + }); + + it('updates amenities on the property entity', async () => { + const listing = createListing('listing-1', 'seller-1'); + const property = createProperty('prop-1'); + mockListingRepo.findById.mockResolvedValue(listing); + mockPropertyRepo.findById.mockResolvedValue(property); + + const command = new UpdateListingCommand( + 'listing-1', 'seller-1', undefined, undefined, + undefined, undefined, ['Hồ bơi', 'Gym', 'Sân tennis'], + ); + const result = await handler.execute(command); + + expect(result.updatedFields).toContain('amenities'); + expect(mockPropertyRepo.update).toHaveBeenCalledTimes(1); + }); + + it('rejects update with no fields provided', async () => { + const listing = createListing('listing-1', 'seller-1'); + mockListingRepo.findById.mockResolvedValue(listing); + + const command = new UpdateListingCommand('listing-1', 'seller-1'); + + await expect(handler.execute(command)).rejects.toThrow(/không có trường/i); + }); + }); + + describe('moderation re-submission', () => { + it('transitions ACTIVE listing to PENDING_REVIEW on content edit', async () => { + const listing = createListing('listing-1', 'seller-1', null, 'ACTIVE'); + const property = createProperty('prop-1'); + mockListingRepo.findById.mockResolvedValue(listing); + mockPropertyRepo.findById.mockResolvedValue(property); + + const command = new UpdateListingCommand('listing-1', 'seller-1', 'Tiêu đề mới'); + const result = await handler.execute(command); + + expect(result.status).toBe('PENDING_REVIEW'); + expect(result.resubmittedForModeration).toBe(true); + }); + + it('does NOT transition DRAFT listing status on content edit', async () => { + const listing = createListing('listing-1', 'seller-1', null, 'DRAFT'); + const property = createProperty('prop-1'); + mockListingRepo.findById.mockResolvedValue(listing); + mockPropertyRepo.findById.mockResolvedValue(property); + + const command = new UpdateListingCommand('listing-1', 'seller-1', 'Tiêu đề mới'); + const result = await handler.execute(command); + + expect(result.status).toBe('DRAFT'); + expect(result.resubmittedForModeration).toBe(false); + }); + }); + + describe('events and caching', () => { + it('publishes domain events after update', async () => { + const listing = createListing('listing-1', 'seller-1'); + const property = createProperty('prop-1'); + mockListingRepo.findById.mockResolvedValue(listing); + mockPropertyRepo.findById.mockResolvedValue(property); + + const command = new UpdateListingCommand('listing-1', 'seller-1', 'Tiêu đề mới'); + await handler.execute(command); + + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('invalidates listing cache after update', async () => { + const listing = createListing('listing-1', 'seller-1'); + const property = createProperty('prop-1'); + mockListingRepo.findById.mockResolvedValue(listing); + mockPropertyRepo.findById.mockResolvedValue(property); + + const command = new UpdateListingCommand('listing-1', 'seller-1', 'Tiêu đề mới'); + await handler.execute(command); + + expect(mockCache.invalidate).toHaveBeenCalled(); + expect(mockCache.invalidateByPrefix).toHaveBeenCalled(); + }); + }); + + it('throws NotFoundException for non-existent listing', async () => { + mockListingRepo.findById.mockResolvedValue(null); + + const command = new UpdateListingCommand('nonexistent', 'seller-1', 'Tiêu đề mới'); + + await expect(handler.execute(command)).rejects.toThrow('Listing'); + }); +}); diff --git a/apps/api/src/modules/listings/application/commands/update-listing/update-listing.command.ts b/apps/api/src/modules/listings/application/commands/update-listing/update-listing.command.ts new file mode 100644 index 0000000..3673ef8 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/update-listing/update-listing.command.ts @@ -0,0 +1,11 @@ +export class UpdateListingCommand { + constructor( + public readonly listingId: string, + public readonly userId: string, + public readonly title?: string, + public readonly description?: string, + public readonly priceVND?: bigint, + public readonly rentPriceMonthly?: bigint, + public readonly amenities?: string[], + ) {} +} diff --git a/apps/api/src/modules/listings/application/commands/update-listing/update-listing.handler.ts b/apps/api/src/modules/listings/application/commands/update-listing/update-listing.handler.ts new file mode 100644 index 0000000..7aa5fe6 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/update-listing/update-listing.handler.ts @@ -0,0 +1,133 @@ +import { Inject, InternalServerErrorException } from '@nestjs/common'; +import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { + DomainException, + ForbiddenException, + NotFoundException, + ValidationException, + CacheService, + CachePrefix, + type LoggerService, +} from '@modules/shared'; +import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository'; +import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository'; +import { UpdateListingCommand } from './update-listing.command'; + +export interface UpdateListingResult { + listingId: string; + status: string; + updatedFields: string[]; + resubmittedForModeration: boolean; +} + +@CommandHandler(UpdateListingCommand) +export class UpdateListingHandler implements ICommandHandler { + constructor( + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + @Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository, + private readonly eventBus: EventBus, + private readonly cache: CacheService, + private readonly logger: LoggerService, + ) {} + + async execute(command: UpdateListingCommand): Promise { + try { + // 1. Load listing + const listing = await this.listingRepo.findById(command.listingId); + if (!listing) { + throw new NotFoundException('Listing', command.listingId); + } + + // 2. Ownership check: only the seller or assigned agent can edit + const isOwner = listing.sellerId === command.userId; + const isAgent = listing.agentId !== null && listing.agentId === command.userId; + if (!isOwner && !isAgent) { + throw new ForbiddenException( + 'Chỉ người bán hoặc môi giới được giao mới có thể chỉnh sửa tin đăng', + ); + } + + // 3. Validate no fields are being sent (empty update) + const hasListingUpdates = + command.priceVND !== undefined || command.rentPriceMonthly !== undefined; + const hasPropertyUpdates = + command.title !== undefined || + command.description !== undefined || + command.amenities !== undefined; + + if (!hasListingUpdates && !hasPropertyUpdates) { + throw new ValidationException('Không có trường nào được cập nhật', {}); + } + + // 4. Load property for property-level updates + const property = await this.propertyRepo.findById(listing.propertyId); + if (!property) { + throw new NotFoundException('Property', listing.propertyId); + } + + // 5. Apply updates to domain entities + const allUpdatedFields: string[] = []; + + // Update listing fields (price, rentPriceMonthly) + if (hasListingUpdates) { + const listingUpdated = listing.updateContent({ + priceVND: command.priceVND, + rentPriceMonthly: command.rentPriceMonthly, + areaM2: property.areaM2, // needed for pricePerM2 recalculation + }); + allUpdatedFields.push(...listingUpdated); + } + + // Update property fields (title, description, amenities) + if (hasPropertyUpdates) { + const propertyUpdated = property.updateContent({ + title: command.title, + description: command.description, + amenities: command.amenities, + }); + allUpdatedFields.push(...propertyUpdated); + } + + // 6. If listing was ACTIVE, transition to PENDING_REVIEW for re-moderation + const previousStatus = listing.status; + listing.markEditedForReModeration(property.id, allUpdatedFields); + const resubmitted = previousStatus === 'ACTIVE' && listing.status === 'PENDING_REVIEW'; + + // 7. Persist changes + await this.listingRepo.update(listing); + if (hasPropertyUpdates) { + await this.propertyRepo.update(property); + } + + // 8. Publish domain events + const listingEvents = listing.clearDomainEvents(); + const propertyEvents = property.clearDomainEvents(); + for (const event of [...listingEvents, ...propertyEvents]) { + this.eventBus.publish(event); + } + + // 9. Invalidate caches + await Promise.all([ + this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)), + this.cache.invalidateByPrefix(CachePrefix.SEARCH), + this.cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT), + this.cache.invalidateByPrefix(CachePrefix.MARKET_REPORT), + ]); + + return { + listingId: listing.id, + status: listing.status, + updatedFields: allUpdatedFields, + resubmittedForModeration: resubmitted, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to update listing ${command.listingId}: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể cập nhật tin đăng'); + } + } +} diff --git a/apps/api/src/modules/listings/domain/events/listing-updated.event.ts b/apps/api/src/modules/listings/domain/events/listing-updated.event.ts new file mode 100644 index 0000000..3702efb --- /dev/null +++ b/apps/api/src/modules/listings/domain/events/listing-updated.event.ts @@ -0,0 +1,16 @@ +import type { ListingStatus } from '@prisma/client'; +import type { DomainEvent } from '@modules/shared'; + +export class ListingUpdatedEvent implements DomainEvent { + readonly eventName = 'listing.updated'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly propertyId: string, + public readonly sellerId: string, + public readonly previousStatus: ListingStatus, + public readonly newStatus: ListingStatus, + public readonly updatedFields: string[], + ) {} +} diff --git a/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts new file mode 100644 index 0000000..441613c --- /dev/null +++ b/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsString, + IsOptional, + MinLength, + IsArray, +} from 'class-validator'; + +export class UpdateListingDto { + @ApiPropertyOptional({ example: 'Căn hộ 3PN view sông - Vinhomes Central Park', description: 'Listing title (min 5 chars)' }) + @IsOptional() + @IsString() + @MinLength(5) + title?: string; + + @ApiPropertyOptional({ example: 'Căn hộ cao cấp 3 phòng ngủ, nội thất đầy đủ...', description: 'Detailed description (min 10 chars)' }) + @IsOptional() + @IsString() + @MinLength(10) + description?: string; + + @ApiPropertyOptional({ type: String, example: '5500000000', description: 'Price in VND (as string to support bigint)' }) + @IsOptional() + @Transform(({ value }) => (value != null ? BigInt(value) : undefined)) + priceVND?: bigint; + + @ApiPropertyOptional({ type: String, example: '25000000', description: 'Monthly rent price in VND (as string to support bigint)' }) + @IsOptional() + @Transform(({ value }) => (value != null ? BigInt(value) : undefined)) + rentPriceMonthly?: bigint; + + @ApiPropertyOptional({ example: ['Hồ bơi', 'Gym', 'Sân chơi trẻ em'], description: 'List of amenities' }) + @IsOptional() + @IsArray() + amenities?: string[]; + + // Note: media order changes are handled via separate media endpoints. + // propertyType, address, location CANNOT be changed after ACTIVE status. +} diff --git a/apps/web/components/listings/inquiry-modal.tsx b/apps/web/components/listings/inquiry-modal.tsx new file mode 100644 index 0000000..9a06bef --- /dev/null +++ b/apps/web/components/listings/inquiry-modal.tsx @@ -0,0 +1,217 @@ +'use client'; + +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { ApiError } from '@/lib/api-client'; +import { useAuthStore } from '@/lib/auth-store'; +import { useCreateInquiry } from '@/lib/hooks/use-inquiries'; + +interface InquiryModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + listingId: string; + listingTitle: string; + sellerName: string; +} + +export function InquiryModal({ + open, + onOpenChange, + listingId, + listingTitle, + sellerName, +}: InquiryModalProps) { + const { user, isAuthenticated } = useAuthStore(); + const createInquiry = useCreateInquiry(); + + const [message, setMessage] = React.useState(''); + const [phone, setPhone] = React.useState(''); + const [error, setError] = React.useState(null); + const [success, setSuccess] = React.useState(false); + + // Pre-fill phone from auth store when modal opens + React.useEffect(() => { + if (open && user?.phone) { + setPhone(user.phone); + } + if (open) { + setError(null); + setSuccess(false); + setMessage(''); + } + }, [open, user?.phone]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!isAuthenticated) { + window.location.href = '/login'; + onOpenChange(false); + return; + } + + const trimmedMessage = message.trim(); + const trimmedPhone = phone.trim(); + + if (!trimmedMessage) { + setError('Vui long nhap noi dung tin nhan'); + return; + } + if (!trimmedPhone || trimmedPhone.length < 9) { + setError('Vui long nhap so dien thoai hop le'); + return; + } + + setError(null); + + try { + await createInquiry.mutateAsync({ + listingId, + message: trimmedMessage, + phone: trimmedPhone, + }); + setSuccess(true); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + window.location.href = '/login'; + onOpenChange(false); + return; + } + setError( + err instanceof ApiError + ? err.message + : 'Gui tin nhan that bai. Vui long thu lai.', + ); + } + }; + + if (success) { + return ( + + + + Da gui thanh cong! + + Tin nhan cua ban da duoc gui den {sellerName}. Ho se lien he voi ban som nhat co the. + + +
+ + + +
+ + + +
+
+ ); + } + + return ( + + + + Nhan tin cho nguoi ban + + Gui tin nhan ve tin dang “{listingTitle}” + + + +
+ {error && ( +
+ {error} +
+ )} + +
+ +