← 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'}`);
    }
  }
}