Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ model User {
openHouseRsvps OpenHouseRsvp[]
transactionNotes TransactionNote[] @relation("TransactionNoteAuthor")
deletedProperties Property[] @relation("DeletedProperties")
priceChanges PropertyPriceHistory[] @relation("PriceChangeAuthor")

@@index([email])
@@index([isDeactivated])
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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")
Expand Down
109 changes: 109 additions & 0 deletions src/properties/dto/price-history.dto.ts
Original file line number Diff line number Diff line change
@@ -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';
}
4 changes: 2 additions & 2 deletions src/properties/properties.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 31 additions & 4 deletions src/properties/properties.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -242,7 +266,7 @@ export class PropertiesService {
}
}

return this.prisma.property.update({
const updatedProperty = await this.prisma.property.update({
where: { id },
data: {
...rest,
Expand All @@ -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) {
Expand Down
Loading