Skip to content
Open
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
c414d34
chore: setup the new property table in schema
matzduniuk Dec 8, 2025
b23c6ae
chore: add one to many relation between properties and listings tables
matzduniuk Dec 8, 2025
ac0ef38
chore: update the listing service to connect to property when possible
matzduniuk Dec 8, 2025
b15fe2e
chore: setup basic structure for the properties endpoints
matzduniuk Dec 10, 2025
a9ea7ee
chore: add endpoint for retrieving paginated and filtrable properties…
matzduniuk Dec 10, 2025
13f01c7
chore: add endpoint to retrieve a single property based on the id
matzduniuk Dec 10, 2025
d34a4fc
chore: add a basic endpoint for creating a new property
matzduniuk Dec 10, 2025
fb66753
fix: fix argument typo
matzduniuk Dec 10, 2025
238aeb8
chore: add a basic endpoint for property update by id
matzduniuk Dec 10, 2025
cf53b5d
fix: add missing decorators to the property creation endpoint
matzduniuk Dec 10, 2025
2b452dd
chore: add a basic endpoint for property deletion by id
matzduniuk Dec 10, 2025
f219218
chore: update the validation pipes to used global default settings
matzduniuk Dec 10, 2025
15bc7b9
chore: add indecies to the name and id columns
matzduniuk Dec 10, 2025
d6686e3
fix: add missing dtos
matzduniuk Dec 10, 2025
81d9d98
fix: add missing url validation decorator
matzduniuk Dec 10, 2025
e2f40b4
chore: add migration files
matzduniuk Dec 11, 2025
81448a2
chore: add newly generated backend types
matzduniuk Dec 11, 2025
b1bcf09
chore: add listings endpoint for retrieving listings by property ID
matzduniuk Dec 11, 2025
b341279
fix: update import paths to relative format
matzduniuk Dec 11, 2025
ae3bdf4
fix: add small fixes to types and backend logic
matzduniuk Dec 12, 2025
2353efa
chore: add integration test for the new properties controller
matzduniuk Dec 12, 2025
50fe405
chore: add last endpoint integration tests
matzduniuk Dec 15, 2025
a788f37
fix: fix typos
matzduniuk Dec 15, 2025
1161b05
Merge branch 'main' into 5577/property-section-backend
matzduniuk Dec 15, 2025
474b273
Merge branch 'main' into 5577/property-section-backend
matzduniuk Dec 15, 2025
b5f605d
fix: add missing properties table jurisdiction relation
matzduniuk Dec 17, 2025
2a0b913
chore: setup new permissions for the properties operations
matzduniuk Dec 17, 2025
fbee33a
chore: update properties filtration to use jurisdiction ID
matzduniuk Dec 17, 2025
b22454d
chore: integrate the new permissions into the properties controller
matzduniuk Dec 17, 2025
67122e6
fix: fix typo
matzduniuk Dec 17, 2025
cfe0243
Merge branch 'main' into 5577/property-section-backend
matzduniuk Dec 17, 2025
46d1d94
fix: fix imports
matzduniuk Dec 17, 2025
a974137
fix: update integration tests to work with permissions service
matzduniuk Dec 17, 2025
d8e699e
chore: add new integration test for jurisdiction ID filter
matzduniuk Dec 17, 2025
1d47bb3
test: re-run testing suite
matzduniuk Dec 17, 2025
6e7af79
chore: add extra models to the properties controller
matzduniuk Dec 17, 2025
f3ef213
Merge branch 'main' into 5577/property-section-backend
matzduniuk Dec 17, 2025
1fe0c52
fix: fix imports
matzduniuk Dec 17, 2025
9845437
Merge branch '5577/property-section-backend' of https://github.com/bl…
matzduniuk Dec 17, 2025
cbe9162
Merge branch 'main' into 5577/property-section-backend
matzduniuk Dec 19, 2025
3680303
Merge branch 'main' into 5577/property-section-backend
matzduniuk Dec 29, 2025
448a970
fix: remove schema LA references
matzduniuk Dec 29, 2025
7a70a4c
fix: update composite indices to two single column indices
matzduniuk Dec 29, 2025
a325bf4
fix: fix typos
matzduniuk Dec 29, 2025
c59879a
fix: remove leftover swagger type function name change
matzduniuk Dec 29, 2025
535b4ee
fix: add documentation to new listings service function
matzduniuk Dec 29, 2025
678defa
fix: remove unused import
matzduniuk Dec 29, 2025
dd6f5d0
fix: update property endpoints handling logic
matzduniuk Dec 29, 2025
1330c7d
fix: add prisma and permission modules to properties modules
matzduniuk Dec 29, 2025
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
26 changes: 26 additions & 0 deletions api/prisma/migrations/42_add_poperties_table/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- AlterTable
ALTER TABLE "listings"
ADD COLUMN "property_id" UUID;

-- CreateTable
CREATE TABLE "properties" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(6) NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"url" TEXT,
"url_title" TEXT,
"jurisdiction_id" UUID,

CONSTRAINT "properties_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "properties_id_name_idx" ON "properties"("id", "name");

-- AddForeignKey
ALTER TABLE "listings" ADD CONSTRAINT "listings_property_id_fkey" FOREIGN KEY ("property_id") REFERENCES "properties"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "properties" ADD CONSTRAINT "properties_jurisdiction_id_fkey" FOREIGN KEY ("jurisdiction_id") REFERENCES "jurisdictions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
25 changes: 25 additions & 0 deletions api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ model Jurisdictions {
duplicateListingPermissions UserRoleEnum[] @map("duplicate_listing_permissions")
requiredListingFields String[] @default([]) @map("required_listing_fields")
visibleNeighborhoodAmenities NeighborhoodAmenitiesEnum[] @default([groceryStores, publicTransportation, schools, parksAndCommunityCenters, pharmacies, healthCareResources]) @map("visible_neighborhood_amenities")
properties Properties[]

@@map("jurisdictions")
}
Expand Down Expand Up @@ -736,6 +737,10 @@ model Listings {
Listings Listings[] @relation("copy_of")
lastUpdatedByUserId String? @map("last_updated_by_user_id") @db.Uuid
lastUpdatedByUser UserAccounts? @relation("last_updated_by_user", fields: [lastUpdatedByUserId], references: [id], onDelete: NoAction, onUpdate: NoAction)
// START LA SPECIFIC
property Properties? @relation(fields: [propertyId], references: [id])
propertyId String? @map("property_id") @db.Uuid
// END LA SPECIFIC

@@index([jurisdictionId])
@@map("listings")
Expand Down Expand Up @@ -1101,6 +1106,26 @@ model UserPreferences {

// END DETROIT SPECIFIC

// START LA SPECIFIC

model Properties {
id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6)
name String
description String?
url String?
urlTitle String? @map("url_title")
jurisdictionId String? @map("jurisdiction_id") @db.Uuid
listings Listings[]
jurisdictions Jurisdictions? @relation(fields: [jurisdictionId], references: [id], onDelete: NoAction, onUpdate: NoAction)

@@index([id, name])
@@map("properties")
}

// END LA SPECIFIC

model ScriptRuns {
id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6)
Expand Down
12 changes: 11 additions & 1 deletion api/src/controllers/listing.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export class ListingController {
@Get(`byMultiselectQuestion/:multiselectQuestionId`)
@ApiOperation({
summary: 'Get listings by multiselect question id',
operationId: 'retrieveListings',
operationId: 'retrieveListingsByMSQ',
})
@ApiOkResponse({ type: IdDTO, isArray: true })
async retrieveListings(
Expand All @@ -234,6 +234,16 @@ export class ListingController {
);
}

@Get('byProperty/:propertyId')
@ApiOperation({
summary: 'Get listings by assigned porperty ID',
operationId: 'retrieveListingsByProperty',
})
@ApiOkResponse({ type: IdDTO, isArray: true })
async retreiveListingsByProperty(@Param('propertyId') propertyId: string) {
return await this.listingService.findListingsWithPorperty(propertyId);
}

// NestJS best practice to have get(':id') at the bottom of the file
@Get(`:id`)
@ApiOperation({ summary: 'Get listing by id', operationId: 'retrieve' })
Expand Down
139 changes: 139 additions & 0 deletions api/src/controllers/property.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
Body,
Controller,
Get,
Post,
Query,
Param,
ParseUUIDPipe,
Put,
UsePipes,
ValidationPipe,
Delete,
UseGuards,
Request,
} from '@nestjs/common';
import { Request as ExpressRequest } from 'express';
import {
ApiExtraModels,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { PermissionTypeDecorator } from '../decorators/permission-type.decorator';
import { PaginatedPropertyDto } from '../dtos/properties/paginated-property.dto';
import { PropertyQueryParams } from '../dtos/properties/property-query-params.dto';
import { PropertyService } from '../services/property.service';
import PropertyCreate from '../dtos/properties/property-create.dto';
import { PropertyUpdate } from '../dtos/properties/property-update.dto';
import Property from '../dtos/properties/property.dto';
import { IdDTO } from '../dtos/shared/id.dto';
import { SuccessDTO } from '../dtos/shared/success.dto';
import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options';
import { OptionalAuthGuard } from '../guards/optional.guard';
import { mapTo } from '../utilities/mapTo';
import { User } from '../dtos/users/user.dto';
import { PaginationMeta } from '../dtos/shared/pagination.dto';
import { PropertyFilterParams } from '../dtos/properties/property-filter-params.dto';

@Controller('properties')
@ApiTags('properties')
@UseGuards(OptionalAuthGuard)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: the optionalAuthGuard allows for users that aren't logged in to hit these endpoints. Is that correct or should this require that a user is logged in to hit these endpoints?

Copy link
Collaborator Author

@matzduniuk matzduniuk Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@YazeedLoonat There were no specified requirements as to how exactly permissions should be handled. In my opinion, some of those endpoints should be protected against unwanted access; therefore, I was basing my work on how the listings and MSQ endpoints are implemented.

@ApiExtraModels(
PropertyCreate,
PropertyUpdate,
PropertyQueryParams,
PropertyFilterParams,
PaginationMeta,
IdDTO,
)
@PermissionTypeDecorator('property')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: this would get this to leverage the old property permissions from permission_policy.csv is that correct or do those perms need to be updated?

export class PropertyController {
constructor(private readonly propertyService: PropertyService) {}

@Get()
@ApiOperation({
summary: 'Get a paginated set of properties',
operationId: 'list',
})
@UsePipes(new ValidationPipe(defaultValidationPipeOptions))
@ApiOkResponse({ type: PaginatedPropertyDto })
public async getPaginatedSet(@Query() queryParams: PropertyQueryParams) {
return await this.propertyService.list(queryParams);
}

@Get(':id')
@ApiOperation({
summary: 'Get a property object by ID',
operationId: 'getById',
})
public async getPropertyById(
@Param('id', new ParseUUIDPipe({ version: '4' })) propertyId: string,
) {
return this.propertyService.findOne(propertyId);
}

@Post('list')
@ApiOperation({
summary: 'Get a paginated filtered set of properties',
operationId: 'filterableList',
})
@UsePipes(new ValidationPipe(defaultValidationPipeOptions))
@ApiOkResponse({ type: PaginatedPropertyDto })
public async getFiltrablePaginatedSet(
@Body() queryParams: PropertyQueryParams,
) {
return await this.propertyService.list(queryParams);
}

@Post()
@ApiOperation({
summary: 'Add a new property entry',
operationId: 'add',
})
@UsePipes(new ValidationPipe(defaultValidationPipeOptions))
@ApiOkResponse({ type: Property })
public async addProperty(
@Request() req: ExpressRequest,
@Body() propertyDto: PropertyCreate,
) {
return await this.propertyService.create(
propertyDto,
mapTo(User, req['user']),
);
}

@Put()
@ApiOperation({
summary: 'Update an exiting property entry by id',
operationId: 'update',
})
@UsePipes(new ValidationPipe(defaultValidationPipeOptions))
@ApiOkResponse({ type: Property })
public async updateProperty(
@Request() req: ExpressRequest,
@Body() propertyDto: PropertyUpdate,
) {
return await this.propertyService.update(
propertyDto,
mapTo(User, req['user']),
);
}

@Delete()
@ApiOperation({
summary: 'Delete an property entry by ID',
operationId: 'deleteById',
})
@UsePipes(new ValidationPipe(defaultValidationPipeOptions))
@ApiOkResponse({ type: SuccessDTO })
public async deleteById(
@Request() req: ExpressRequest,
@Body() idDto: IdDTO,
) {
return await this.propertyService.deleteOne(
idDto.id,
mapTo(User, req['user']),
);
}
}
10 changes: 10 additions & 0 deletions api/src/dtos/listings/listing.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
import { ValidateListingDeposit } from '../../decorators/validate-listing-deposit.decorator';
import { ListingDocuments } from './listing-documents.dto';
import { ValidateListingImages } from '../../decorators/validate-listing-images.decorator';
import Property from '../properties/property.dto';

class Listing extends AbstractDTO {
@Expose()
Expand Down Expand Up @@ -1165,6 +1166,15 @@ class Listing extends AbstractDTO {
},
)
lastUpdatedByUser?: IdDTO;

@Expose()
@ValidateListingPublish('property', {
groups: [ValidationsGroupsEnum.default],
})
@ValidateNested({ groups: [ValidationsGroupsEnum.default] })
@Type(() => Property)
@ApiPropertyOptional({ type: Property })
property?: Property;
}

export { Listing as default, Listing };
6 changes: 6 additions & 0 deletions api/src/dtos/properties/paginated-property.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { PaginationFactory } from '../shared/pagination.dto';
import Property from './property.dto';

export class PaginatedPropertyDto extends PaginationFactory<Property>(
Property,
) {}
8 changes: 8 additions & 0 deletions api/src/dtos/properties/property-create.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { OmitType } from '@nestjs/swagger';
import Property from './property.dto';

export default class PropertyCreate extends OmitType(Property, [
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: to get this to match up to our pattern you should have this extend the update dto instead of the base dto

'id',
'createdAt',
'updatedAt',
]) {}
14 changes: 14 additions & 0 deletions api/src/dtos/properties/property-filter-params.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Expose } from 'class-transformer';
import { BaseFilter } from '../shared/base-filter.dto';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum';

export class PropertyFilterParams extends BaseFilter {
@Expose()
@ApiPropertyOptional({
example: 'uuid',
})
@IsString({ groups: [ValidationsGroupsEnum.default] })
jurisdiction?: string;
}
27 changes: 27 additions & 0 deletions api/src/dtos/properties/property-query-params.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Expose, Type } from 'class-transformer';
import { PaginationAllowsAllQueryParams } from '../shared/pagination.dto';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, MinLength, ValidateNested } from 'class-validator';
import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum';
import { PropertyFilterParams } from './property-filter-params.dto';

export class PropertyQueryParams extends PaginationAllowsAllQueryParams {
@Expose()
@ApiPropertyOptional({
example: 'search',
})
@MinLength(3, {
message: 'Search must be at least 3 characters',
groups: [ValidationsGroupsEnum.default],
})
search?: string;

@Expose()
@ApiPropertyOptional({
type: [String],
})
@IsArray({ groups: [ValidationsGroupsEnum.default] })
@Type(() => PropertyFilterParams)
@ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true })
filter?: PropertyFilterParams[];
}
16 changes: 16 additions & 0 deletions api/src/dtos/properties/property-update.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import Property from './property.dto';
import { Expose } from 'class-transformer';
import { IsString } from 'class-validator';
import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum';

export class PropertyUpdate extends OmitType(Property, [
'createdAt',
'updatedAt',
'name',
]) {
@Expose()
@IsString({ groups: [ValidationsGroupsEnum.default] })
@ApiPropertyOptional()
name?: string;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: since name is required in the db why is it optional here?

}
39 changes: 39 additions & 0 deletions api/src/dtos/properties/property.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Expose, Type } from 'class-transformer';
import { AbstractDTO } from '../shared/abstract.dto';
import { IsDefined, IsString, IsUrl, ValidateNested } from 'class-validator';
import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IdDTO } from '../shared/id.dto';

export default class Property extends AbstractDTO {
@Expose()
@IsDefined({ groups: [ValidationsGroupsEnum.default] })
@IsString({ groups: [ValidationsGroupsEnum.default] })
@ApiProperty()
name: string;

@Expose()
@IsString({ groups: [ValidationsGroupsEnum.default] })
@ApiPropertyOptional()
description?: string;

@Expose()
@IsString({ groups: [ValidationsGroupsEnum.default] })
@IsUrl(
{ require_protocol: true },
{ groups: [ValidationsGroupsEnum.default] },
)
@ApiPropertyOptional()
url?: string;

@Expose()
@IsString({ groups: [ValidationsGroupsEnum.default] })
@ApiPropertyOptional()
urlTitle?: string;

@Expose()
@ValidateNested({ groups: [ValidationsGroupsEnum.default] })
@Type(() => IdDTO)
@ApiPropertyOptional({ type: IdDTO })
jurisdictions?: IdDTO;
}
Loading
Loading