diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 65faa340..388ed0c6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -240,6 +240,7 @@ model User { openHouseRsvps OpenHouseRsvp[] transactionNotes TransactionNote[] @relation("TransactionNoteAuthor") deletedProperties Property[] @relation("DeletedProperties") + priceChanges PropertyPriceHistory[] @relation("PriceChangeAuthor") @@index([email]) @@index([isDeactivated]) @@ -458,6 +459,7 @@ model Property { neighborhood Neighborhood? @relation(fields: [neighborhoodId], references: [id], onDelete: SetNull) amenities PropertyAmenity[] deletedBy User? @relation("DeletedProperties", fields: [deletedById], references: [id], onDelete: SetNull) + priceHistory PropertyPriceHistory[] @@index([ownerId]) @@index([status]) @@ -513,6 +515,26 @@ model PropertyFavorite { @@map("property_favorites") } +// Property price history tracking +model PropertyPriceHistory { + id String @id @default(uuid()) + propertyId String @map("property_id") + oldPrice Decimal @map("old_price") + newPrice Decimal @map("new_price") + changeAmount Decimal @map("change_amount") + changePercentage Float @map("change_percentage") + changedBy String? @map("changed_by") + changeReason String? @map("change_reason") @db.Text + createdAt DateTime @default(now()) @map("created_at") + + property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade) + changedByUser User? @relation("PriceChangeAuthor", fields: [changedBy], references: [id], onDelete: SetNull) + + @@index([propertyId, createdAt]) + @@index([createdAt]) + @@map("property_price_history") +} + model PropertyView { id String @id @default(uuid()) propertyId String @map("property_id") diff --git a/src/properties/dto/price-history.dto.ts b/src/properties/dto/price-history.dto.ts new file mode 100644 index 00000000..6223c57e --- /dev/null +++ b/src/properties/dto/price-history.dto.ts @@ -0,0 +1,109 @@ +// @ts-nocheck + +import { IsString, IsOptional, IsNumber, IsDateString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { InputType, Field, Float, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class PriceHistoryResponseDto { + @Field() + id: string; + + @Field() + propertyId: string; + + @Field(() => Float) + oldPrice: number; + + @Field(() => Float) + newPrice: number; + + @Field(() => Float) + changeAmount: number; + + @Field(() => Float) + changePercentage: number; + + @Field({ nullable: true }) + changedBy?: string; + + @Field({ nullable: true }) + changeReason?: string; + + @Field() + createdAt: Date; +} + +@ObjectType() +export class ChartDataPointDto { + @Field() + date: string; + + @Field(() => Float) + price: number; + + @Field(() => Float, { nullable: true }) + changePercentage?: number; +} + +@ObjectType() +export class PriceHistoryChartDataDto { + @Field() + propertyId: string; + + @Field(() => [ChartDataPointDto]) + data: ChartDataPointDto[]; + + @Field(() => Float) + currentPrice: number; + + @Field(() => Float) + initialPrice: number; + + @Field(() => Float) + totalChangePercentage: number; + + @Field() + totalChanges: number; +} + +@InputType() +export class GetPriceHistoryQueryDto { + @Field({ nullable: true }) + @IsOptional() + @IsDateString() + startDate?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsDateString() + endDate?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsNumber() + limit?: number; + + @Field({ nullable: true }) + @IsOptional() + @IsNumber() + offset?: number; +} + +@InputType() +export class GetChartDataQueryDto { + @Field({ nullable: true }) + @IsOptional() + @IsDateString() + startDate?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsDateString() + endDate?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + interval?: 'day' | 'week' | 'month' | 'year'; +} diff --git a/src/properties/properties.controller.ts b/src/properties/properties.controller.ts index 08324a16..5499be55 100644 --- a/src/properties/properties.controller.ts +++ b/src/properties/properties.controller.ts @@ -78,8 +78,8 @@ export class PropertiesController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.AGENT, UserRole.ADMIN) @Put(':id') - update(@Param('id') id: string, @Body() updatePropertyDto: UpdatePropertyDto) { - return this.propertiesService.update(id, updatePropertyDto); + update(@Param('id') id: string, @Body() updatePropertyDto: UpdatePropertyDto, @CurrentUser() user: AuthUserPayload) { + return this.propertiesService.update(id, updatePropertyDto, user.sub); } @UseGuards(JwtAuthGuard, RolesGuard) diff --git a/src/properties/properties.service.ts b/src/properties/properties.service.ts index 0ebae3f0..13f4d28d 100644 --- a/src/properties/properties.service.ts +++ b/src/properties/properties.service.ts @@ -172,14 +172,38 @@ export class PropertiesService { }); } - async update(id: string, updatePropertyDto: UpdatePropertyDto) { + async update(id: string, updatePropertyDto: UpdatePropertyDto, userId?: string) { const { price, squareFeet, lotSize, latitude, longitude, hoaMonthlyFee, ...rest } = updatePropertyDto; + // Get existing property to check for price changes + const existingProperty = await this.prisma.property.findUnique({ where: { id } }); + if (!existingProperty) throw new NotFoundException(`Property ${id} not found`); + + // Record price change if price is being updated + if (price !== undefined && price !== Number(existingProperty.price)) { + const oldPrice = existingProperty.price; + const newPrice = new Decimal(price.toString()); + const changeAmount = newPrice.minus(oldPrice); + const changePercentage = oldPrice.equals(new Decimal(0)) + ? 0 + : changeAmount.div(oldPrice).mul(100).toNumber(); + + await this.prisma.propertyPriceHistory.create({ + data: { + propertyId: id, + oldPrice, + newPrice, + changeAmount, + changePercentage, + changedBy: userId, + changeReason: rest.changeReason || null, + }, + }); + } + // Duplicate address check (if address fields are being updated) if (rest.address || rest.city || rest.state || rest.zipCode || rest.country) { - const existingProperty = await this.prisma.property.findUnique({ where: { id } }); - if (!existingProperty) throw new NotFoundException(`Property ${id} not found`); const newAddress = { address: rest.address ?? existingProperty.address, city: rest.city ?? existingProperty.city, @@ -242,7 +266,7 @@ export class PropertiesService { } } - return this.prisma.property.update({ + const updatedProperty = await this.prisma.property.update({ where: { id }, data: { ...rest, @@ -256,6 +280,9 @@ export class PropertiesService { expiryDate: updatePropertyDto.expiryDate, }, }); + + await this.cacheService.invalidateByTag(CACHE_TAGS.PROPERTIES); + return updatedProperty; } async remove(id: string, user: AuthUserPayload) {