← back to Jill Website
src/models/Property.ts
262 lines
import { Pool, QueryResult } from 'pg';
import { getPool } from '../config/database';
export interface Property {
id?: number;
name: string;
description: string;
amenities: string[];
images: string[];
airbnb_url: string;
capacity?: number;
bedrooms?: number;
bathrooms?: number;
size_sqm?: number;
price_per_night?: number;
created_at?: Date;
updated_at?: Date;
}
export interface PropertyValidationError {
field: string;
message: string;
}
export class PropertyModel {
private pool: Pool;
constructor() {
this.pool = getPool();
}
/**
* Validate property data
*/
private validate(property: Partial<Property>): PropertyValidationError[] {
const errors: PropertyValidationError[] = [];
if (!property.name || property.name.trim().length === 0) {
errors.push({ field: 'name', message: 'Property name is required' });
}
if (!property.description || property.description.trim().length === 0) {
errors.push({ field: 'description', message: 'Property description is required' });
}
if (!property.amenities || property.amenities.length === 0) {
errors.push({ field: 'amenities', message: 'At least one amenity is required' });
}
if (!property.images || property.images.length === 0) {
errors.push({ field: 'images', message: 'At least one image is required' });
}
if (property.images && property.images.length > 0) {
const invalidUrls = property.images.filter(url => !this.isValidUrl(url));
if (invalidUrls.length > 0) {
errors.push({ field: 'images', message: 'All image URLs must be valid' });
}
}
if (!property.airbnb_url || !this.isValidUrl(property.airbnb_url)) {
errors.push({ field: 'airbnb_url', message: 'Valid Airbnb URL is required' });
}
if (property.capacity !== undefined && property.capacity < 1) {
errors.push({ field: 'capacity', message: 'Capacity must be at least 1' });
}
if (property.bedrooms !== undefined && property.bedrooms < 0) {
errors.push({ field: 'bedrooms', message: 'Bedrooms cannot be negative' });
}
if (property.bathrooms !== undefined && property.bathrooms < 0) {
errors.push({ field: 'bathrooms', message: 'Bathrooms cannot be negative' });
}
if (property.size_sqm !== undefined && property.size_sqm < 0) {
errors.push({ field: 'size_sqm', message: 'Size cannot be negative' });
}
if (property.price_per_night !== undefined && property.price_per_night < 0) {
errors.push({ field: 'price_per_night', message: 'Price cannot be negative' });
}
return errors;
}
/**
* Validate URL format
*/
private isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Create a new property
*/
async create(property: Omit<Property, 'id' | 'created_at' | 'updated_at'>): Promise<Property> {
const errors = this.validate(property);
if (errors.length > 0) {
throw new Error(`Validation failed: ${errors.map(e => `${e.field}: ${e.message}`).join(', ')}`);
}
try {
const query = `
INSERT INTO properties (name, description, amenities, images, airbnb_url, capacity, bedrooms, bathrooms, size_sqm, price_per_night)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
`;
const values = [
property.name.trim(),
property.description.trim(),
JSON.stringify(property.amenities),
JSON.stringify(property.images),
property.airbnb_url.trim(),
property.capacity || null,
property.bedrooms || null,
property.bathrooms || null,
property.size_sqm || null,
property.price_per_night || null
];
const result: QueryResult<Property> = await this.pool.query(query, values);
const row = result.rows[0];
// Parse JSON fields back to arrays and convert decimals to numbers
return {
...row,
amenities: typeof row.amenities === 'string' ? JSON.parse(row.amenities) : row.amenities,
images: typeof row.images === 'string' ? JSON.parse(row.images) : row.images,
size_sqm: row.size_sqm ? parseFloat(row.size_sqm as any) : row.size_sqm,
price_per_night: row.price_per_night ? parseFloat(row.price_per_night as any) : row.price_per_night
};
} catch (error) {
console.error('Error creating property:', error);
throw new Error(`Failed to create property: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get property by ID
*/
async findById(id: number): Promise<Property | null> {
try {
const query = 'SELECT * FROM properties WHERE id = $1';
const result: QueryResult<Property> = await this.pool.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return {
...row,
amenities: typeof row.amenities === 'string' ? JSON.parse(row.amenities) : row.amenities,
images: typeof row.images === 'string' ? JSON.parse(row.images) : row.images,
size_sqm: row.size_sqm ? parseFloat(row.size_sqm as any) : row.size_sqm,
price_per_night: row.price_per_night ? parseFloat(row.price_per_night as any) : row.price_per_night
};
} catch (error) {
console.error('Error finding property:', error);
throw new Error(`Failed to find property: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get all properties
*/
async findAll(limit: number = 100, offset: number = 0): Promise<Property[]> {
try {
const query = 'SELECT * FROM properties ORDER BY created_at DESC LIMIT $1 OFFSET $2';
const result: QueryResult<Property> = await this.pool.query(query, [limit, offset]);
return result.rows.map(row => ({
...row,
amenities: typeof row.amenities === 'string' ? JSON.parse(row.amenities) : row.amenities,
images: typeof row.images === 'string' ? JSON.parse(row.images) : row.images,
size_sqm: row.size_sqm ? parseFloat(row.size_sqm as any) : row.size_sqm,
price_per_night: row.price_per_night ? parseFloat(row.price_per_night as any) : row.price_per_night
}));
} catch (error) {
console.error('Error finding properties:', error);
throw new Error(`Failed to find properties: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Update property
*/
async update(id: number, property: Partial<Property>): Promise<Property | null> {
const errors = this.validate(property);
if (errors.length > 0) {
throw new Error(`Validation failed: ${errors.map(e => `${e.field}: ${e.message}`).join(', ')}`);
}
try {
const query = `
UPDATE properties
SET name = $1, description = $2, amenities = $3, images = $4, airbnb_url = $5,
capacity = $6, bedrooms = $7, bathrooms = $8, size_sqm = $9, price_per_night = $10,
updated_at = NOW()
WHERE id = $11
RETURNING *
`;
const values = [
property.name?.trim(),
property.description?.trim(),
property.amenities ? JSON.stringify(property.amenities) : null,
property.images ? JSON.stringify(property.images) : null,
property.airbnb_url?.trim(),
property.capacity || null,
property.bedrooms || null,
property.bathrooms || null,
property.size_sqm || null,
property.price_per_night || null,
id
];
const result: QueryResult<Property> = await this.pool.query(query, values);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return {
...row,
amenities: typeof row.amenities === 'string' ? JSON.parse(row.amenities) : row.amenities,
images: typeof row.images === 'string' ? JSON.parse(row.images) : row.images,
size_sqm: row.size_sqm ? parseFloat(row.size_sqm as any) : row.size_sqm,
price_per_night: row.price_per_night ? parseFloat(row.price_per_night as any) : row.price_per_night
};
} catch (error) {
console.error('Error updating property:', error);
throw new Error(`Failed to update property: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Delete property
*/
async delete(id: number): Promise<boolean> {
try {
const query = 'DELETE FROM properties WHERE id = $1';
const result = await this.pool.query(query, [id]);
return result.rowCount !== null && result.rowCount > 0;
} catch (error) {
console.error('Error deleting property:', error);
throw new Error(`Failed to delete property: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}