Skip to content
Open
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
2,680 changes: 1,690 additions & 990 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
"eslint-plugin-prettier": "^5.0.1",
"jest": "^29.7.0",
"prettier": "^3.1.0",
"prisma": "^6.19.3",
"prisma": "^6.19.2",
"rimraf": "^5.0.5",
"source-map-support": "^0.5.21",
"supertest": "^6.3.4",
Expand Down
19 changes: 19 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")
priceHistoryChangedBy PropertyPriceHistory[] @relation("PriceHistoryChangedBy")

@@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 All @@ -470,6 +472,23 @@ model Property {
@@map("properties")
}

model PropertyPriceHistory {
id String @id @default(uuid())
propertyId String @map("property_id")
oldPrice Decimal? // null for the initial record
newPrice Decimal
changeReason String? @map("change_reason") // e.g. "price_update", "market_adjustment"
changedById String? @map("changed_by_id")
createdAt DateTime @default(now()) @map("created_at")

property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
changedBy User? @relation("PriceHistoryChangedBy", fields: [changedById], references: [id], onDelete: SetNull)

@@index([propertyId])
@@index([createdAt])
@@map("property_price_history")
}

// Property gallery image with optimized variants and ordering (#TASK2)
model PropertyImage {
id String @id @default(uuid())
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { OpenHouseModule } from './open-house/open-house.module';
import { MortgageCalculatorModule } from './mortgage-calculator/mortgage-calculator.module';
import { SupportTicketsModule } from './support-tickets/support-tickets.module';
import { AuditModule } from './audit/audit.module';
import { PriceHistoryModule } from './price-history/price-history.module';

@Module({
imports: [
Expand Down Expand Up @@ -84,6 +85,7 @@ import { AuditModule } from './audit/audit.module';
MortgageCalculatorModule,
SupportTicketsModule,
AuditModule,
PriceHistoryModule,
],

controllers: [AppController],
Expand Down
76 changes: 76 additions & 0 deletions src/price-history/dto/price-history.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @ts-nocheck

import { IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { Type } from 'class-transformer';

export class RecordPriceChangeDto {
@IsNotEmpty()
@IsString()
newPrice: string;

@IsOptional()
@IsString()
changeReason?: string;
}

export class PriceHistoryQueryDto {
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
take?: number;

@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
skip?: number;

@IsOptional()
@IsString()
since?: string;

@IsOptional()
@IsString()
until?: string;

@IsOptional()
@IsBoolean()
includeChartData?: boolean;
}

export class PriceHistoryResponseDto {
id: string;
propertyId: string;
oldPrice: string | null;
newPrice: string;
changePercentage: number | null;
changeReason: string | null;
changedById: string | null;
createdAt: string;
}

export class PriceHistoryChartPointDto {
date: string;
price: string;
changePercentage: number | null;
}

export class PriceHistoryAnalyticsDto {
propertyId: string;
currentPrice: string;
initialPrice: string;
highestPrice: string;
lowestPrice: string;
totalChanges: number;
changePercentage: number;
priceChangeDirection: 'up' | 'down' | 'stable';
chartData: PriceHistoryChartPointDto[];
}

export class PaginatedPriceHistoryDto {
items: PriceHistoryResponseDto[];
total: number;
skip: number;
take: number;
}
78 changes: 78 additions & 0 deletions src/price-history/price-history.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// @ts-nocheck

import {
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Body,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { Decimal } from '@prisma/client/runtime/library';
import { PriceHistoryService } from './price-history.service';
import { PriceHistoryQueryDto, RecordPriceChangeDto } from './dto/price-history.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AuthUserPayload } from '../auth/types/auth-user.type';

@Controller('price-history')
export class PriceHistoryController {
constructor(private readonly priceHistoryService: PriceHistoryService) {}

/**
* Record a new price change for a property.
* Typically called internally when a property price is updated.
*/
@UseGuards(JwtAuthGuard)
@Post(':propertyId')
@HttpCode(HttpStatus.CREATED)
record(
@Param('propertyId', new ParseUUIDPipe()) propertyId: string,
@Body() dto: RecordPriceChangeDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.priceHistoryService.recordPriceChange({
propertyId,
oldPrice: null,
newPrice: new Decimal(dto.newPrice),
changeReason: dto.changeReason,
changedById: user.sub,
});
}

/**
* Get paginated price history for a property.
*/
@Get(':propertyId/history')
history(
@Param('propertyId', new ParseUUIDPipe()) propertyId: string,
@Query() query: PriceHistoryQueryDto,
) {
return this.priceHistoryService.getPriceHistory(propertyId, {
take: query.take,
skip: query.skip,
since: query.since,
until: query.until,
});
}

/**
* Get price analytics with chart data for a property.
*/
@Get(':propertyId/analytics')
analytics(@Param('propertyId', new ParseUUIDPipe()) propertyId: string) {
return this.priceHistoryService.getPriceAnalytics(propertyId);
}

/**
* Get the latest price change for a property.
*/
@Get(':propertyId/latest')
latest(@Param('propertyId', new ParseUUIDPipe()) propertyId: string) {
return this.priceHistoryService.getLatestPriceChange(propertyId);
}
}
15 changes: 15 additions & 0 deletions src/price-history/price-history.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// @ts-nocheck

import { Module } from '@nestjs/common';
import { PriceHistoryController } from './price-history.controller';
import { PriceHistoryService } from './price-history.service';
import { PrismaModule } from '../database/prisma.module';
import { AuthModule } from '../auth/auth.module';

@Module({
imports: [PrismaModule, AuthModule],
controllers: [PriceHistoryController],
providers: [PriceHistoryService],
exports: [PriceHistoryService],
})
export class PriceHistoryModule {}
Loading
Loading